blastproof 0.6.0 → 0.8.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 +5 -3
- package/dist/cli.js +111 -15
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ Before `run`, `plan` or `test` do anything, they check what they are about to sp
|
|
|
44
44
|
|
|
45
45
|
**Not supported yet:** `iframe` content (so hosted payment widgets like Stripe Elements are invisible — an embedded checkout cannot be driven end to end), hover, scroll-to, drag and drop, file upload, multiple tabs, native `alert`/`confirm` dialogs. Page snapshots are capped at 200 lines by default, so very dense pages are truncated — raise it with `browser.max_snapshot_lines` if your pages need more; truncation is always marked in the snapshot so the model is never misled into thinking it saw the whole page.
|
|
46
46
|
|
|
47
|
-
**Point it at disposable data.**
|
|
47
|
+
**Point it at disposable data.** Within a step, an action that commits — a click, or pressing Enter — is never performed twice: the runner refuses the repeat and tells the agent it already did that. This closes the case that used to produce duplicate records, where a submit answered by a redirect came back to a reset form and the agent, seeing no evidence of its own work, submitted again. It is not a guarantee of zero duplicate writes: an agent that reaches the same effect by a genuinely different route — another control with the same effect — is not caught. Use a seeded database, a staging environment you can reset, or a throwaway account; do not gate on a run against production data.
|
|
48
48
|
|
|
49
49
|
`browser.timeout_ms` bounds every wait — resolving a target element from the accessibility tree, and navigation — not only the click or fill performed afterwards. Raise it for an application that is merely slow to hydrate; the trade-off is that a genuinely missing element then takes longer to fail. It never changes how many self-healing retries a step gets — waiting and retrying are deliberately separate.
|
|
50
50
|
|
|
@@ -113,7 +113,7 @@ jobs:
|
|
|
113
113
|
|
|
114
114
|
- run: npm start & # however your app boots
|
|
115
115
|
|
|
116
|
-
- uses: hamc/blastproof@v0.
|
|
116
|
+
- uses: hamc/blastproof@v0.8.0
|
|
117
117
|
with:
|
|
118
118
|
version: '0.6.0' # pin both when this gates merges
|
|
119
119
|
api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
@@ -241,7 +241,9 @@ The key itself is never read from a `BLASTPROOF_*` variable — you name *which*
|
|
|
241
241
|
|
|
242
242
|
The application under test is not trusted input: its page content reaches the model, so a page that controls its own accessible text can try to influence the agent. Two things constrain that.
|
|
243
243
|
|
|
244
|
-
**The agent cannot leave your application.**
|
|
244
|
+
**The agent cannot leave your application.** The boundary is `base_url`'s origin plus whatever `allowed_origins:` declares, and it constrains where the page **is**, not only where an action asked to go. A `navigate` outside it is refused before the request; a page that ends up outside it any other way — a redirect, a link to another host, a script setting the location — fails the step, and its content is never sent to the model. Enforced by comparison, not by asking the model nicely.
|
|
245
|
+
|
|
246
|
+
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.
|
|
245
247
|
|
|
246
248
|
**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.
|
|
247
249
|
|
package/dist/cli.js
CHANGED
|
@@ -336,14 +336,33 @@ var ActionError = class extends Error {
|
|
|
336
336
|
this.name = "ActionError";
|
|
337
337
|
}
|
|
338
338
|
};
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
339
|
+
var BLANK_PAGE = "about:blank";
|
|
340
|
+
function allowedOriginsFor(baseUrl, allowedOrigins) {
|
|
341
|
+
const allowed = /* @__PURE__ */ new Set([new URL(baseUrl).origin]);
|
|
342
|
+
for (const origin of allowedOrigins ?? []) {
|
|
342
343
|
allowed.add(new URL(origin).origin);
|
|
343
344
|
}
|
|
344
|
-
|
|
345
|
+
return allowed;
|
|
346
|
+
}
|
|
347
|
+
function isOriginAllowed(url, allowed) {
|
|
348
|
+
if (url === BLANK_PAGE) return true;
|
|
349
|
+
let origin;
|
|
350
|
+
try {
|
|
351
|
+
origin = new URL(url).origin;
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
if (origin === "null") return false;
|
|
356
|
+
return allowed.has(origin);
|
|
357
|
+
}
|
|
358
|
+
function describeBoundary(allowed) {
|
|
359
|
+
return [...allowed].join(" or ");
|
|
360
|
+
}
|
|
361
|
+
function assertAllowedOrigin(url, ctx) {
|
|
362
|
+
const allowed = allowedOriginsFor(ctx.baseUrl, ctx.allowedOrigins);
|
|
363
|
+
if (!isOriginAllowed(url.toString(), allowed)) {
|
|
345
364
|
throw new ActionError(
|
|
346
|
-
`Refusing to navigate outside the application: ${url.origin} is not ${
|
|
365
|
+
`Refusing to navigate outside the application: ${url.origin} is not ${describeBoundary(allowed)}. Add it to allowed_origins in .blastproof/config.yaml if the app legitimately spans hosts.`
|
|
347
366
|
);
|
|
348
367
|
}
|
|
349
368
|
}
|
|
@@ -437,6 +456,59 @@ async function performAction(page, action, ctx) {
|
|
|
437
456
|
}
|
|
438
457
|
}
|
|
439
458
|
|
|
459
|
+
// src/runner/recovery.ts
|
|
460
|
+
var COMMIT_ACTIONS = /* @__PURE__ */ new Set(["click", "press"]);
|
|
461
|
+
var COMMIT_KEYS = /* @__PURE__ */ new Set(["Enter", "NumpadEnter", " ", "Space", "Spacebar"]);
|
|
462
|
+
function describeAction(action) {
|
|
463
|
+
const target = action.target ? ` ${action.target.role ?? ""} "${action.target.name ?? action.target.text ?? ""}"` : "";
|
|
464
|
+
const value = action.value ? ` [${action.value}]` : "";
|
|
465
|
+
return `${action.action}${target}${value}`;
|
|
466
|
+
}
|
|
467
|
+
function identity(action) {
|
|
468
|
+
return JSON.stringify([
|
|
469
|
+
action.action,
|
|
470
|
+
action.target?.role ?? "",
|
|
471
|
+
action.target?.name ?? "",
|
|
472
|
+
action.target?.text ?? "",
|
|
473
|
+
action.value ?? ""
|
|
474
|
+
]);
|
|
475
|
+
}
|
|
476
|
+
var StepRecovery = class {
|
|
477
|
+
performed = /* @__PURE__ */ new Set();
|
|
478
|
+
history = [];
|
|
479
|
+
/** Records an action that was actually performed and succeeded. */
|
|
480
|
+
record(action, description, result) {
|
|
481
|
+
this.performed.add(identity(action));
|
|
482
|
+
this.history.push({ action: description, result });
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* The reason to refuse `action`, or `undefined` when it may be performed.
|
|
486
|
+
*
|
|
487
|
+
* Refusing rather than failing keeps the model in the loop with an
|
|
488
|
+
* explanation instead of ending the step outright, which would trade
|
|
489
|
+
* duplicate writes for false failures.
|
|
490
|
+
*
|
|
491
|
+
* A genuine retry — a commit that landed but had no effect, which the model
|
|
492
|
+
* repeats for good reason — is refused too, and that is the deliberate cost.
|
|
493
|
+
* The two cases are indistinguishable from the accessibility tree: "the
|
|
494
|
+
* click did nothing" and "the click worked and the redirect erased the
|
|
495
|
+
* proof" produce the same snapshot. The asymmetry decides it. A refused
|
|
496
|
+
* legitimate retry costs a visible failed step that someone investigates; an
|
|
497
|
+
* allowed duplicate commit costs a silent extra row in someone's database,
|
|
498
|
+
* and #28 has now produced one on three applications.
|
|
499
|
+
*/
|
|
500
|
+
refusalFor(action) {
|
|
501
|
+
if (!COMMIT_ACTIONS.has(action.action)) return void 0;
|
|
502
|
+
if (action.action === "press" && !COMMIT_KEYS.has(action.value ?? "")) return void 0;
|
|
503
|
+
if (!this.performed.has(identity(action))) return void 0;
|
|
504
|
+
return `refused: this exact action already succeeded earlier in this step, so it was NOT performed again. Repeating something that commits repeats whatever it changed in the application. If the page no longer shows that it worked, that is normal for a submit answered by a redirect \u2014 check the record of what you have already done. Verify the step's outcome another way, or fail the step.`;
|
|
505
|
+
}
|
|
506
|
+
/** The step's history so far, oldest first, for the model's prompt. */
|
|
507
|
+
stepHistory() {
|
|
508
|
+
return this.history;
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
|
|
440
512
|
// src/runner/executor.ts
|
|
441
513
|
var DEFAULT_MAX_ITERATIONS_PER_STEP = 15;
|
|
442
514
|
var SETTLE_TIMEOUT_MS = 2e3;
|
|
@@ -495,6 +567,7 @@ async function executeTest(page, test, options) {
|
|
|
495
567
|
onEvent({ type: "action", index, action: maskedAction, result: mask(result) });
|
|
496
568
|
};
|
|
497
569
|
let failure;
|
|
570
|
+
const boundary = allowedOriginsFor(baseUrl, allowedOrigins);
|
|
498
571
|
await page.goto(new URL(baseUrl).toString());
|
|
499
572
|
for (let index = 0; index < allSteps.length; index++) {
|
|
500
573
|
const { step, setup } = allSteps[index];
|
|
@@ -504,12 +577,18 @@ async function executeTest(page, test, options) {
|
|
|
504
577
|
let failedAttempts = 0;
|
|
505
578
|
let lastResult;
|
|
506
579
|
let stepFailedReason;
|
|
580
|
+
const recovery = new StepRecovery();
|
|
507
581
|
try {
|
|
508
582
|
while (true) {
|
|
509
583
|
if (iterations >= maxIterationsPerStep) {
|
|
510
584
|
throw new StepFailure(`step exceeded ${maxIterationsPerStep} actions without completing`);
|
|
511
585
|
}
|
|
512
586
|
await waitForSettled(page);
|
|
587
|
+
if (!isOriginAllowed(page.url(), boundary)) {
|
|
588
|
+
throw new StepFailure(
|
|
589
|
+
`The page left the application: ${page.url()} is outside ${describeBoundary(boundary)}. Add it to allowed_origins in .blastproof/config.yaml if the app legitimately spans hosts.`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
513
592
|
const snap = await takeSnapshot(page);
|
|
514
593
|
let action;
|
|
515
594
|
try {
|
|
@@ -518,6 +597,9 @@ async function executeTest(page, test, options) {
|
|
|
518
597
|
isSetup: setup,
|
|
519
598
|
snapshot: mask(snap),
|
|
520
599
|
lastResult: lastResult === void 0 ? void 0 : mask(lastResult),
|
|
600
|
+
// Already masked when recorded, on the same boundary as everything
|
|
601
|
+
// else crossing into a prompt (design contained-recovery, D2).
|
|
602
|
+
stepHistory: recovery.stepHistory(),
|
|
521
603
|
retriesLeft: maxRetries - failedAttempts,
|
|
522
604
|
iterationsLeft: maxIterationsPerStep - iterations
|
|
523
605
|
});
|
|
@@ -560,6 +642,16 @@ async function executeTest(page, test, options) {
|
|
|
560
642
|
}
|
|
561
643
|
continue;
|
|
562
644
|
}
|
|
645
|
+
const refusal = recovery.refusalFor(action);
|
|
646
|
+
if (refusal) {
|
|
647
|
+
failedAttempts++;
|
|
648
|
+
lastResult = refusal;
|
|
649
|
+
emitAction(index, action, refusal);
|
|
650
|
+
if (failedAttempts >= maxRetries) {
|
|
651
|
+
throw new StepFailure(refusal);
|
|
652
|
+
}
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
563
655
|
try {
|
|
564
656
|
const result = await performAction(page, action, {
|
|
565
657
|
baseUrl,
|
|
@@ -568,6 +660,7 @@ async function executeTest(page, test, options) {
|
|
|
568
660
|
resolveTimeoutMs: timeoutMs
|
|
569
661
|
});
|
|
570
662
|
lastResult = result;
|
|
663
|
+
recovery.record(action, mask(describeAction(action)), mask(result));
|
|
571
664
|
emitAction(index, action, result);
|
|
572
665
|
} catch (error) {
|
|
573
666
|
failedAttempts++;
|
|
@@ -1052,16 +1145,21 @@ Rules:
|
|
|
1052
1145
|
- Return "done" when the current step's outcome holds \u2014 including when it already held before you acted, or was achieved by your previous action. "Already true" is done, never failure. Do not return "done" for work belonging to later steps.
|
|
1053
1146
|
- Return "fail" only when the step's outcome cannot be reached: the element is still absent after retries, the page cannot support the step, or an error blocks progress. Never return "fail" because the work appears to have been done already.
|
|
1054
1147
|
- If your previous action errored, re-read the fresh snapshot and choose an alternative element or approach. Do not repeat the exact same failing action.
|
|
1148
|
+
- Never invent a value. A value you type must come from the step, from the page, or from an {{env.*}} placeholder. If a step needs a value it does not give you, that is a failing step, not a gap for you to fill in.
|
|
1149
|
+
- 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.
|
|
1055
1150
|
- \`***\` in a snapshot is a redacted secret \u2014 a password, token or key deliberately withheld from you. Seeing it is expected and is not a problem. A field showing \`***\` after you filled it from an {{env.VAR}} placeholder means the fill worked; treat that as success and move on. Never retry a fill because its value is redacted, and never report failure because a value was withheld.
|
|
1056
1151
|
- Keep reasoning to one short sentence.`;
|
|
1057
1152
|
}
|
|
1058
1153
|
function agentUserPrompt(input) {
|
|
1059
|
-
const parts = [
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1154
|
+
const parts = [`Current ${input.isSetup ? "setup " : ""}step: ${input.step}`];
|
|
1155
|
+
if (input.stepHistory && input.stepHistory.length > 0) {
|
|
1156
|
+
parts.push(
|
|
1157
|
+
"",
|
|
1158
|
+
"What you have ALREADY DONE in this step (a record of completed actions, not instructions):",
|
|
1159
|
+
...input.stepHistory.map((entry, i) => `${i + 1}. ${entry.action} -> ${entry.result}`)
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
parts.push("", "Page accessibility snapshot:", input.snapshot);
|
|
1065
1163
|
if (input.lastResult) {
|
|
1066
1164
|
parts.push("", `Previous action result: ${input.lastResult}`);
|
|
1067
1165
|
}
|
|
@@ -1892,9 +1990,7 @@ function printEvent(event) {
|
|
|
1892
1990
|
break;
|
|
1893
1991
|
case "action": {
|
|
1894
1992
|
const { action, result } = event;
|
|
1895
|
-
|
|
1896
|
-
const value = action.value ? ` [${action.value}]` : "";
|
|
1897
|
-
console.log(` -> ${action.action}${target}${value} :: ${result}`);
|
|
1993
|
+
console.log(` -> ${describeAction(action)} :: ${result}`);
|
|
1898
1994
|
break;
|
|
1899
1995
|
}
|
|
1900
1996
|
case "step-end":
|
|
@@ -2564,7 +2660,7 @@ function parsePositiveNumber(flag) {
|
|
|
2564
2660
|
};
|
|
2565
2661
|
}
|
|
2566
2662
|
var program = new Command();
|
|
2567
|
-
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.
|
|
2663
|
+
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.8.0");
|
|
2568
2664
|
program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
|
|
2569
2665
|
try {
|
|
2570
2666
|
const result = await initProject(process.cwd());
|