blastproof 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,2067 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command, InvalidArgumentError } from "commander";
5
+
6
+ // src/commands/init.ts
7
+ import { mkdir, writeFile } from "fs/promises";
8
+ import path from "path";
9
+ import { access } from "fs/promises";
10
+ var DEFAULT_CONFIG = `# blastproof configuration
11
+ # Docs: https://github.com/hamc/blastproof
12
+
13
+ # Base URL of the app under test. Relative navigate paths resolve against it.
14
+ base_url: http://localhost:4173
15
+
16
+ llm:
17
+ # anthropic | openai | ollama
18
+ provider: anthropic
19
+ # Optional. Defaults: anthropic=claude-haiku-4-5, openai=gpt-4o-mini, ollama=qwen2.5
20
+ # model: claude-haiku-4-5
21
+ # Env var holding your API key (not needed for ollama).
22
+ api_key_env: ANTHROPIC_API_KEY
23
+ # OpenAI-compatible endpoint: required for ollama (http://localhost:11434/v1);
24
+ # with provider "openai" it enables compatible gateways, e.g. OpenRouter:
25
+ # base_url: https://openrouter.ai/api/v1 (model e.g. anthropic/claude-haiku-4.5)
26
+
27
+ browser:
28
+ headless: true
29
+ timeout_ms: 30000
30
+
31
+ # Budget of self-healing retries per step (element re-resolution via accessibility tree).
32
+ # max_retries_per_step: 3
33
+
34
+ # Impact hints mapping file globs to routes (used by \`blastproof run --impacted\`).
35
+ routes:
36
+ "src/auth/**": ["/login"]
37
+ "src/cart/**": ["/cart", "/checkout"]
38
+
39
+ # Files knowingly irrelevant to any route. Nothing is ignored by default: a file
40
+ # nobody has classified is exactly the risk \`--fail-on-unmapped\` exists to surface,
41
+ # so the list is yours to state rather than ours to guess.
42
+ # ignore:
43
+ # - "**/*.md"
44
+ # - "docs/**"
45
+ # - ".github/**"
46
+ # - "LICENSE"
47
+
48
+ # Optional: authenticate once per run so tests and \`plan\` reach pages behind a login.
49
+ # Pick exactly ONE strategy. Credentials come from the environment via {{env.VAR}} \u2014
50
+ # never write them here. A test can opt out with \`auth: false\` (a login test must).
51
+ #
52
+ # 1) A plain-English login journey, executed once (form login, any clickable flow):
53
+ # auth:
54
+ # steps:
55
+ # - navigate to /login
56
+ # - fill the email field with {{env.TEST_EMAIL}}
57
+ # - fill the password field with {{env.TEST_PASSWORD}}
58
+ # - submit the login form
59
+ # verify: a signed-in indicator is visible # optional, but turns N failures into 1
60
+ #
61
+ # 2) A session captured by hand \u2014 for SSO, MFA or magic links, which cannot be
62
+ # automated honestly. The file holds live session cookies: NEVER commit it.
63
+ # auth:
64
+ # storage_state: .blastproof/auth.json
65
+ #
66
+ # 3) Static values, for token-based apps where driving a UI would be theatre:
67
+ # auth:
68
+ # headers:
69
+ # Authorization: "Bearer {{env.API_TOKEN}}"
70
+
71
+ # CI tip: these settings can be overridden from the environment, so you never have
72
+ # to commit a provider choice just to configure a pipeline. Precedence is
73
+ # CLI flag > environment > this file.
74
+ # BLASTPROOF_BASE_URL the app under test
75
+ # BLASTPROOF_LLM_PROVIDER anthropic | openai | ollama
76
+ # BLASTPROOF_LLM_MODEL
77
+ # BLASTPROOF_LLM_BASE_URL the provider endpoint (not the app)
78
+ # BLASTPROOF_LLM_API_KEY_ENV name of the variable holding your key, never the key
79
+ `;
80
+ var SAMPLE_TEST = `summary: App loads and shows the home page
81
+ priority: P1
82
+ tags: [smoke]
83
+ # Routes this test covers; run --impacted selects tests by these routes.
84
+ routes: ["/"]
85
+ steps:
86
+ - verify the home page loads and shows a heading
87
+ `;
88
+ var GITIGNORE = `# blastproof runtime artifacts
89
+ reports/
90
+ sessions/
91
+
92
+ # Captured sessions are live credentials \u2014 never commit them.
93
+ .auth-state.json
94
+ auth.json
95
+ `;
96
+ var SAMPLE_LOGIN_TEST = `summary: Login with valid credentials succeeds
97
+ priority: P0
98
+ tags: [smoke, auth]
99
+ routes: ["/login"]
100
+ # This test exercises the login itself, so it must start signed out even when an
101
+ # auth recipe is configured.
102
+ auth: false
103
+ steps:
104
+ - navigate to /login
105
+ - fill the email field with demo@blastproof.dev
106
+ - fill the password field with demo123
107
+ - submit the login form
108
+ - verify a welcome message is shown
109
+ # Secrets tip: use {{env.VAR_NAME}} placeholders in steps (e.g. {{env.TEST_PASSWORD}})
110
+ # and export the variable before running. Values are masked as *** in all output.
111
+ `;
112
+ async function exists(file) {
113
+ try {
114
+ await access(file);
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+ async function writeIfAbsent(file, content, result) {
121
+ if (await exists(file)) {
122
+ result.kept.push(file);
123
+ return;
124
+ }
125
+ await mkdir(path.dirname(file), { recursive: true });
126
+ await writeFile(file, content);
127
+ result.created.push(file);
128
+ }
129
+ async function initProject(cwd = process.cwd()) {
130
+ const result = { created: [], kept: [] };
131
+ const root = path.join(cwd, ".blastproof");
132
+ await writeIfAbsent(path.join(root, ".gitignore"), GITIGNORE, result);
133
+ await writeIfAbsent(path.join(root, "config.yaml"), DEFAULT_CONFIG, result);
134
+ await writeIfAbsent(path.join(root, "tests", "app-load.yaml"), SAMPLE_TEST, result);
135
+ await writeIfAbsent(path.join(root, "tests", "login.yaml"), SAMPLE_LOGIN_TEST, result);
136
+ return result;
137
+ }
138
+ function formatInitGuidance(result) {
139
+ const lines = [];
140
+ if (result.created.length > 0) {
141
+ lines.push("Created:");
142
+ for (const file of result.created) lines.push(` ${file}`);
143
+ }
144
+ if (result.kept.length > 0) {
145
+ lines.push("Kept existing (not overwritten):");
146
+ for (const file of result.kept) lines.push(` ${file}`);
147
+ }
148
+ lines.push(
149
+ "",
150
+ "Next steps:",
151
+ " 1. Set your LLM API key: export ANTHROPIC_API_KEY=... (or OPENAI_API_KEY; ollama needs no key)",
152
+ " 2. Point base_url at your app in .blastproof/config.yaml",
153
+ " 3. Run your tests: blastproof run",
154
+ "",
155
+ "First time with Playwright? Install the browser: npx playwright install chromium"
156
+ );
157
+ return lines.join("\n");
158
+ }
159
+
160
+ // src/commands/plan.ts
161
+ import path10 from "path";
162
+ import { chromium as chromium2 } from "playwright";
163
+
164
+ // src/auth.ts
165
+ import { readFile, writeFile as writeFile2, mkdir as mkdir3 } from "fs/promises";
166
+ import path3 from "path";
167
+
168
+ // src/runner/env.ts
169
+ var ENV_PLACEHOLDER = /\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
170
+ var MissingEnvError = class extends Error {
171
+ constructor(variable) {
172
+ super(
173
+ `Environment variable ${variable} is referenced via {{env.${variable}}} but is not set. Export it before running, e.g. \`export ${variable}=...\`.`
174
+ );
175
+ this.name = "MissingEnvError";
176
+ }
177
+ };
178
+ function substituteEnv(text, env = process.env) {
179
+ return text.replace(ENV_PLACEHOLDER, (_match, name) => {
180
+ const value = env[name];
181
+ if (value === void 0) {
182
+ throw new MissingEnvError(name);
183
+ }
184
+ return value;
185
+ });
186
+ }
187
+ function referencedEnvVars(text) {
188
+ const names = /* @__PURE__ */ new Set();
189
+ for (const match of text.matchAll(ENV_PLACEHOLDER)) {
190
+ if (match[1]) names.add(match[1]);
191
+ }
192
+ return [...names];
193
+ }
194
+ function escapeRegExp(value) {
195
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
196
+ }
197
+ function maskSecrets(text, secrets) {
198
+ const ordered = [...secrets].filter(Boolean).sort((a, b) => b.length - a.length);
199
+ let masked = text;
200
+ for (const secret of ordered) {
201
+ masked = masked.replace(new RegExp(escapeRegExp(secret), "g"), "***");
202
+ }
203
+ return masked;
204
+ }
205
+ var SecretsMask = class {
206
+ secrets = /* @__PURE__ */ new Set();
207
+ /** Registers the current values of the env vars referenced in `text`. Throws MissingEnvError on unset vars. */
208
+ registerFrom(text, env = process.env) {
209
+ for (const name of referencedEnvVars(text)) {
210
+ const value = env[name];
211
+ if (value === void 0) {
212
+ throw new MissingEnvError(name);
213
+ }
214
+ this.secrets.add(value);
215
+ }
216
+ }
217
+ mask(text) {
218
+ return maskSecrets(text, this.secrets);
219
+ }
220
+ };
221
+
222
+ // src/runner/executor.ts
223
+ import { mkdir as mkdir2 } from "fs/promises";
224
+ import path2 from "path";
225
+
226
+ // src/runner/actions.ts
227
+ var ActionError = class extends Error {
228
+ constructor(message) {
229
+ super(message);
230
+ this.name = "ActionError";
231
+ }
232
+ };
233
+ function describeTarget(target) {
234
+ const parts = [];
235
+ if (target.role) parts.push(`role=${target.role}`);
236
+ if (target.name) parts.push(`name="${target.name}"`);
237
+ if (target.text) parts.push(`text="${target.text}"`);
238
+ return parts.join(" ") || "(no target)";
239
+ }
240
+ async function resolveTarget(page, target, resolveTimeoutMs = 2e3) {
241
+ const candidates = [];
242
+ if (target.role) {
243
+ candidates.push(page.getByRole(target.role, target.name ? { name: target.name } : {}));
244
+ }
245
+ if (target.name) {
246
+ candidates.push(page.getByLabel(target.name));
247
+ }
248
+ const text = target.text ?? target.name;
249
+ if (text) {
250
+ candidates.push(page.getByText(text));
251
+ }
252
+ for (const candidate of candidates) {
253
+ const locator = candidate.first();
254
+ try {
255
+ await locator.waitFor({ state: "visible", timeout: resolveTimeoutMs });
256
+ return locator;
257
+ } catch {
258
+ }
259
+ }
260
+ throw new ActionError(`Element not found: ${describeTarget(target)}`);
261
+ }
262
+ function requireTarget(action) {
263
+ if (!action.target || !action.target.role && !action.target.name && !action.target.text) {
264
+ throw new ActionError(`Action "${action.action}" requires a target (role/name/text)`);
265
+ }
266
+ return action.target;
267
+ }
268
+ function requireValue(action) {
269
+ if (action.value === void 0 || action.value === "") {
270
+ throw new ActionError(`Action "${action.action}" requires a value`);
271
+ }
272
+ return action.value;
273
+ }
274
+ async function performAction(page, action, ctx) {
275
+ switch (action.action) {
276
+ case "navigate": {
277
+ const value = requireValue(action);
278
+ const url = new URL(value, ctx.baseUrl).toString();
279
+ await page.goto(url, { timeout: 3e4 });
280
+ return `ok: navigated to ${url}`;
281
+ }
282
+ case "click": {
283
+ const target = requireTarget(action);
284
+ const locator = await resolveTarget(page, target, ctx.resolveTimeoutMs);
285
+ await locator.click();
286
+ return `ok: clicked ${describeTarget(target)}`;
287
+ }
288
+ case "fill": {
289
+ const target = requireTarget(action);
290
+ const value = requireValue(action);
291
+ const locator = await resolveTarget(page, target, ctx.resolveTimeoutMs);
292
+ await locator.fill(value);
293
+ return `ok: filled ${describeTarget(target)}`;
294
+ }
295
+ case "press": {
296
+ const key = action.value ?? "Enter";
297
+ if (action.target && (action.target.role || action.target.name || action.target.text)) {
298
+ const locator = await resolveTarget(page, action.target, ctx.resolveTimeoutMs);
299
+ await locator.press(key);
300
+ return `ok: pressed ${key} on ${describeTarget(action.target)}`;
301
+ }
302
+ await page.keyboard.press(key);
303
+ return `ok: pressed ${key}`;
304
+ }
305
+ case "select": {
306
+ const target = requireTarget(action);
307
+ const value = requireValue(action);
308
+ const locator = await resolveTarget(page, target, ctx.resolveTimeoutMs);
309
+ await locator.selectOption({ label: value });
310
+ return `ok: selected "${value}" in ${describeTarget(target)}`;
311
+ }
312
+ case "assert":
313
+ case "done":
314
+ case "fail":
315
+ throw new ActionError(`Action "${action.action}" is handled by the executor, not the page`);
316
+ }
317
+ }
318
+
319
+ // src/runner/executor.ts
320
+ async function defaultSnapshot(page) {
321
+ const { captureSnapshot } = await import("./snapshot-CAIB2OHX.js");
322
+ return captureSnapshot(page);
323
+ }
324
+ function slugify(text) {
325
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
326
+ return slug || "test";
327
+ }
328
+ var StepFailure = class extends Error {
329
+ };
330
+ async function executeTest(page, test, options) {
331
+ const {
332
+ brain,
333
+ sessionDir,
334
+ baseUrl,
335
+ maxRetries = 3,
336
+ maxIterationsPerStep = 15,
337
+ mask = (s) => s,
338
+ snapshot = defaultSnapshot,
339
+ onEvent = () => {
340
+ }
341
+ } = options;
342
+ const startedAt = Date.now();
343
+ const stepResults = [];
344
+ const allSteps = [
345
+ ...(test.setup ?? []).map((step) => ({ step, setup: true })),
346
+ ...test.steps.map((step) => ({ step, setup: false }))
347
+ ];
348
+ const emitAction = (index, action, result) => {
349
+ const maskedAction = {
350
+ ...action,
351
+ value: action.value === void 0 ? void 0 : mask(action.value),
352
+ reasoning: mask(action.reasoning),
353
+ expectation: action.expectation === void 0 ? void 0 : mask(action.expectation)
354
+ };
355
+ onEvent({ type: "action", index, action: maskedAction, result: mask(result) });
356
+ };
357
+ let failure;
358
+ await page.goto(new URL(baseUrl).toString());
359
+ for (let index = 0; index < allSteps.length; index++) {
360
+ const { step, setup } = allSteps[index];
361
+ const stepStartedAt = Date.now();
362
+ onEvent({ type: "step-start", index, total: allSteps.length, step: mask(step), setup });
363
+ let iterations = 0;
364
+ let failedAttempts = 0;
365
+ let lastResult;
366
+ let stepFailedReason;
367
+ try {
368
+ while (true) {
369
+ if (iterations >= maxIterationsPerStep) {
370
+ throw new StepFailure(`step exceeded ${maxIterationsPerStep} actions without completing`);
371
+ }
372
+ const snap = await snapshot(page);
373
+ let action;
374
+ try {
375
+ action = await brain.nextAction({
376
+ step,
377
+ isSetup: setup,
378
+ snapshot: snap,
379
+ lastResult,
380
+ retriesLeft: maxRetries - failedAttempts,
381
+ iterationsLeft: maxIterationsPerStep - iterations
382
+ });
383
+ } catch (error) {
384
+ failedAttempts++;
385
+ lastResult = `error: ${error instanceof Error ? error.message : String(error)}`;
386
+ if (failedAttempts >= maxRetries) {
387
+ throw new StepFailure(`retry budget exhausted (${maxRetries}): ${lastResult}`);
388
+ }
389
+ continue;
390
+ }
391
+ iterations++;
392
+ if (action.action === "done") {
393
+ emitAction(index, action, action.reasoning);
394
+ break;
395
+ }
396
+ if (action.action === "fail") {
397
+ emitAction(index, action, action.reasoning);
398
+ throw new StepFailure(action.reasoning);
399
+ }
400
+ if (action.action === "assert") {
401
+ const expectation = action.expectation ?? action.reasoning;
402
+ const judgment = await brain.judge(expectation, snap);
403
+ const result = judgment.pass ? `ok: assertion passed: ${judgment.reason}` : `assertion failed: ${judgment.reason}`;
404
+ emitAction(index, action, result);
405
+ if (judgment.pass) {
406
+ lastResult = result;
407
+ continue;
408
+ }
409
+ failedAttempts++;
410
+ lastResult = result;
411
+ if (failedAttempts >= maxRetries) {
412
+ throw new StepFailure(judgment.reason);
413
+ }
414
+ continue;
415
+ }
416
+ try {
417
+ const result = await performAction(page, action, { baseUrl });
418
+ lastResult = result;
419
+ emitAction(index, action, result);
420
+ } catch (error) {
421
+ failedAttempts++;
422
+ lastResult = `error: ${error instanceof Error ? error.message : String(error)}`;
423
+ emitAction(index, action, lastResult);
424
+ if (failedAttempts >= maxRetries) {
425
+ throw new StepFailure(lastResult);
426
+ }
427
+ }
428
+ }
429
+ } catch (error) {
430
+ stepFailedReason = mask(error instanceof Error ? error.message : String(error));
431
+ }
432
+ const status = stepFailedReason ? "failed" : "passed";
433
+ stepResults.push({
434
+ step: mask(step),
435
+ // results are a report channel — never store substituted secrets
436
+ setup,
437
+ status,
438
+ iterations,
439
+ failedAttempts,
440
+ reason: stepFailedReason,
441
+ durationMs: Date.now() - stepStartedAt
442
+ });
443
+ onEvent({ type: "step-end", index, status, reason: stepFailedReason });
444
+ if (stepFailedReason) {
445
+ failure = { step: mask(step), reason: stepFailedReason };
446
+ break;
447
+ }
448
+ }
449
+ let screenshot;
450
+ if (failure) {
451
+ try {
452
+ await mkdir2(sessionDir, { recursive: true });
453
+ const file = path2.join(sessionDir, `${slugify(test.summary)}.png`);
454
+ await page.screenshot({ path: file, fullPage: true });
455
+ screenshot = file;
456
+ } catch {
457
+ }
458
+ }
459
+ return {
460
+ file: test.path,
461
+ summary: test.summary,
462
+ priority: test.priority,
463
+ tags: test.tags,
464
+ status: failure ? "failed" : "passed",
465
+ steps: stepResults,
466
+ failedStep: failure?.step,
467
+ reason: failure?.reason,
468
+ screenshot,
469
+ durationMs: Date.now() - startedAt
470
+ };
471
+ }
472
+
473
+ // src/auth.ts
474
+ var AUTH_STATE_RELATIVE_PATH = path3.join(".blastproof", ".auth-state.json");
475
+ var AuthError = class extends Error {
476
+ constructor(message) {
477
+ super(message);
478
+ this.name = "AuthError";
479
+ }
480
+ };
481
+ function isStorageState(value) {
482
+ return typeof value === "object" && value !== null && Array.isArray(value.cookies) && Array.isArray(value.origins);
483
+ }
484
+ async function fromStorageState(file) {
485
+ let raw;
486
+ try {
487
+ raw = await readFile(file, "utf8");
488
+ } catch {
489
+ throw new AuthError(
490
+ `Cannot read auth.storage_state at ${file}. Capture a session first, or switch to an auth.steps recipe.`
491
+ );
492
+ }
493
+ let parsed;
494
+ try {
495
+ parsed = JSON.parse(raw);
496
+ } catch {
497
+ throw new AuthError(`Invalid JSON in auth.storage_state at ${file}.`);
498
+ }
499
+ if (!isStorageState(parsed)) {
500
+ throw new AuthError(
501
+ `Invalid storage state at ${file}: expected an object with "cookies" and "origins" arrays.`
502
+ );
503
+ }
504
+ return { storageState: parsed };
505
+ }
506
+ function fromStatic(auth) {
507
+ const mask = new SecretsMask();
508
+ const session = {};
509
+ if (auth.headers) {
510
+ const headers = {};
511
+ for (const [name, value] of Object.entries(auth.headers)) {
512
+ mask.registerFrom(value);
513
+ headers[name] = substituteEnv(value);
514
+ }
515
+ session.extraHTTPHeaders = headers;
516
+ }
517
+ if (auth.cookies) {
518
+ const cookies = auth.cookies.map((cookie) => {
519
+ mask.registerFrom(cookie.value);
520
+ return { ...cookie, value: substituteEnv(cookie.value) };
521
+ });
522
+ session.storageState = { cookies, origins: [] };
523
+ }
524
+ return { session, mask };
525
+ }
526
+ async function runJourney(...args) {
527
+ try {
528
+ return await executeTest(...args);
529
+ } catch (error) {
530
+ throw new AuthError(
531
+ `Authentication could not run: ${error instanceof Error ? error.message : String(error)}`
532
+ );
533
+ }
534
+ }
535
+ async function fromSteps(options) {
536
+ const { auth, baseUrl, browser, brain, maxRetries, snapshot = defaultSnapshot, onEvent } = options;
537
+ const steps = auth.steps;
538
+ const mask = new SecretsMask();
539
+ for (const step of steps) mask.registerFrom(step);
540
+ const context = await browser.newContext();
541
+ try {
542
+ const page = await context.newPage();
543
+ const result = await runJourney(
544
+ page,
545
+ {
546
+ path: "<auth>",
547
+ summary: "authentication",
548
+ steps: steps.map((step) => substituteEnv(step)),
549
+ priority: "P0",
550
+ tags: [],
551
+ routes: [],
552
+ auth: false
553
+ },
554
+ {
555
+ brain,
556
+ sessionDir: path3.join(options.cwd, ".blastproof", "reports", "auth"),
557
+ baseUrl,
558
+ maxRetries,
559
+ mask: (text) => mask.mask(text),
560
+ snapshot,
561
+ onEvent
562
+ }
563
+ );
564
+ if (result.status === "failed") {
565
+ throw new AuthError(
566
+ `Authentication failed at step "${result.failedStep ?? "unknown"}": ${result.reason ?? "no reason given"}`
567
+ );
568
+ }
569
+ if (auth.verify) {
570
+ const judgment = await brain.judge(auth.verify, await snapshot(page));
571
+ if (!judgment.pass) {
572
+ throw new AuthError(`Authentication could not be verified: ${mask.mask(judgment.reason)}`);
573
+ }
574
+ }
575
+ return { storageState: await context.storageState() };
576
+ } finally {
577
+ await context.close();
578
+ }
579
+ }
580
+ async function readCached(cwd) {
581
+ try {
582
+ return await fromStorageState(path3.join(cwd, AUTH_STATE_RELATIVE_PATH));
583
+ } catch {
584
+ return void 0;
585
+ }
586
+ }
587
+ async function writeCached(cwd, session) {
588
+ if (!session.storageState) return;
589
+ const file = path3.join(cwd, AUTH_STATE_RELATIVE_PATH);
590
+ await mkdir3(path3.dirname(file), { recursive: true });
591
+ await writeFile2(file, JSON.stringify(session.storageState), "utf8");
592
+ }
593
+ async function authenticate(options) {
594
+ const { auth, cwd } = options;
595
+ if (auth.cache) {
596
+ const cached = await readCached(cwd);
597
+ if (cached) return cached;
598
+ }
599
+ if (auth.storage_state) {
600
+ return fromStorageState(path3.resolve(cwd, auth.storage_state));
601
+ }
602
+ if (auth.headers || auth.cookies) {
603
+ return fromStatic(auth).session;
604
+ }
605
+ const session = await fromSteps(options);
606
+ if (auth.cache) await writeCached(cwd, session);
607
+ return session;
608
+ }
609
+ function contextOptions(session) {
610
+ if (!session) return {};
611
+ return {
612
+ ...session.storageState ? { storageState: session.storageState } : {},
613
+ ...session.extraHTTPHeaders ? { extraHTTPHeaders: session.extraHTTPHeaders } : {}
614
+ };
615
+ }
616
+
617
+ // src/config.ts
618
+ import { readFile as readFile2 } from "fs/promises";
619
+ import path4 from "path";
620
+ import { parse as parseYaml } from "yaml";
621
+ import { z } from "zod";
622
+ var CONFIG_RELATIVE_PATH = path4.join(".blastproof", "config.yaml");
623
+ var llmSchema = z.object({
624
+ provider: z.enum(["anthropic", "openai", "ollama"]).default("anthropic"),
625
+ model: z.string().min(1).optional(),
626
+ api_key_env: z.string().min(1).optional(),
627
+ base_url: z.string().url().optional()
628
+ });
629
+ var browserSchema = z.object({
630
+ headless: z.boolean().default(true),
631
+ timeout_ms: z.number().int().positive().default(3e4)
632
+ });
633
+ var cookieSchema = z.object({
634
+ name: z.string().min(1),
635
+ value: z.string(),
636
+ domain: z.string().optional(),
637
+ path: z.string().optional(),
638
+ url: z.string().optional()
639
+ });
640
+ var authSchema = z.object({
641
+ steps: z.array(z.string().min(1)).min(1).optional(),
642
+ storage_state: z.string().min(1).optional(),
643
+ headers: z.record(z.string()).optional(),
644
+ cookies: z.array(cookieSchema).min(1).optional(),
645
+ verify: z.string().min(1).optional(),
646
+ cache: z.boolean().default(false)
647
+ }).superRefine((value, ctx) => {
648
+ const present = [];
649
+ if (value.steps) present.push("steps");
650
+ if (value.storage_state) present.push("storage_state");
651
+ if (value.headers || value.cookies) {
652
+ present.push([value.headers && "headers", value.cookies && "cookies"].filter(Boolean).join("+"));
653
+ }
654
+ if (present.length === 0) {
655
+ ctx.addIssue({
656
+ code: z.ZodIssueCode.custom,
657
+ message: "configure one auth strategy: steps (login journey), storage_state (captured session), or headers/cookies"
658
+ });
659
+ return;
660
+ }
661
+ if (present.length > 1) {
662
+ ctx.addIssue({
663
+ code: z.ZodIssueCode.custom,
664
+ message: `configure exactly one auth strategy, found ${present.join(" and ")}`
665
+ });
666
+ }
667
+ });
668
+ var configSchema = z.object({
669
+ base_url: z.string().url(),
670
+ llm: llmSchema.default({}),
671
+ browser: browserSchema.default({}),
672
+ routes: z.record(z.array(z.string())).optional(),
673
+ /** Globs for files knowingly irrelevant to any route (docs, CI config, licences). */
674
+ ignore: z.array(z.string()).optional(),
675
+ auth: authSchema.optional(),
676
+ max_retries_per_step: z.number().int().min(1).default(3)
677
+ });
678
+ var ENV_OVERRIDES = {
679
+ BLASTPROOF_BASE_URL: ["base_url"],
680
+ BLASTPROOF_LLM_PROVIDER: ["llm", "provider"],
681
+ BLASTPROOF_LLM_MODEL: ["llm", "model"],
682
+ BLASTPROOF_LLM_BASE_URL: ["llm", "base_url"],
683
+ BLASTPROOF_LLM_API_KEY_ENV: ["llm", "api_key_env"]
684
+ };
685
+ function applyEnvOverrides(data, env = process.env) {
686
+ const merged = data && typeof data === "object" ? { ...data } : {};
687
+ const applied = [];
688
+ for (const [variable, keyPath] of Object.entries(ENV_OVERRIDES)) {
689
+ const value = env[variable];
690
+ if (value === void 0 || value === "") continue;
691
+ if (keyPath.length === 1) {
692
+ merged[keyPath[0]] = value;
693
+ } else {
694
+ const [section2, field] = keyPath;
695
+ const current = merged[section2];
696
+ merged[section2] = {
697
+ ...current && typeof current === "object" ? current : {},
698
+ [field]: value
699
+ };
700
+ }
701
+ applied.push(variable);
702
+ }
703
+ return { data: merged, applied };
704
+ }
705
+ var ConfigError = class extends Error {
706
+ constructor(message) {
707
+ super(message);
708
+ this.name = "ConfigError";
709
+ }
710
+ };
711
+ function formatIssues(error) {
712
+ return error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
713
+ }
714
+ async function loadConfig(cwd = process.cwd(), env = process.env) {
715
+ const configPath = path4.join(cwd, CONFIG_RELATIVE_PATH);
716
+ let raw;
717
+ try {
718
+ raw = await readFile2(configPath, "utf8");
719
+ } catch {
720
+ throw new ConfigError(
721
+ `No config found at ${CONFIG_RELATIVE_PATH}. Run \`blastproof init\` to scaffold one.`
722
+ );
723
+ }
724
+ let data;
725
+ try {
726
+ data = parseYaml(raw);
727
+ } catch (error) {
728
+ throw new ConfigError(
729
+ `Invalid YAML in ${CONFIG_RELATIVE_PATH}: ${error instanceof Error ? error.message : String(error)}`
730
+ );
731
+ }
732
+ const { data: merged, applied } = applyEnvOverrides(data ?? {}, env);
733
+ const result = configSchema.safeParse(merged);
734
+ if (!result.success) {
735
+ const source = applied.length > 0 ? `${CONFIG_RELATIVE_PATH} (with overrides from ${applied.join(", ")})` : CONFIG_RELATIVE_PATH;
736
+ throw new ConfigError(`Invalid ${source}:
737
+ ${formatIssues(result.error)}`);
738
+ }
739
+ return result.data;
740
+ }
741
+
742
+ // src/diff.ts
743
+ import { simpleGit } from "simple-git";
744
+ var DiffError = class extends Error {
745
+ constructor(message) {
746
+ super(message);
747
+ this.name = "DiffError";
748
+ }
749
+ };
750
+ function toDiffError(error, baseRef) {
751
+ const message = error instanceof Error ? error.message : String(error);
752
+ if (/not a git repository/i.test(message)) {
753
+ return new DiffError(
754
+ "Cannot compute diff: not inside a git repository. Run blastproof from within your project repository."
755
+ );
756
+ }
757
+ if (/no merge base/i.test(message)) {
758
+ return new DiffError(
759
+ `Cannot compute diff against '${baseRef}': no common merge-base with HEAD (shallow clone?). Fetch more history, e.g. \`git fetch --deepen\` or set fetch-depth: 0 in actions/checkout.`
760
+ );
761
+ }
762
+ if (/unknown revision|ambiguous argument|bad revision|invalid object name/i.test(message)) {
763
+ return new DiffError(
764
+ `Cannot compute diff: base ref '${baseRef}' does not exist in this repository. Fetch it first (e.g. \`git fetch origin ${baseRef}\`) or pass a valid --base ref.`
765
+ );
766
+ }
767
+ return new DiffError(`Cannot compute diff against '${baseRef}': ${message}`);
768
+ }
769
+ async function getChangedFiles(baseRef, cwd = process.cwd()) {
770
+ try {
771
+ const summary = await simpleGit(cwd).diffSummary(["--name-only", `${baseRef}...HEAD`]);
772
+ return summary.files.map((file) => file.file).sort();
773
+ } catch (error) {
774
+ throw toDiffError(error, baseRef);
775
+ }
776
+ }
777
+
778
+ // src/impact.ts
779
+ import picomatch from "picomatch";
780
+ function mapImpact(changedFiles, routes, ignore = []) {
781
+ const matchers = Object.entries(routes).map(([glob, urls]) => ({
782
+ isMatch: picomatch(glob, { dot: true }),
783
+ urls
784
+ }));
785
+ const isIgnored = ignore.map((glob) => picomatch(glob, { dot: true }));
786
+ const affected = /* @__PURE__ */ new Set();
787
+ const unmapped = [];
788
+ const ignored = [];
789
+ for (const file of changedFiles) {
790
+ const normalized = file.replace(/\\/g, "/");
791
+ if (isIgnored.some((matches) => matches(normalized))) {
792
+ ignored.push(normalized);
793
+ continue;
794
+ }
795
+ let matched = false;
796
+ for (const { isMatch, urls } of matchers) {
797
+ if (isMatch(normalized)) {
798
+ matched = true;
799
+ for (const url of urls) {
800
+ affected.add(url);
801
+ }
802
+ }
803
+ }
804
+ if (!matched) {
805
+ unmapped.push(normalized);
806
+ }
807
+ }
808
+ return {
809
+ affectedRoutes: [...affected].sort(),
810
+ unmappedFiles: unmapped.sort(),
811
+ ignoredFiles: ignored.sort()
812
+ };
813
+ }
814
+
815
+ // src/llm/brain.ts
816
+ import { generateObject } from "ai";
817
+
818
+ // src/llm/prompts.ts
819
+ function agentSystemPrompt() {
820
+ return `You are a meticulous QA agent executing one plain-English test step at a time in a real browser.
821
+
822
+ You receive the current page as a YAML accessibility snapshot (roles and accessible names, exactly what a user perceives) and decide the single next action to move the current step forward.
823
+
824
+ Rules:
825
+ - Return exactly ONE action per response. Never batch actions.
826
+ - Pick target elements exclusively from the current snapshot, using their exact role and accessible name. Never invent CSS selectors or guess elements not in the snapshot.
827
+ - Use "navigate" only with a URL or path as value. Use "fill" to type into textboxes/inputs. Use "press" for keyboard keys (value e.g. "Enter"). Use "select" for dropdowns (value = option label).
828
+ - Use "assert" with an expectation to verify page state (visible text, counts, URLs). Assertions never modify the page.
829
+ - Return "done" only when the current step is fully accomplished. Do not return "done" for work belonging to later steps.
830
+ - Return "fail" with a clear reason when the step is impossible to accomplish (element missing after retries, unexpected page state, blocking error).
831
+ - 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.
832
+ - Keep reasoning to one short sentence.`;
833
+ }
834
+ function agentUserPrompt(input) {
835
+ const parts = [
836
+ `Current ${input.isSetup ? "setup " : ""}step: ${input.step}`,
837
+ "",
838
+ "Page accessibility snapshot:",
839
+ input.snapshot
840
+ ];
841
+ if (input.lastResult) {
842
+ parts.push("", `Previous action result: ${input.lastResult}`);
843
+ }
844
+ parts.push(
845
+ "",
846
+ `Budget: ${input.retriesLeft} failed attempts left, ${input.iterationsLeft} actions left for this step.`,
847
+ "What is the single next action?"
848
+ );
849
+ return parts.join("\n");
850
+ }
851
+ function assertSystemPrompt() {
852
+ return `You are a QA judge. You receive a page accessibility snapshot and an expectation. Decide whether the snapshot satisfies the expectation. Be strict: only pass when the expectation is clearly met by the snapshot content. Answer with pass=true/false and a one-sentence reason.`;
853
+ }
854
+ function assertUserPrompt(expectation, snapshot) {
855
+ return [
856
+ `Expectation: ${expectation}`,
857
+ "",
858
+ "Page accessibility snapshot:",
859
+ snapshot,
860
+ "",
861
+ "Does the snapshot satisfy the expectation?"
862
+ ].join("\n");
863
+ }
864
+ function plannerSystemPrompt() {
865
+ return `You are a QA engineer writing one end-to-end test for a web page, in plain English.
866
+
867
+ You receive a YAML accessibility snapshot of the page (roles and accessible names, exactly what a user perceives) and the list of source files a pull request changed in the area this page covers.
868
+
869
+ Rules:
870
+ - Write steps a human tester could follow without looking at the code. One action or check per step.
871
+ - Refer to controls by the accessible name shown in the snapshot, spelled exactly. Never invent buttons, fields or links that are not in the snapshot.
872
+ - Never write CSS selectors, XPath, IDs or any code \u2014 the runner resolves elements live from the accessibility tree.
873
+ - Prefer the journey the changed files touch over a generic tour of the page. The changed files tell you which part of the page matters.
874
+ - End with at least one step that verifies an observable outcome (visible text, a count, a state change).
875
+ - If a step needs a credential or any secret, write it as a placeholder like {{env.TEST_PASSWORD}}. Never write a real or invented password, token or key.
876
+ - Keep the whole test to a handful of steps: one journey, not an exhaustive suite.`;
877
+ }
878
+ function plannerUserPrompt(input) {
879
+ const parts = [`Route under test: ${input.route}`, "", "Page accessibility snapshot:", input.snapshot];
880
+ if (input.changedFiles.length > 0) {
881
+ parts.push(
882
+ "",
883
+ "Files this pull request changed in the area covering this route:",
884
+ ...input.changedFiles.map((file) => `- ${file}`)
885
+ );
886
+ } else {
887
+ parts.push("", "No specific changed files for this route: cover its main user journey.");
888
+ }
889
+ parts.push("", "Write the test for this route.");
890
+ return parts.join("\n");
891
+ }
892
+
893
+ // src/llm/schemas.ts
894
+ import { z as z2 } from "zod";
895
+ var agentActionSchema = z2.object({
896
+ action: z2.enum(["navigate", "click", "fill", "press", "select", "assert", "done", "fail"]).describe("The next browser action to perform for the current step."),
897
+ target: z2.object({
898
+ role: z2.string().optional().describe('ARIA role of the target element, e.g. "button", "link", "textbox".'),
899
+ name: z2.string().optional().describe("Accessible name of the target element, exactly as shown in the snapshot."),
900
+ text: z2.string().optional().describe("Visible text of the target element, used as a fallback when no role matches.")
901
+ }).optional().describe("Element to act on, resolved from the accessibility snapshot. Omit for navigate/done/fail."),
902
+ value: z2.string().optional().describe(
903
+ 'Action payload: URL/path for navigate, text for fill, key for press (e.g. "Enter"), option label for select.'
904
+ ),
905
+ reasoning: z2.string().describe("One sentence explaining why this action moves the step forward."),
906
+ expectation: z2.string().optional().describe("For assert: the condition the current page snapshot must satisfy.")
907
+ });
908
+ var assertJudgmentSchema = z2.object({
909
+ pass: z2.boolean().describe("Whether the snapshot satisfies the expectation."),
910
+ reason: z2.string().describe("One sentence explaining the judgment.")
911
+ });
912
+ var generatedTestSchema = z2.object({
913
+ summary: z2.string().min(1).describe('One line naming the user journey this test covers, e.g. "Applying a discount updates the cart total".'),
914
+ steps: z2.array(z2.string().min(1)).min(1).describe(
915
+ "Plain-English steps a QA engineer would follow, each naming controls exactly as they appear in the snapshot."
916
+ ),
917
+ priority: z2.enum(["P0", "P1", "P2"]).describe("P0 for revenue- or auth-critical journeys, P1 for main flows, P2 for edge cases."),
918
+ tags: z2.array(z2.string()).describe('Short lowercase tags grouping this test, e.g. ["cart", "checkout"].')
919
+ });
920
+
921
+ // src/llm/brain.ts
922
+ var MalformedModelOutputError = class extends Error {
923
+ constructor(message) {
924
+ super(message);
925
+ this.name = "MalformedModelOutputError";
926
+ }
927
+ };
928
+ function createBrain(model, generate = generateObject) {
929
+ return {
930
+ async nextAction(input) {
931
+ const result = await generate({
932
+ model,
933
+ schema: agentActionSchema,
934
+ system: agentSystemPrompt(),
935
+ prompt: agentUserPrompt(input)
936
+ });
937
+ const parsed = agentActionSchema.safeParse(result.object);
938
+ if (!parsed.success) {
939
+ throw new MalformedModelOutputError(
940
+ `Model returned an invalid action: ${parsed.error.issues[0]?.message ?? "unknown"}`
941
+ );
942
+ }
943
+ return parsed.data;
944
+ },
945
+ async judge(expectation, snapshot) {
946
+ const result = await generate({
947
+ model,
948
+ schema: assertJudgmentSchema,
949
+ system: assertSystemPrompt(),
950
+ prompt: assertUserPrompt(expectation, snapshot)
951
+ });
952
+ const parsed = assertJudgmentSchema.safeParse(result.object);
953
+ if (!parsed.success) {
954
+ throw new MalformedModelOutputError(
955
+ `Model returned an invalid assert judgment: ${parsed.error.issues[0]?.message ?? "unknown"}`
956
+ );
957
+ }
958
+ return parsed.data;
959
+ }
960
+ };
961
+ }
962
+ function createPlanner(model, generate = generateObject) {
963
+ return {
964
+ async planTest(input) {
965
+ const result = await generate({
966
+ model,
967
+ schema: generatedTestSchema,
968
+ system: plannerSystemPrompt(),
969
+ prompt: plannerUserPrompt(input)
970
+ });
971
+ const parsed = generatedTestSchema.safeParse(result.object);
972
+ if (!parsed.success) {
973
+ throw new MalformedModelOutputError(
974
+ `Model returned an invalid test draft: ${parsed.error.issues[0]?.message ?? "unknown"}`
975
+ );
976
+ }
977
+ return parsed.data;
978
+ }
979
+ };
980
+ }
981
+
982
+ // src/llm/provider.ts
983
+ import { createAnthropic } from "@ai-sdk/anthropic";
984
+ import { createOpenAI } from "@ai-sdk/openai";
985
+ var DEFAULT_MODELS = {
986
+ anthropic: "claude-haiku-4-5",
987
+ openai: "gpt-4o-mini",
988
+ ollama: "qwen2.5"
989
+ };
990
+ var DEFAULT_API_KEY_ENVS = {
991
+ anthropic: "ANTHROPIC_API_KEY",
992
+ openai: "OPENAI_API_KEY"
993
+ };
994
+ var DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
995
+ var MissingApiKeyError = class extends Error {
996
+ constructor(variable, provider) {
997
+ super(
998
+ `Missing API key: environment variable ${variable} is not set (required for provider "${provider}"). Export it before running, e.g. \`export ${variable}=...\`, or change llm.provider / llm.api_key_env in .blastproof/config.yaml.`
999
+ );
1000
+ this.name = "MissingApiKeyError";
1001
+ }
1002
+ };
1003
+ function createModel(llm, env = process.env) {
1004
+ const modelId = llm.model ?? DEFAULT_MODELS[llm.provider];
1005
+ switch (llm.provider) {
1006
+ case "anthropic":
1007
+ case "openai": {
1008
+ const keyEnv = llm.api_key_env ?? DEFAULT_API_KEY_ENVS[llm.provider];
1009
+ const apiKey = env[keyEnv];
1010
+ if (!apiKey) {
1011
+ throw new MissingApiKeyError(keyEnv, llm.provider);
1012
+ }
1013
+ const model = llm.provider === "anthropic" ? createAnthropic({ apiKey })(modelId) : (
1014
+ // `.chat` = Chat Completions: works with official OpenAI and any
1015
+ // OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM) via base_url.
1016
+ createOpenAI({ apiKey, ...llm.base_url ? { baseURL: llm.base_url } : {} }).chat(modelId)
1017
+ );
1018
+ return { model, provider: llm.provider, modelId };
1019
+ }
1020
+ case "ollama": {
1021
+ const baseURL = llm.base_url ?? DEFAULT_OLLAMA_BASE_URL;
1022
+ const model = createOpenAI({ baseURL, apiKey: "ollama" }).chat(modelId);
1023
+ return { model, provider: "ollama", modelId };
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ // src/planner.ts
1029
+ import { writeFile as writeFile3 } from "fs/promises";
1030
+ import path6 from "path";
1031
+ import { stringify } from "yaml";
1032
+
1033
+ // src/runner/testfile.ts
1034
+ import { readFile as readFile3, readdir } from "fs/promises";
1035
+ import path5 from "path";
1036
+ import { parse as parseYaml2 } from "yaml";
1037
+ import { z as z3 } from "zod";
1038
+ var TESTS_RELATIVE_DIR = path5.join(".blastproof", "tests");
1039
+ var prioritySchema = z3.enum(["P0", "P1", "P2"]);
1040
+ var testFileSchema = z3.object({
1041
+ summary: z3.string().min(1),
1042
+ steps: z3.array(z3.string().min(1)).min(1),
1043
+ priority: prioritySchema.default("P1"),
1044
+ tags: z3.array(z3.string()).default([]),
1045
+ setup: z3.array(z3.string().min(1)).optional(),
1046
+ routes: z3.array(z3.string()).default([]),
1047
+ auth: z3.boolean().default(true)
1048
+ });
1049
+ var TestFileError = class extends Error {
1050
+ constructor(message) {
1051
+ super(message);
1052
+ this.name = "TestFileError";
1053
+ }
1054
+ };
1055
+ function formatIssues2(file, error) {
1056
+ return error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
1057
+ }
1058
+ async function parseTestFile(filePath) {
1059
+ let raw;
1060
+ try {
1061
+ raw = await readFile3(filePath, "utf8");
1062
+ } catch {
1063
+ throw new TestFileError(`Cannot read test file: ${filePath}`);
1064
+ }
1065
+ let data;
1066
+ try {
1067
+ data = parseYaml2(raw);
1068
+ } catch (error) {
1069
+ throw new TestFileError(
1070
+ `Invalid YAML in ${filePath}: ${error instanceof Error ? error.message : String(error)}`
1071
+ );
1072
+ }
1073
+ const result = testFileSchema.safeParse(data ?? {});
1074
+ if (!result.success) {
1075
+ throw new TestFileError(`Invalid test file ${filePath}:
1076
+ ${formatIssues2(filePath, result.error)}`);
1077
+ }
1078
+ return { path: filePath, ...result.data };
1079
+ }
1080
+ async function discoverTestFiles(root) {
1081
+ const found = [];
1082
+ async function walk(dir) {
1083
+ const entries = await readdir(dir, { withFileTypes: true });
1084
+ for (const entry of entries) {
1085
+ const full = path5.join(dir, entry.name);
1086
+ if (entry.isDirectory()) {
1087
+ await walk(full);
1088
+ } else if (/\.ya?ml$/i.test(entry.name)) {
1089
+ found.push(full);
1090
+ }
1091
+ }
1092
+ }
1093
+ await walk(root);
1094
+ return found.sort();
1095
+ }
1096
+
1097
+ // src/planner.ts
1098
+ var PlannerError = class extends Error {
1099
+ constructor(message) {
1100
+ super(message);
1101
+ this.name = "PlannerError";
1102
+ }
1103
+ };
1104
+ var CREDENTIAL_WORD = /\b(password|passwd|api[ _-]?key|token|secret|credential)\b/i;
1105
+ var QUOTED_LITERAL = /["'][^"']+["']/;
1106
+ function findSecretLiterals(steps) {
1107
+ return steps.filter(
1108
+ (step) => CREDENTIAL_WORD.test(step) && QUOTED_LITERAL.test(step) && !step.includes("{{env.")
1109
+ );
1110
+ }
1111
+ function routeToSlug(route) {
1112
+ const slug = route.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
1113
+ return slug || "home";
1114
+ }
1115
+ function renderTestYaml(draft, meta) {
1116
+ const header = [
1117
+ "# Generated by `blastproof plan` \u2014 review before committing.",
1118
+ `# route: ${meta.route}`,
1119
+ `# base: ${meta.base ?? "(explicit --route)"}`,
1120
+ `# generated: ${meta.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`,
1121
+ ""
1122
+ ].join("\n");
1123
+ return header + stringify({
1124
+ summary: draft.summary,
1125
+ priority: draft.priority,
1126
+ tags: draft.tags,
1127
+ routes: draft.routes,
1128
+ steps: draft.steps
1129
+ });
1130
+ }
1131
+ async function generateForRoute(page, options) {
1132
+ const { route, baseUrl, changedFiles, brain, snapshot = defaultSnapshot } = options;
1133
+ const url = new URL(route, baseUrl).toString();
1134
+ try {
1135
+ await page.goto(url, { timeout: 3e4 });
1136
+ } catch (error) {
1137
+ throw new PlannerError(
1138
+ `Cannot load ${url}: ${error instanceof Error ? error.message : String(error)}`
1139
+ );
1140
+ }
1141
+ const generated = await brain.planTest({ route, snapshot: await snapshot(page), changedFiles });
1142
+ const leaked = findSecretLiterals(generated.steps);
1143
+ if (leaked.length > 0) {
1144
+ throw new PlannerError(
1145
+ `Generated steps contain literal secrets instead of {{env.VAR}} placeholders: ${leaked.join(" | ")}`
1146
+ );
1147
+ }
1148
+ return { ...generated, routes: [route] };
1149
+ }
1150
+ async function writeDraft(cwd, draft, meta) {
1151
+ const file = path6.join(cwd, TESTS_RELATIVE_DIR, `${routeToSlug(meta.route)}.yaml`);
1152
+ try {
1153
+ await writeFile3(file, renderTestYaml(draft, meta), { flag: "wx" });
1154
+ } catch (error) {
1155
+ if (error.code === "EEXIST") {
1156
+ throw new PlannerError(
1157
+ `Refusing to overwrite ${path6.relative(cwd, file)} (route ${meta.route}). Delete or rename it to regenerate.`
1158
+ );
1159
+ }
1160
+ throw error;
1161
+ }
1162
+ return file;
1163
+ }
1164
+ function coveredRoutes(tests) {
1165
+ return new Set(tests.flatMap((test) => test.routes));
1166
+ }
1167
+
1168
+ // src/commands/run.ts
1169
+ import path9 from "path";
1170
+ import { chromium } from "playwright";
1171
+
1172
+ // src/report/html.ts
1173
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
1174
+ import path7 from "path";
1175
+ var ESCAPES = {
1176
+ "&": "&amp;",
1177
+ "<": "&lt;",
1178
+ ">": "&gt;",
1179
+ '"': "&quot;",
1180
+ "'": "&#39;"
1181
+ };
1182
+ function escapeHtml(text) {
1183
+ return text.replace(/[&<>"']/g, (char) => ESCAPES[char]);
1184
+ }
1185
+ async function embedScreenshot(file) {
1186
+ try {
1187
+ const bytes = await readFile4(file);
1188
+ return `data:image/png;base64,${bytes.toString("base64")}`;
1189
+ } catch {
1190
+ return void 0;
1191
+ }
1192
+ }
1193
+ var STYLES = `
1194
+ :root { color-scheme: light dark; --bg:#fff; --fg:#111; --muted:#666; --line:#e3e3e3;
1195
+ --pass:#137333; --fail:#c5221f; --card:#fafafa; }
1196
+ @media (prefers-color-scheme: dark) {
1197
+ :root { --bg:#15171a; --fg:#e8e8e8; --muted:#9aa0a6; --line:#2c2f34;
1198
+ --pass:#5bb974; --fail:#f28b82; --card:#1c1f23; }
1199
+ }
1200
+ * { box-sizing: border-box; }
1201
+ body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
1202
+ font:15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
1203
+ main { max-width: 62rem; margin: 0 auto; }
1204
+ h1 { font-size:1.35rem; margin:0 0 .25rem; }
1205
+ .sub { color:var(--muted); font-size:.85rem; margin-bottom:1.5rem; }
1206
+ .score { display:flex; align-items:baseline; gap:.75rem; flex-wrap:wrap;
1207
+ padding:1.25rem; border:1px solid var(--line); border-radius:10px;
1208
+ background:var(--card); margin-bottom:1.5rem; }
1209
+ .score b { font-size:2.5rem; line-height:1; font-variant-numeric:tabular-nums; }
1210
+ .verdict { font-weight:600; }
1211
+ .verdict.pass { color:var(--pass); } .verdict.fail { color:var(--fail); }
1212
+ .counts { color:var(--muted); font-size:.9rem; }
1213
+ table { width:100%; border-collapse:collapse; margin-bottom:1.5rem; }
1214
+ th, td { text-align:left; padding:.5rem .6rem; border-bottom:1px solid var(--line);
1215
+ font-size:.9rem; vertical-align:top; }
1216
+ th { color:var(--muted); font-weight:600; }
1217
+ td.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
1218
+ .tag { font-weight:700; font-size:.78rem; letter-spacing:.03em; }
1219
+ .tag.pass { color:var(--pass); } .tag.fail { color:var(--fail); }
1220
+ .tag.skip { color:var(--muted); }
1221
+ details { border:1px solid var(--line); border-radius:10px; padding:.75rem 1rem;
1222
+ margin-bottom:.75rem; background:var(--card); }
1223
+ details > summary { cursor:pointer; font-weight:600; }
1224
+ .reason { border-left:3px solid var(--fail); padding:.5rem .75rem; margin:.75rem 0;
1225
+ color:var(--fg); background:color-mix(in srgb, var(--fail) 8%, transparent);
1226
+ border-radius:0 6px 6px 0; }
1227
+ ol.steps { margin:.5rem 0 0; padding-left:1.25rem; }
1228
+ ol.steps li { margin:.15rem 0; }
1229
+ ol.steps li.failed { color:var(--fail); font-weight:600; }
1230
+ img.shot { max-width:100%; border:1px solid var(--line); border-radius:8px; margin-top:.75rem; }
1231
+ .note { color:var(--muted); font-style:italic; font-size:.85rem; }
1232
+ footer { color:var(--muted); font-size:.8rem; border-top:1px solid var(--line);
1233
+ padding-top:1rem; margin-top:2rem; }
1234
+ `;
1235
+ function seconds(ms) {
1236
+ return `${(ms / 1e3).toFixed(1)}s`;
1237
+ }
1238
+ function renderSteps(steps) {
1239
+ if (steps.length === 0) return "";
1240
+ const items = steps.map(
1241
+ (step) => `<li class="${step.status === "failed" ? "failed" : ""}">${escapeHtml(step.step)}${step.setup ? ' <span class="note">(setup)</span>' : ""}</li>`
1242
+ ).join("\n ");
1243
+ return `<ol class="steps">
1244
+ ${items}
1245
+ </ol>`;
1246
+ }
1247
+ async function renderTest(result, cwd) {
1248
+ const failed = result.status === "failed";
1249
+ const file = cwd ? path7.relative(cwd, result.file) : result.file;
1250
+ const parts = [
1251
+ ` <details${failed ? " open" : ""}>`,
1252
+ ` <summary><span class="tag ${failed ? "fail" : "pass"}">${failed ? "FAIL" : "PASS"}</span> ${escapeHtml(result.summary)} <span class="note">${escapeHtml(result.priority)} \xB7 ${escapeHtml(file)}</span></summary>`
1253
+ ];
1254
+ if (failed) {
1255
+ if (result.failedStep) {
1256
+ parts.push(` <p><strong>Failing step:</strong> ${escapeHtml(result.failedStep)}</p>`);
1257
+ }
1258
+ if (result.reason) {
1259
+ parts.push(` <p class="reason">${escapeHtml(result.reason)}</p>`);
1260
+ }
1261
+ }
1262
+ parts.push(` ${renderSteps(result.steps)}`);
1263
+ if (failed && result.screenshot) {
1264
+ const embedded = await embedScreenshot(result.screenshot);
1265
+ parts.push(
1266
+ embedded ? ` <img class="shot" alt="Screenshot at failure" src="${embedded}">` : ' <p class="note">Screenshot unavailable.</p>'
1267
+ );
1268
+ }
1269
+ parts.push(" </details>");
1270
+ return parts.join("\n");
1271
+ }
1272
+ async function renderHtml(results, skipped, meta) {
1273
+ const passed = results.filter((r) => r.status === "passed").length;
1274
+ const failures = results.filter((r) => r.status === "failed");
1275
+ const generatedAt = (meta.generatedAt ?? /* @__PURE__ */ new Date()).toISOString();
1276
+ const ordered = [...failures, ...results.filter((r) => r.status === "passed")];
1277
+ const detail = (await Promise.all(ordered.map((r) => renderTest(r, meta.cwd)))).join("\n");
1278
+ 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>`;
1279
+ const rows = results.map((r) => {
1280
+ const file = meta.cwd ? path7.relative(meta.cwd, r.file) : r.file;
1281
+ return ` <tr><td><span class="tag ${r.status === "failed" ? "fail" : "pass"}">${r.status === "failed" ? "FAIL" : "PASS"}</span></td><td>${escapeHtml(r.priority)}</td><td>${escapeHtml(r.summary)}</td><td>${escapeHtml(file)}</td><td class="num">${seconds(r.durationMs)}</td></tr>`;
1282
+ }).join("\n");
1283
+ const skippedRows = skipped.map((s) => {
1284
+ const file = meta.cwd ? path7.relative(meta.cwd, s.path) : s.path;
1285
+ return ` <tr><td><span class="tag skip">SKIP</span></td><td>\u2014</td><td>${escapeHtml(s.summary)}</td><td>${escapeHtml(file)}</td><td class="num">\u2014</td></tr>`;
1286
+ }).join("\n");
1287
+ return `<!doctype html>
1288
+ <html lang="en">
1289
+ <head>
1290
+ <meta charset="utf-8">
1291
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1292
+ <title>blastproof report</title>
1293
+ <style>${STYLES}</style>
1294
+ </head>
1295
+ <body>
1296
+ <main>
1297
+ <h1>blastproof report</h1>
1298
+ <p class="sub">${escapeHtml(generatedAt)} \xB7 ${seconds(meta.durationMs)}</p>
1299
+
1300
+ <section class="score">
1301
+ <b>${meta.score}</b>
1302
+ ${verdict}
1303
+ <span class="counts">${passed} passed \xB7 ${failures.length} failed${skipped.length > 0 ? ` \xB7 ${skipped.length} skipped` : ""}</span>
1304
+ </section>
1305
+
1306
+ <table>
1307
+ <thead><tr><th>Status</th><th>Priority</th><th>Test</th><th>File</th><th class="num">Time</th></tr></thead>
1308
+ <tbody>
1309
+ ${rows}${rows && skippedRows ? "\n" : ""}${skippedRows}
1310
+ </tbody>
1311
+ </table>
1312
+
1313
+ ${detail}
1314
+
1315
+ <footer>Generated by blastproof. Screenshots are embedded, so this file works offline.</footer>
1316
+ </main>
1317
+ </body>
1318
+ </html>
1319
+ `;
1320
+ }
1321
+ async function writeHtml(file, html) {
1322
+ await mkdir4(path7.dirname(file), { recursive: true });
1323
+ await writeFile4(file, html, "utf8");
1324
+ return file;
1325
+ }
1326
+
1327
+ // src/report/junit.ts
1328
+ import { mkdir as mkdir5, writeFile as writeFile5 } from "fs/promises";
1329
+ import path8 from "path";
1330
+ var ESCAPES2 = {
1331
+ "&": "&amp;",
1332
+ "<": "&lt;",
1333
+ ">": "&gt;",
1334
+ '"': "&quot;",
1335
+ "'": "&apos;"
1336
+ };
1337
+ function escapeXml(text) {
1338
+ return text.replace(/[&<>"']/g, (char) => ESCAPES2[char]);
1339
+ }
1340
+ function seconds2(ms) {
1341
+ return (ms / 1e3).toFixed(3);
1342
+ }
1343
+ function renderJUnit(results, skipped, meta) {
1344
+ const relative = (file) => meta.cwd ? path8.relative(meta.cwd, file) : file;
1345
+ const failures = results.filter((result) => result.status === "failed").length;
1346
+ const lines = [
1347
+ '<?xml version="1.0" encoding="UTF-8"?>',
1348
+ `<testsuite name="blastproof" tests="${results.length + skipped.length}" failures="${failures}" skipped="${skipped.length}" time="${seconds2(meta.durationMs)}">`,
1349
+ " <properties>",
1350
+ ` <property name="score" value="${meta.score}"/>`,
1351
+ " </properties>"
1352
+ ];
1353
+ for (const result of results) {
1354
+ const open = ` <testcase classname="${escapeXml(relative(result.file))}" name="${escapeXml(result.summary)}" time="${seconds2(result.durationMs)}"`;
1355
+ if (result.status === "passed") {
1356
+ lines.push(`${open}/>`);
1357
+ continue;
1358
+ }
1359
+ const reason = result.reason ?? "test failed";
1360
+ const body = result.failedStep ? `failing step: ${result.failedStep}
1361
+ ${reason}` : reason;
1362
+ lines.push(`${open}>`);
1363
+ lines.push(` <failure message="${escapeXml(reason)}">${escapeXml(body)}</failure>`);
1364
+ lines.push(" </testcase>");
1365
+ }
1366
+ for (const test of skipped) {
1367
+ lines.push(
1368
+ ` <testcase classname="${escapeXml(relative(test.path))}" name="${escapeXml(test.summary)}" time="0.000">`,
1369
+ ' <skipped message="no routes: declared, skipped by --impacted"/>',
1370
+ " </testcase>"
1371
+ );
1372
+ }
1373
+ lines.push("</testsuite>", "");
1374
+ return lines.join("\n");
1375
+ }
1376
+ async function writeJUnit(file, xml) {
1377
+ await mkdir5(path8.dirname(file), { recursive: true });
1378
+ await writeFile5(file, xml, "utf8");
1379
+ return file;
1380
+ }
1381
+
1382
+ // src/report/score.ts
1383
+ var WEIGHTS = { P0: 3, P1: 2, P2: 1 };
1384
+ var DEFAULT_WEIGHT = WEIGHTS.P1;
1385
+ function computeScore(results) {
1386
+ let total = 0;
1387
+ let passed = 0;
1388
+ for (const result of results) {
1389
+ const weight = WEIGHTS[result.priority] ?? DEFAULT_WEIGHT;
1390
+ total += weight;
1391
+ if (result.status === "passed") passed += weight;
1392
+ }
1393
+ if (total === 0) return 100;
1394
+ return Math.round(100 * passed / total);
1395
+ }
1396
+ function formatScoreLine(score, results, threshold) {
1397
+ if (results.length === 0) {
1398
+ const base = "Score: 100 (no tests executed)";
1399
+ return threshold === void 0 ? base : `${base} \u2014 min-score ${threshold}: pass`;
1400
+ }
1401
+ if (threshold === void 0) return `Score: ${score}`;
1402
+ return score >= threshold ? `Score: ${score} \u2014 min-score ${threshold}: pass` : `Score: ${score} \u2014 min-score ${threshold}: FAIL (below threshold)`;
1403
+ }
1404
+
1405
+ // src/runner/selection.ts
1406
+ function matchesFilters(test, filters) {
1407
+ if (filters.tags.length > 0 && !filters.tags.some((tag) => test.tags.includes(tag))) {
1408
+ return false;
1409
+ }
1410
+ if (filters.priority && test.priority !== filters.priority) {
1411
+ return false;
1412
+ }
1413
+ if (filters.query && !test.summary.toLowerCase().includes(filters.query.toLowerCase())) {
1414
+ return false;
1415
+ }
1416
+ return true;
1417
+ }
1418
+ function selectImpactedTests(tests, affectedRoutes, filters = { tags: [] }) {
1419
+ const affected = new Set(affectedRoutes);
1420
+ const selected = [];
1421
+ const unroutedSkipped = [];
1422
+ for (const test of tests) {
1423
+ if (test.routes.length === 0) {
1424
+ unroutedSkipped.push(test);
1425
+ continue;
1426
+ }
1427
+ if (!test.routes.some((route) => affected.has(route))) continue;
1428
+ if (!matchesFilters(test, filters)) continue;
1429
+ selected.push(test);
1430
+ }
1431
+ const covered = new Set(selected.flatMap((test) => test.routes));
1432
+ return {
1433
+ selected,
1434
+ unroutedSkipped,
1435
+ uncoveredRoutes: affectedRoutes.filter((route) => !covered.has(route))
1436
+ };
1437
+ }
1438
+
1439
+ // src/commands/run.ts
1440
+ var EXIT_OK = 0;
1441
+ var EXIT_FAILED = 1;
1442
+ var EXIT_USAGE = 2;
1443
+ function sessionId() {
1444
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1445
+ }
1446
+ function applyUrlOverride(config, url) {
1447
+ if (url === void 0) return config;
1448
+ try {
1449
+ new URL(url);
1450
+ } catch {
1451
+ throw new ConfigError(
1452
+ `Invalid --url '${url}': expected an absolute URL like http://localhost:4173.`
1453
+ );
1454
+ }
1455
+ return { ...config, base_url: url };
1456
+ }
1457
+ function resolveSecretsAndSteps(test) {
1458
+ const mask = new SecretsMask();
1459
+ for (const step of [...test.setup ?? [], ...test.steps]) {
1460
+ mask.registerFrom(step);
1461
+ }
1462
+ return {
1463
+ test: {
1464
+ ...test,
1465
+ setup: test.setup?.map((step) => substituteEnv(step)),
1466
+ steps: test.steps.map((step) => substituteEnv(step))
1467
+ },
1468
+ mask
1469
+ };
1470
+ }
1471
+ function printEvent(event) {
1472
+ switch (event.type) {
1473
+ case "step-start":
1474
+ console.log(` ${event.setup ? "(setup) " : ""}step ${event.index + 1}/${event.total}: ${event.step}`);
1475
+ break;
1476
+ case "action": {
1477
+ const { action, result } = event;
1478
+ const target = action.target ? ` ${action.target.role ?? ""} "${action.target.name ?? action.target.text ?? ""}"` : "";
1479
+ const value = action.value ? ` [${action.value}]` : "";
1480
+ console.log(` -> ${action.action}${target}${value} :: ${result}`);
1481
+ break;
1482
+ }
1483
+ case "step-end":
1484
+ if (event.status === "failed") {
1485
+ console.log(` X step failed: ${event.reason ?? "unknown reason"}`);
1486
+ }
1487
+ break;
1488
+ }
1489
+ }
1490
+ function printSummary(results) {
1491
+ const passed = results.filter((r) => r.status === "passed");
1492
+ const failed = results.filter((r) => r.status === "failed");
1493
+ console.log("\n--- Summary ---------------------------------------------------");
1494
+ const rows = results.map((r) => ({
1495
+ status: r.status === "passed" ? "PASS" : "FAIL",
1496
+ priority: r.priority,
1497
+ summary: r.summary,
1498
+ duration: `${(r.durationMs / 1e3).toFixed(1)}s`
1499
+ }));
1500
+ const width = (key) => Math.max(key.length, ...rows.map((r) => String(r[key]).length));
1501
+ const [ws, wp, wsum, wd] = [width("status"), width("priority"), width("summary"), width("duration")];
1502
+ for (const row of rows) {
1503
+ console.log(
1504
+ `${row.status.padEnd(ws)} ${row.priority.padEnd(wp)} ${row.summary.padEnd(wsum)} ${row.duration.padStart(wd)}`
1505
+ );
1506
+ }
1507
+ console.log("---------------------------------------------------------------");
1508
+ console.log(`${passed.length} passed, ${failed.length} failed, ${results.length} total`);
1509
+ for (const r of failed) {
1510
+ console.log(`
1511
+ X ${r.summary} (${r.file})`);
1512
+ if (r.failedStep) console.log(` failing step: ${r.failedStep}`);
1513
+ if (r.reason) console.log(` reason: ${r.reason}`);
1514
+ if (r.screenshot) console.log(` screenshot: ${r.screenshot}`);
1515
+ }
1516
+ }
1517
+ async function runOne(browser, test, config, sessionDir, session) {
1518
+ const brain = createBrain(createModel(config.llm).model);
1519
+ let resolved;
1520
+ let mask;
1521
+ try {
1522
+ ({ test: resolved, mask } = resolveSecretsAndSteps(test));
1523
+ } catch (error) {
1524
+ if (error instanceof MissingEnvError) {
1525
+ return {
1526
+ file: test.path,
1527
+ summary: test.summary,
1528
+ priority: test.priority,
1529
+ tags: test.tags,
1530
+ status: "failed",
1531
+ steps: [],
1532
+ reason: error.message,
1533
+ durationMs: 0
1534
+ };
1535
+ }
1536
+ throw error;
1537
+ }
1538
+ const context = await browser.newContext(test.auth ? contextOptions(session) : {});
1539
+ try {
1540
+ const page = await context.newPage();
1541
+ page.setDefaultTimeout(config.browser.timeout_ms);
1542
+ return await executeTest(page, resolved, {
1543
+ brain,
1544
+ sessionDir,
1545
+ baseUrl: config.base_url,
1546
+ maxRetries: config.max_retries_per_step,
1547
+ mask: (text) => mask.mask(text),
1548
+ onEvent: printEvent
1549
+ });
1550
+ } finally {
1551
+ await context.close();
1552
+ }
1553
+ }
1554
+ function printImpactReport(impact, selection, cwd) {
1555
+ console.log("\n--- Impact -----------------------------------------------------");
1556
+ console.log(
1557
+ impact.affectedRoutes.length > 0 ? "Affected routes:" : "Affected routes: none"
1558
+ );
1559
+ for (const route of impact.affectedRoutes) console.log(` ${route}`);
1560
+ if (impact.unmappedFiles.length > 0) {
1561
+ console.log("Unclassified files (matched by no routes: or ignore: glob):");
1562
+ for (const file of impact.unmappedFiles) console.log(` ${file}`);
1563
+ }
1564
+ if (impact.ignoredFiles.length > 0) {
1565
+ console.log(`Ignored files: ${impact.ignoredFiles.length}`);
1566
+ }
1567
+ if (selection.unroutedSkipped.length > 0) {
1568
+ console.log("Unrouted tests (skipped):");
1569
+ for (const test of selection.unroutedSkipped) {
1570
+ console.log(` ${test.summary} (${path9.relative(cwd, test.path)})`);
1571
+ }
1572
+ }
1573
+ if (selection.uncoveredRoutes.length > 0) {
1574
+ console.log("Affected but uncovered routes:");
1575
+ for (const route of selection.uncoveredRoutes) console.log(` ${route}`);
1576
+ }
1577
+ console.log("---------------------------------------------------------------");
1578
+ }
1579
+ async function finalize(results, skipped, options, sessionDir, durationMs, impact) {
1580
+ if (results.length > 0) printSummary(results);
1581
+ const score = computeScore(results);
1582
+ console.log(formatScoreLine(score, results, options.minScore));
1583
+ if (options.junit) {
1584
+ const target = typeof options.junit === "string" ? path9.resolve(options.cwd, options.junit) : path9.join(sessionDir, "junit.xml");
1585
+ const xml = renderJUnit(results, skipped, { score, durationMs, cwd: options.cwd });
1586
+ await writeJUnit(target, xml);
1587
+ console.log(`JUnit report: ${path9.relative(options.cwd, target)}`);
1588
+ }
1589
+ if (options.html) {
1590
+ const target = typeof options.html === "string" ? path9.resolve(options.cwd, options.html) : path9.join(sessionDir, "report.html");
1591
+ const html = await renderHtml(results, skipped, {
1592
+ score,
1593
+ durationMs,
1594
+ minScore: options.minScore,
1595
+ cwd: options.cwd
1596
+ });
1597
+ await writeHtml(target, html);
1598
+ console.log(`HTML report: ${path9.relative(options.cwd, target)}`);
1599
+ }
1600
+ const unclassified = options.failOnUnmapped ? impact?.unmappedFiles ?? [] : [];
1601
+ if (unclassified.length > 0) {
1602
+ console.error(
1603
+ `error: ${unclassified.length} changed file(s) match no routes: or ignore: glob, so their blast radius is unknown:`
1604
+ );
1605
+ for (const file of unclassified) console.error(` ${file}`);
1606
+ console.error(
1607
+ "Map them in routes: if they can affect a page, or list them in ignore: if they cannot."
1608
+ );
1609
+ return EXIT_FAILED;
1610
+ }
1611
+ if (options.minScore !== void 0) {
1612
+ return score >= options.minScore ? EXIT_OK : EXIT_FAILED;
1613
+ }
1614
+ return results.some((result) => result.status === "failed") ? EXIT_FAILED : EXIT_OK;
1615
+ }
1616
+ function printDryRun(selected, baseUrl, cwd) {
1617
+ console.log(`
1618
+ Dry run: ${selected.length} test(s) selected, base_url=${baseUrl}`);
1619
+ for (const test of selected) {
1620
+ console.log(` ${test.summary} [${test.priority}] (${path9.relative(cwd, test.path)})`);
1621
+ }
1622
+ console.log("Dry run: no browser launched, no LLM calls made.");
1623
+ }
1624
+ async function runCommand(options) {
1625
+ let config;
1626
+ try {
1627
+ config = applyUrlOverride(await loadConfig(options.cwd), options.url);
1628
+ } catch (error) {
1629
+ if (error instanceof ConfigError) {
1630
+ console.error(`error: ${error.message}`);
1631
+ return EXIT_USAGE;
1632
+ }
1633
+ throw error;
1634
+ }
1635
+ const impacted = options.impacted ?? false;
1636
+ const base = options.base ?? "main";
1637
+ let impact;
1638
+ if (impacted) {
1639
+ let changedFiles;
1640
+ try {
1641
+ changedFiles = await getChangedFiles(base, options.cwd);
1642
+ } catch (error) {
1643
+ if (error instanceof DiffError) {
1644
+ console.error(`error: ${error.message}`);
1645
+ return EXIT_USAGE;
1646
+ }
1647
+ throw error;
1648
+ }
1649
+ impact = mapImpact(changedFiles, config.routes ?? {}, config.ignore ?? []);
1650
+ }
1651
+ const testsDir = path9.join(options.cwd, TESTS_RELATIVE_DIR);
1652
+ let files;
1653
+ try {
1654
+ files = await discoverTestFiles(testsDir);
1655
+ } catch {
1656
+ console.error(`error: no tests directory at ${TESTS_RELATIVE_DIR}. Run \`blastproof init\` first.`);
1657
+ return EXIT_USAGE;
1658
+ }
1659
+ const results = [];
1660
+ const parsed = [];
1661
+ for (const file of files) {
1662
+ try {
1663
+ parsed.push(await parseTestFile(file));
1664
+ } catch (error) {
1665
+ if (error instanceof TestFileError) {
1666
+ results.push({
1667
+ file: path9.relative(options.cwd, file),
1668
+ summary: path9.basename(file),
1669
+ priority: "P1",
1670
+ tags: [],
1671
+ status: "failed",
1672
+ steps: [],
1673
+ reason: error.message,
1674
+ durationMs: 0
1675
+ });
1676
+ continue;
1677
+ }
1678
+ throw error;
1679
+ }
1680
+ }
1681
+ const selection = impact ? selectImpactedTests(parsed, impact.affectedRoutes, options) : {
1682
+ selected: parsed.filter((test) => matchesFilters(test, options)),
1683
+ unroutedSkipped: [],
1684
+ uncoveredRoutes: []
1685
+ };
1686
+ const selected = selection.selected;
1687
+ if (impact) {
1688
+ printImpactReport(impact, selection, options.cwd);
1689
+ }
1690
+ if (options.dryRun) {
1691
+ printDryRun(selected, config.base_url, options.cwd);
1692
+ return EXIT_OK;
1693
+ }
1694
+ const sessionDir = path9.join(options.cwd, ".blastproof", "reports", sessionId());
1695
+ const startedAt = Date.now();
1696
+ if (selected.length === 0) {
1697
+ console.log(
1698
+ impact ? "No impacted tests to run." : "No tests matched the given filters."
1699
+ );
1700
+ return finalize(results, selection.unroutedSkipped, options, sessionDir, Date.now() - startedAt, impact);
1701
+ }
1702
+ try {
1703
+ createModel(config.llm);
1704
+ } catch (error) {
1705
+ if (error instanceof MissingApiKeyError) {
1706
+ console.error(`error: ${error.message}`);
1707
+ return EXIT_USAGE;
1708
+ }
1709
+ throw error;
1710
+ }
1711
+ const { provider, modelId } = createModel(config.llm);
1712
+ console.log(
1713
+ `blastproof run: ${selected.length} test(s), provider=${provider} model=${modelId}, base_url=${config.base_url}`
1714
+ );
1715
+ const browser = await chromium.launch({ headless: config.browser.headless });
1716
+ try {
1717
+ let session;
1718
+ if (config.auth) {
1719
+ try {
1720
+ console.log("Authenticating...");
1721
+ session = await authenticate({
1722
+ auth: config.auth,
1723
+ cwd: options.cwd,
1724
+ baseUrl: config.base_url,
1725
+ browser,
1726
+ brain: createBrain(createModel(config.llm).model),
1727
+ maxRetries: config.max_retries_per_step,
1728
+ onEvent: printEvent
1729
+ });
1730
+ } catch (error) {
1731
+ if (error instanceof AuthError) {
1732
+ console.error(`error: ${error.message}`);
1733
+ return EXIT_USAGE;
1734
+ }
1735
+ throw error;
1736
+ }
1737
+ }
1738
+ for (const test of selected) {
1739
+ console.log(`
1740
+ > ${test.summary} [${test.priority}] (${path9.relative(options.cwd, test.path)})`);
1741
+ results.push(await runOne(browser, test, config, sessionDir, session));
1742
+ }
1743
+ } finally {
1744
+ await browser.close();
1745
+ }
1746
+ return finalize(results, selection.unroutedSkipped, options, sessionDir, Date.now() - startedAt, impact);
1747
+ }
1748
+
1749
+ // src/commands/plan.ts
1750
+ function printAuthEvent(event) {
1751
+ if (event.type === "step-start") {
1752
+ console.log(` step ${event.index + 1}/${event.total}: ${event.step}`);
1753
+ } else if (event.type === "action") {
1754
+ console.log(` -> ${event.action.action} :: ${event.result}`);
1755
+ } else if (event.status === "failed") {
1756
+ console.log(` X step failed: ${event.reason ?? "unknown reason"}`);
1757
+ }
1758
+ }
1759
+ async function loadSuite(cwd) {
1760
+ const testsDir = path10.join(cwd, TESTS_RELATIVE_DIR);
1761
+ let files;
1762
+ try {
1763
+ files = await discoverTestFiles(testsDir);
1764
+ } catch {
1765
+ return [];
1766
+ }
1767
+ const parsed = [];
1768
+ for (const file of files) {
1769
+ try {
1770
+ parsed.push(await parseTestFile(file));
1771
+ } catch (error) {
1772
+ if (error instanceof TestFileError) continue;
1773
+ throw error;
1774
+ }
1775
+ }
1776
+ return parsed;
1777
+ }
1778
+ async function resolveTargets(options, config, suite) {
1779
+ const covered = coveredRoutes(suite);
1780
+ if (options.routes && options.routes.length > 0) {
1781
+ return { work: options.routes.map((route) => ({ route, changedFiles: [] })), alreadyCovered: [] };
1782
+ }
1783
+ const base = options.base ?? "main";
1784
+ const changedFiles = await getChangedFiles(base, options.cwd);
1785
+ const impact = mapImpact(changedFiles, config.routes ?? {}, config.ignore ?? []);
1786
+ const filesForRoute = (route) => changedFiles.filter(
1787
+ (file) => mapImpact([file], config.routes ?? {}, config.ignore ?? []).affectedRoutes.includes(route)
1788
+ );
1789
+ const work = [];
1790
+ const alreadyCovered = [];
1791
+ for (const route of impact.affectedRoutes) {
1792
+ if (covered.has(route)) {
1793
+ alreadyCovered.push(route);
1794
+ continue;
1795
+ }
1796
+ work.push({ route, changedFiles: filesForRoute(route) });
1797
+ }
1798
+ return { work, alreadyCovered };
1799
+ }
1800
+ async function planCommand(options) {
1801
+ let config;
1802
+ try {
1803
+ config = applyUrlOverride(await loadConfig(options.cwd), options.url);
1804
+ } catch (error) {
1805
+ if (error instanceof ConfigError) {
1806
+ console.error(`error: ${error.message}`);
1807
+ return EXIT_USAGE;
1808
+ }
1809
+ throw error;
1810
+ }
1811
+ const suite = await loadSuite(options.cwd);
1812
+ let work;
1813
+ let alreadyCovered;
1814
+ try {
1815
+ ({ work, alreadyCovered } = await resolveTargets(options, config, suite));
1816
+ } catch (error) {
1817
+ if (error instanceof DiffError) {
1818
+ console.error(`error: ${error.message}`);
1819
+ return EXIT_USAGE;
1820
+ }
1821
+ throw error;
1822
+ }
1823
+ if (alreadyCovered.length > 0) {
1824
+ console.log("Already covered (skipped):");
1825
+ for (const route of alreadyCovered) console.log(` ${route}`);
1826
+ }
1827
+ if (work.length === 0) {
1828
+ console.log("Nothing to generate: no affected route is missing coverage.");
1829
+ return EXIT_OK;
1830
+ }
1831
+ try {
1832
+ createModel(config.llm);
1833
+ } catch (error) {
1834
+ if (error instanceof MissingApiKeyError) {
1835
+ console.error(`error: ${error.message}`);
1836
+ return EXIT_USAGE;
1837
+ }
1838
+ throw error;
1839
+ }
1840
+ const { model, provider, modelId } = createModel(config.llm);
1841
+ const brain = createPlanner(model);
1842
+ const base = options.routes && options.routes.length > 0 ? void 0 : options.base ?? "main";
1843
+ console.log(
1844
+ `blastproof plan: ${work.length} route(s), provider=${provider} model=${modelId}, base_url=${config.base_url}`
1845
+ );
1846
+ const generated = [];
1847
+ const written = [];
1848
+ const failed = [];
1849
+ const browser = await chromium2.launch({ headless: config.browser.headless });
1850
+ try {
1851
+ let session;
1852
+ if (config.auth) {
1853
+ try {
1854
+ console.log("Authenticating...");
1855
+ session = await authenticate({
1856
+ auth: config.auth,
1857
+ cwd: options.cwd,
1858
+ baseUrl: config.base_url,
1859
+ browser,
1860
+ brain: createBrain(createModel(config.llm).model),
1861
+ maxRetries: config.max_retries_per_step,
1862
+ onEvent: printAuthEvent
1863
+ });
1864
+ } catch (error) {
1865
+ if (error instanceof AuthError) {
1866
+ console.error(`error: ${error.message}`);
1867
+ return EXIT_USAGE;
1868
+ }
1869
+ throw error;
1870
+ }
1871
+ }
1872
+ for (const { route, changedFiles } of work) {
1873
+ console.log(`
1874
+ > ${route}`);
1875
+ let draft;
1876
+ const context = await browser.newContext(contextOptions(session));
1877
+ try {
1878
+ const page = await context.newPage();
1879
+ page.setDefaultTimeout(config.browser.timeout_ms);
1880
+ draft = await generateForRoute(page, {
1881
+ route,
1882
+ baseUrl: config.base_url,
1883
+ changedFiles,
1884
+ brain
1885
+ });
1886
+ } catch (error) {
1887
+ failed.push({ route, reason: error instanceof Error ? error.message : String(error) });
1888
+ console.log(` X failed: ${error instanceof Error ? error.message : String(error)}`);
1889
+ continue;
1890
+ } finally {
1891
+ await context.close();
1892
+ }
1893
+ generated.push(route);
1894
+ if (options.write) {
1895
+ try {
1896
+ const file = await writeDraft(options.cwd, draft, { route, base });
1897
+ written.push(path10.relative(options.cwd, file));
1898
+ console.log(` wrote ${path10.relative(options.cwd, file)}`);
1899
+ } catch (error) {
1900
+ if (error instanceof PlannerError) {
1901
+ generated.pop();
1902
+ failed.push({ route, reason: error.message });
1903
+ console.log(` X failed: ${error.message}`);
1904
+ continue;
1905
+ }
1906
+ throw error;
1907
+ }
1908
+ } else {
1909
+ console.log(`
1910
+ ${renderTestYaml(draft, { route, base })}`);
1911
+ }
1912
+ }
1913
+ } finally {
1914
+ await browser.close();
1915
+ }
1916
+ console.log("\n--- Plan -------------------------------------------------------");
1917
+ console.log(`Generated: ${generated.length > 0 ? generated.join(", ") : "none"}`);
1918
+ if (written.length > 0) {
1919
+ console.log("Written:");
1920
+ for (const file of written) console.log(` ${file}`);
1921
+ }
1922
+ if (!options.write && generated.length > 0) {
1923
+ console.log("Preview only: no files written. Re-run with --write to persist.");
1924
+ }
1925
+ if (failed.length > 0) {
1926
+ console.log("Failed:");
1927
+ for (const { route, reason } of failed) console.log(` ${route}: ${reason}`);
1928
+ }
1929
+ console.log("---------------------------------------------------------------");
1930
+ return failed.length > 0 ? EXIT_FAILED : EXIT_OK;
1931
+ }
1932
+
1933
+ // src/commands/test.ts
1934
+ function section(title) {
1935
+ console.log(`
1936
+ === ${title} ${"=".repeat(Math.max(0, 58 - title.length))}`);
1937
+ }
1938
+ async function testCommand(options) {
1939
+ section("Verify: tests covering the affected routes");
1940
+ const runCode = await runCommand({
1941
+ cwd: options.cwd,
1942
+ tags: [],
1943
+ impacted: true,
1944
+ base: options.base,
1945
+ url: options.url,
1946
+ minScore: options.minScore,
1947
+ junit: options.junit,
1948
+ html: options.html,
1949
+ failOnUnmapped: options.failOnUnmapped
1950
+ });
1951
+ if (runCode === EXIT_USAGE) return EXIT_USAGE;
1952
+ section("Draft: affected routes no test covers");
1953
+ const planCode = await planCommand({
1954
+ cwd: options.cwd,
1955
+ base: options.base,
1956
+ url: options.url,
1957
+ write: options.write
1958
+ });
1959
+ if (planCode === EXIT_USAGE) return EXIT_USAGE;
1960
+ console.log(
1961
+ "\nDrafts are not executed and do not affect the score \u2014 review them before they join the suite."
1962
+ );
1963
+ return runCode === EXIT_OK && planCode === EXIT_OK ? EXIT_OK : EXIT_FAILED;
1964
+ }
1965
+
1966
+ // src/cli.ts
1967
+ function collect(value, previous) {
1968
+ return [...previous, value];
1969
+ }
1970
+ function parsePriority(value) {
1971
+ if (value !== "P0" && value !== "P1" && value !== "P2") {
1972
+ throw new InvalidArgumentError("priority must be one of P0, P1, P2");
1973
+ }
1974
+ return value;
1975
+ }
1976
+ function parseMinScore(value) {
1977
+ const score = Number(value);
1978
+ if (!Number.isInteger(score) || score < 0 || score > 100) {
1979
+ throw new InvalidArgumentError("min-score must be an integer between 0 and 100");
1980
+ }
1981
+ return score;
1982
+ }
1983
+ var program = new Command();
1984
+ program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.0.1");
1985
+ program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
1986
+ try {
1987
+ const result = await initProject(process.cwd());
1988
+ console.log(formatInitGuidance(result));
1989
+ process.exitCode = EXIT_OK;
1990
+ } catch (error) {
1991
+ console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
1992
+ process.exitCode = EXIT_USAGE;
1993
+ }
1994
+ });
1995
+ program.command("run").description("Discover and run all tests under .blastproof/tests/ agentically").option("--tag <tag>", "only run tests with this tag (repeatable)", collect, []).option("--priority <priority>", "only run tests with this priority (P0|P1|P2)", parsePriority).option("--query <text>", "only run tests whose summary contains <text> (case-insensitive)").option("--impacted", "run only tests impacted by the diff vs --base (uses routes: mappings)").option("--base <ref>", "base git ref for --impacted", "main").option("--url <url>", "override config base_url for this run only (config file untouched)").option("--dry-run", "print the selection plan and exit without launching a browser or calling the LLM").option(
1996
+ "--min-score <n>",
1997
+ "require a weighted score of at least n (0-100); replaces the all-must-pass rule",
1998
+ parseMinScore
1999
+ ).option("--junit [path]", "write a JUnit XML report (default: .blastproof/reports/<session>/junit.xml)").option("--html [path]", "write a self-contained HTML report (default: .blastproof/reports/<session>/report.html)").option("--fail-on-unmapped", "fail when a changed file matches no routes: or ignore: glob").action(
2000
+ async (options) => {
2001
+ try {
2002
+ process.exitCode = await runCommand({
2003
+ cwd: process.cwd(),
2004
+ tags: options.tag,
2005
+ priority: options.priority,
2006
+ query: options.query,
2007
+ impacted: options.impacted,
2008
+ base: options.base,
2009
+ url: options.url,
2010
+ dryRun: options.dryRun,
2011
+ minScore: options.minScore,
2012
+ junit: options.junit,
2013
+ html: options.html,
2014
+ failOnUnmapped: options.failOnUnmapped
2015
+ });
2016
+ } catch (error) {
2017
+ console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
2018
+ process.exitCode = EXIT_USAGE;
2019
+ }
2020
+ }
2021
+ );
2022
+ program.command("plan").description("Generate plain-English YAML tests for affected routes no test covers yet").option("--base <ref>", "base git ref for the diff", "main").option("--url <url>", "override config base_url for this run only (config file untouched)").option("--route <route>", "generate for this route, bypassing the diff (repeatable)", collect, []).option("--write", "persist drafts under .blastproof/tests/ instead of previewing them").action(async (options) => {
2023
+ try {
2024
+ process.exitCode = await planCommand({
2025
+ cwd: process.cwd(),
2026
+ base: options.base,
2027
+ url: options.url,
2028
+ routes: options.route,
2029
+ write: options.write
2030
+ });
2031
+ } catch (error) {
2032
+ console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
2033
+ process.exitCode = EXIT_USAGE;
2034
+ }
2035
+ });
2036
+ program.command("test").description("Full PR pipeline: run the tests covering the diff, then draft tests for the gaps").option("--base <ref>", "base git ref for the diff", "main").option("--url <url>", "override config base_url for this run only (config file untouched)").option(
2037
+ "--min-score <n>",
2038
+ "require a weighted score of at least n (0-100); replaces the all-must-pass rule",
2039
+ parseMinScore
2040
+ ).option("--junit [path]", "write a JUnit XML report (default: .blastproof/reports/<session>/junit.xml)").option("--html [path]", "write a self-contained HTML report (default: .blastproof/reports/<session>/report.html)").option("--write", "persist generated drafts under .blastproof/tests/ instead of previewing them").option("--fail-on-unmapped", "fail when a changed file matches no routes: or ignore: glob").action(
2041
+ async (options) => {
2042
+ try {
2043
+ process.exitCode = await testCommand({
2044
+ cwd: process.cwd(),
2045
+ base: options.base,
2046
+ url: options.url,
2047
+ minScore: options.minScore,
2048
+ junit: options.junit,
2049
+ html: options.html,
2050
+ write: options.write,
2051
+ failOnUnmapped: options.failOnUnmapped
2052
+ });
2053
+ } catch (error) {
2054
+ console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
2055
+ process.exitCode = EXIT_USAGE;
2056
+ }
2057
+ }
2058
+ );
2059
+ program.exitOverride();
2060
+ for (const command of program.commands) command.exitOverride();
2061
+ try {
2062
+ await program.parseAsync(process.argv);
2063
+ } catch (error) {
2064
+ const exitCode = error.exitCode ?? EXIT_USAGE;
2065
+ process.exitCode = exitCode === 0 ? EXIT_OK : EXIT_USAGE;
2066
+ }
2067
+ //# sourceMappingURL=cli.js.map