blastproof 0.20.0 → 0.22.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 +2 -2
- package/dist/cli.js +134 -48
- package/dist/cli.js.map +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -275,11 +275,11 @@ The application under test is not trusted input: its page content reaches the mo
|
|
|
275
275
|
|
|
276
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.
|
|
277
277
|
|
|
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.
|
|
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
279
|
|
|
280
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)).
|
|
281
281
|
|
|
282
|
-
**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.*}}`.
|
|
283
283
|
|
|
284
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.
|
|
285
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]
|
|
@@ -333,22 +333,35 @@ function referencedEnvVars(text) {
|
|
|
333
333
|
}
|
|
334
334
|
return [...names];
|
|
335
335
|
}
|
|
336
|
+
function placeholdersAsLabels(text) {
|
|
337
|
+
return text.replace(ENV_PLACEHOLDER, (_match, name) => redactionLabel(name));
|
|
338
|
+
}
|
|
336
339
|
function escapeRegExp(value) {
|
|
337
340
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
338
341
|
}
|
|
339
342
|
function secretPattern(secret) {
|
|
340
343
|
return new RegExp(escapeRegExp(secret).replace(/\s+/g, "\\s+"), "gi");
|
|
341
344
|
}
|
|
342
|
-
|
|
343
|
-
|
|
345
|
+
var REDACTION_PREFIX = "[redacted";
|
|
346
|
+
function redactionLabel(name) {
|
|
347
|
+
return name === void 0 ? `${REDACTION_PREFIX}]` : `${REDACTION_PREFIX} ${name}]`;
|
|
348
|
+
}
|
|
349
|
+
var LABEL = /\[redacted ([A-Za-z_][A-Za-z0-9_]*)\]/g;
|
|
350
|
+
function labelledVariables(text) {
|
|
351
|
+
const names = /* @__PURE__ */ new Set();
|
|
352
|
+
for (const match of text.matchAll(LABEL)) if (match[1]) names.add(match[1]);
|
|
353
|
+
return [...names];
|
|
354
|
+
}
|
|
355
|
+
function compile(secrets, names) {
|
|
356
|
+
return [...secrets].filter(Boolean).sort((a, b) => b.length - a.length).map((value) => ({ value, pattern: secretPattern(value), label: redactionLabel(names?.get(value)) }));
|
|
344
357
|
}
|
|
345
358
|
function maskCompiled(text, compiled, onNearMiss) {
|
|
346
359
|
let masked = text;
|
|
347
|
-
for (const { value, pattern } of compiled) {
|
|
360
|
+
for (const { value, pattern, label } of compiled) {
|
|
348
361
|
pattern.lastIndex = 0;
|
|
349
362
|
masked = masked.replace(pattern, (found) => {
|
|
350
363
|
if (found !== value) onNearMiss?.(value, found);
|
|
351
|
-
return
|
|
364
|
+
return label;
|
|
352
365
|
});
|
|
353
366
|
}
|
|
354
367
|
return masked;
|
|
@@ -388,7 +401,7 @@ var SecretsMask = class {
|
|
|
388
401
|
}
|
|
389
402
|
}
|
|
390
403
|
mask(text) {
|
|
391
|
-
this.compiled ??= compile(this.secrets);
|
|
404
|
+
this.compiled ??= compile(this.secrets, this.names);
|
|
392
405
|
return maskCompiled(text, this.compiled, (secret) => {
|
|
393
406
|
const name = this.names.get(secret);
|
|
394
407
|
if (name !== void 0) this.nearMissed.add(name);
|
|
@@ -677,7 +690,26 @@ var StepRecovery = class {
|
|
|
677
690
|
* and #28 has now produced one on three applications.
|
|
678
691
|
*/
|
|
679
692
|
refusalFor(action) {
|
|
680
|
-
return this.repeatedCommitRefusal(action) ?? this.unsourcedValueRefusal(action);
|
|
693
|
+
return this.repeatedCommitRefusal(action) ?? this.redactionLabelRefusal(action) ?? this.unsourcedValueRefusal(action);
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Refuses a typed value containing a redaction label (design
|
|
697
|
+
* label-a-redaction-with-its-variable, D3).
|
|
698
|
+
*
|
|
699
|
+
* Checked before the source check because the source check would admit it:
|
|
700
|
+
* `observe` credits the model with the masked snapshot, and the label is in
|
|
701
|
+
* the masked snapshot. It is the mask's own writing, not the application's, so
|
|
702
|
+
* typing it would put the words "[redacted TEST_EMAIL]" into a real field.
|
|
703
|
+
* A prefix match rather than the exact label, because a model that copies
|
|
704
|
+
* part of one, or rebuilds one around another name, is equally wrong.
|
|
705
|
+
*
|
|
706
|
+
* Translating it back into its placeholder was the alternative, and it is the
|
|
707
|
+
* exemption #66 closed: the model would be choosing a secret to type.
|
|
708
|
+
*/
|
|
709
|
+
redactionLabelRefusal(action) {
|
|
710
|
+
if (!SOURCED_VALUE_ACTIONS.has(action.action)) return void 0;
|
|
711
|
+
if (!action.value?.includes(REDACTION_PREFIX)) return void 0;
|
|
712
|
+
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.`;
|
|
681
713
|
}
|
|
682
714
|
repeatedCommitRefusal(action) {
|
|
683
715
|
if (!COMMIT_ACTIONS.has(action.action)) return void 0;
|
|
@@ -1449,7 +1481,7 @@ Rules:
|
|
|
1449
1481
|
- 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.
|
|
1450
1482
|
- 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.
|
|
1451
1483
|
- 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.
|
|
1452
|
-
-
|
|
1484
|
+
- \`[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.
|
|
1453
1485
|
- Keep reasoning to one short sentence.`;
|
|
1454
1486
|
}
|
|
1455
1487
|
function agentUserPrompt(input) {
|
|
@@ -1485,7 +1517,7 @@ The record is not evidence that the step's outcome holds. An action reported as
|
|
|
1485
1517
|
|
|
1486
1518
|
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.
|
|
1487
1519
|
|
|
1488
|
-
|
|
1520
|
+
\`[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.`;
|
|
1489
1521
|
}
|
|
1490
1522
|
function assertUserPrompt(step, expectation, snapshot, stepHistory) {
|
|
1491
1523
|
const parts = [`Step under test: ${step}`];
|
|
@@ -1520,7 +1552,7 @@ Rules:
|
|
|
1520
1552
|
- **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.
|
|
1521
1553
|
- **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.
|
|
1522
1554
|
- **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.
|
|
1523
|
-
- If a step needs a credential or any
|
|
1555
|
+
- 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.
|
|
1524
1556
|
- Keep the whole test to a handful of steps: one journey, not an exhaustive suite.`;
|
|
1525
1557
|
}
|
|
1526
1558
|
function plannerUserPrompt(input) {
|
|
@@ -1584,8 +1616,10 @@ function parseAgentAction(value) {
|
|
|
1584
1616
|
return parsedAgentActionSchema.safeParse(value);
|
|
1585
1617
|
}
|
|
1586
1618
|
var assertJudgmentSchema = z2.object({
|
|
1587
|
-
|
|
1588
|
-
|
|
1619
|
+
reason: z2.string().describe("One sentence: what the STEP requires to be true now, and whether the snapshot shows it."),
|
|
1620
|
+
pass: z2.boolean().describe(
|
|
1621
|
+
"Whether the snapshot shows the STEP's own 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."
|
|
1622
|
+
)
|
|
1589
1623
|
});
|
|
1590
1624
|
var generatedTestSchema = z2.object({
|
|
1591
1625
|
summary: z2.string().min(1).describe('One line naming the user journey this test covers, e.g. "Applying a discount updates the cart total".'),
|
|
@@ -1642,11 +1676,20 @@ function createBrain(model, generate = generateObject, budget) {
|
|
|
1642
1676
|
return parsed.data;
|
|
1643
1677
|
},
|
|
1644
1678
|
async judge(step, expectation, snapshot, stepHistory) {
|
|
1679
|
+
const asJudged = stepHistory?.map((entry) => ({
|
|
1680
|
+
action: placeholdersAsLabels(entry.action),
|
|
1681
|
+
result: placeholdersAsLabels(entry.result)
|
|
1682
|
+
}));
|
|
1645
1683
|
const result = await countedGenerate(generate, budget, {
|
|
1646
1684
|
model,
|
|
1647
1685
|
schema: assertJudgmentSchema,
|
|
1648
1686
|
system: assertSystemPrompt(),
|
|
1649
|
-
prompt: assertUserPrompt(
|
|
1687
|
+
prompt: assertUserPrompt(
|
|
1688
|
+
placeholdersAsLabels(step),
|
|
1689
|
+
placeholdersAsLabels(expectation),
|
|
1690
|
+
snapshot,
|
|
1691
|
+
asJudged
|
|
1692
|
+
),
|
|
1650
1693
|
temperature: 0
|
|
1651
1694
|
});
|
|
1652
1695
|
const parsed = assertJudgmentSchema.safeParse(result.object);
|
|
@@ -1655,10 +1698,26 @@ function createBrain(model, generate = generateObject, budget) {
|
|
|
1655
1698
|
`Model returned an invalid assert judgment: ${parsed.error.issues[0]?.message ?? "unknown"}`
|
|
1656
1699
|
);
|
|
1657
1700
|
}
|
|
1658
|
-
return parsed.data;
|
|
1701
|
+
return secretMismatch(placeholdersAsLabels(step), snapshot, asJudged, parsed.data);
|
|
1659
1702
|
}
|
|
1660
1703
|
};
|
|
1661
1704
|
}
|
|
1705
|
+
function secretMismatch(judgedStep, snapshot, judgedRecord, judgment) {
|
|
1706
|
+
if (!judgment.pass) return judgment;
|
|
1707
|
+
const onPage = labelledVariables(snapshot);
|
|
1708
|
+
const inRecord = labelledVariables((judgedRecord ?? []).map((e) => `${e.action}
|
|
1709
|
+
${e.result}`).join("\n"));
|
|
1710
|
+
for (const name of labelledVariables(judgedStep)) {
|
|
1711
|
+
const others = onPage.filter((other) => other !== name);
|
|
1712
|
+
if (onPage.includes(name) || inRecord.includes(name) || others.length === 0) continue;
|
|
1713
|
+
const shown = others.map((other) => redactionLabel(other)).join(", ");
|
|
1714
|
+
return {
|
|
1715
|
+
pass: false,
|
|
1716
|
+
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})`
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
return judgment;
|
|
1720
|
+
}
|
|
1662
1721
|
function createPlanner(model, generate = generateObject, budget) {
|
|
1663
1722
|
return {
|
|
1664
1723
|
async planTest(input) {
|
|
@@ -1736,6 +1795,41 @@ import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
|
|
|
1736
1795
|
import path6 from "path";
|
|
1737
1796
|
import { stringify } from "yaml";
|
|
1738
1797
|
|
|
1798
|
+
// src/runner/authoring.ts
|
|
1799
|
+
var VALUE_VERBS = ["fill", "enter", "type", "input", "set"];
|
|
1800
|
+
var LEADING_VALUE_VERB = new RegExp(`^\\s*(?:${VALUE_VERBS.join("|")})\\b`, "i");
|
|
1801
|
+
var CONNECTOR_WORDS = ["with", "to", "as", "using", "into", "from", "in"];
|
|
1802
|
+
var CONNECTOR_WORD = new RegExp(`\\b(?:${CONNECTOR_WORDS.join("|")})\\b`, "i");
|
|
1803
|
+
var CONNECTOR_SYMBOL = /["'`:=]|\{\{env\./i;
|
|
1804
|
+
var PHRASAL_IN = new RegExp(`^(\\s*(?:${VALUE_VERBS.join("|")}))\\s+in\\b`, "i");
|
|
1805
|
+
function namesAValue(step) {
|
|
1806
|
+
const withoutPhrasal = step.replace(PHRASAL_IN, "$1");
|
|
1807
|
+
return CONNECTOR_WORD.test(withoutPhrasal) || CONNECTOR_SYMBOL.test(withoutPhrasal);
|
|
1808
|
+
}
|
|
1809
|
+
function namesNoValue(step) {
|
|
1810
|
+
return LEADING_VALUE_VERB.test(step) && !namesAValue(step);
|
|
1811
|
+
}
|
|
1812
|
+
function entersNamedValue(step) {
|
|
1813
|
+
return LEADING_VALUE_VERB.test(step) && namesAValue(step);
|
|
1814
|
+
}
|
|
1815
|
+
function detectMissingValues(tests) {
|
|
1816
|
+
const findings = [];
|
|
1817
|
+
for (const test of tests) {
|
|
1818
|
+
for (const origin of ["setup", "steps"]) {
|
|
1819
|
+
const steps = origin === "setup" ? test.setup ?? [] : test.steps;
|
|
1820
|
+
steps.forEach((step, position) => {
|
|
1821
|
+
if (namesNoValue(step)) {
|
|
1822
|
+
findings.push({ test, origin, index: position + 1, step });
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
return { findings };
|
|
1828
|
+
}
|
|
1829
|
+
function suggestValueClause(step) {
|
|
1830
|
+
return `${step.trimEnd()} with <value>`;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1739
1833
|
// src/runner/testfile.ts
|
|
1740
1834
|
import { readFile as readFile3, readdir } from "fs/promises";
|
|
1741
1835
|
import path5 from "path";
|
|
@@ -1811,9 +1905,20 @@ var CREDENTIAL_WORD = /\b(password|passwd|api[ _-]?key|token|secret|credential)\
|
|
|
1811
1905
|
var QUOTED_LITERAL = /["'][^"']+["']/;
|
|
1812
1906
|
function findSecretLiterals(steps) {
|
|
1813
1907
|
return steps.filter(
|
|
1814
|
-
(step) => CREDENTIAL_WORD.test(step) &&
|
|
1908
|
+
(step) => CREDENTIAL_WORD.test(step) && !step.includes("{{env.") && (QUOTED_LITERAL.test(step) || entersNamedValue(step))
|
|
1815
1909
|
);
|
|
1816
1910
|
}
|
|
1911
|
+
var EMAIL_ADDRESS = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g;
|
|
1912
|
+
function findUnsourcedEmails(steps, snapshot) {
|
|
1913
|
+
const page = snapshot.toLowerCase();
|
|
1914
|
+
const found = [];
|
|
1915
|
+
steps.forEach((text, step) => {
|
|
1916
|
+
for (const [address] of text.matchAll(EMAIL_ADDRESS)) {
|
|
1917
|
+
if (!page.includes(address.toLowerCase())) found.push({ step, address });
|
|
1918
|
+
}
|
|
1919
|
+
});
|
|
1920
|
+
return found;
|
|
1921
|
+
}
|
|
1817
1922
|
function routeToSlug(route) {
|
|
1818
1923
|
const slug = route.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
1819
1924
|
return slug || "home";
|
|
@@ -1845,9 +1950,10 @@ async function generateForRoute(page, options) {
|
|
|
1845
1950
|
`Cannot load ${url}: ${error instanceof Error ? error.message : String(error)}`
|
|
1846
1951
|
);
|
|
1847
1952
|
}
|
|
1953
|
+
const pageSnapshot = mask(await takeSnapshot(page));
|
|
1848
1954
|
const generated = await brain.planTest({
|
|
1849
1955
|
route,
|
|
1850
|
-
snapshot:
|
|
1956
|
+
snapshot: pageSnapshot,
|
|
1851
1957
|
changedFiles
|
|
1852
1958
|
});
|
|
1853
1959
|
const leaked = findSecretLiterals(generated.steps);
|
|
@@ -1856,7 +1962,11 @@ async function generateForRoute(page, options) {
|
|
|
1856
1962
|
`Generated steps contain literal secrets instead of {{env.VAR}} placeholders: ${leaked.join(" | ")}`
|
|
1857
1963
|
);
|
|
1858
1964
|
}
|
|
1859
|
-
return {
|
|
1965
|
+
return {
|
|
1966
|
+
...generated,
|
|
1967
|
+
routes: [route],
|
|
1968
|
+
unsourcedEmails: findUnsourcedEmails(generated.steps, pageSnapshot)
|
|
1969
|
+
};
|
|
1860
1970
|
}
|
|
1861
1971
|
async function writeDraft(cwd, draft, meta) {
|
|
1862
1972
|
const dir = path6.join(cwd, TESTS_RELATIVE_DIR);
|
|
@@ -2294,36 +2404,6 @@ async function writeJUnit(file, xml) {
|
|
|
2294
2404
|
}
|
|
2295
2405
|
}
|
|
2296
2406
|
|
|
2297
|
-
// src/runner/authoring.ts
|
|
2298
|
-
var VALUE_VERBS = ["fill", "enter", "type", "input", "set"];
|
|
2299
|
-
var LEADING_VALUE_VERB = new RegExp(`^\\s*(?:${VALUE_VERBS.join("|")})\\b`, "i");
|
|
2300
|
-
var CONNECTOR_WORDS = ["with", "to", "as", "using", "into", "from", "in"];
|
|
2301
|
-
var CONNECTOR_WORD = new RegExp(`\\b(?:${CONNECTOR_WORDS.join("|")})\\b`, "i");
|
|
2302
|
-
var CONNECTOR_SYMBOL = /["'`:=]|\{\{env\./i;
|
|
2303
|
-
var PHRASAL_IN = new RegExp(`^(\\s*(?:${VALUE_VERBS.join("|")}))\\s+in\\b`, "i");
|
|
2304
|
-
function namesNoValue(step) {
|
|
2305
|
-
if (!LEADING_VALUE_VERB.test(step)) return false;
|
|
2306
|
-
const withoutPhrasal = step.replace(PHRASAL_IN, "$1");
|
|
2307
|
-
return !CONNECTOR_WORD.test(withoutPhrasal) && !CONNECTOR_SYMBOL.test(withoutPhrasal);
|
|
2308
|
-
}
|
|
2309
|
-
function detectMissingValues(tests) {
|
|
2310
|
-
const findings = [];
|
|
2311
|
-
for (const test of tests) {
|
|
2312
|
-
for (const origin of ["setup", "steps"]) {
|
|
2313
|
-
const steps = origin === "setup" ? test.setup ?? [] : test.steps;
|
|
2314
|
-
steps.forEach((step, position) => {
|
|
2315
|
-
if (namesNoValue(step)) {
|
|
2316
|
-
findings.push({ test, origin, index: position + 1, step });
|
|
2317
|
-
}
|
|
2318
|
-
});
|
|
2319
|
-
}
|
|
2320
|
-
}
|
|
2321
|
-
return { findings };
|
|
2322
|
-
}
|
|
2323
|
-
function suggestValueClause(step) {
|
|
2324
|
-
return `${step.trimEnd()} with <value>`;
|
|
2325
|
-
}
|
|
2326
|
-
|
|
2327
2407
|
// src/runner/pool.ts
|
|
2328
2408
|
async function runWithConcurrency(items, concurrency, run) {
|
|
2329
2409
|
if (concurrency < 1) throw new RangeError(`concurrency must be at least 1, got ${concurrency}`);
|
|
@@ -3074,6 +3154,12 @@ async function planCommand(options) {
|
|
|
3074
3154
|
} finally {
|
|
3075
3155
|
await context.close();
|
|
3076
3156
|
}
|
|
3157
|
+
for (const { step, address } of draft.unsourcedEmails) {
|
|
3158
|
+
console.error(
|
|
3159
|
+
` warning: step ${step + 1} writes an email address the page does not show (${address}).
|
|
3160
|
+
If it is the account the test signs in as, use a placeholder like {{env.TEST_EMAIL}}.`
|
|
3161
|
+
);
|
|
3162
|
+
}
|
|
3077
3163
|
generated.push(route);
|
|
3078
3164
|
if (options.write) {
|
|
3079
3165
|
try {
|
|
@@ -3206,7 +3292,7 @@ function parsePositiveNumber(flag) {
|
|
|
3206
3292
|
};
|
|
3207
3293
|
}
|
|
3208
3294
|
var program = new Command();
|
|
3209
|
-
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.
|
|
3295
|
+
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.22.0");
|
|
3210
3296
|
program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
|
|
3211
3297
|
try {
|
|
3212
3298
|
const result = await initProject(process.cwd());
|