aztrx-cli 0.4.3 → 0.4.5

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.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Recorded repro (Phase 3) — turns a reproducible bug into a shareable
3
+ * "before/after" proof: an animated GIF and a static crash screenshot, both
4
+ * destined for the patrol PR body.
5
+ *
6
+ * The capture is deliberately OFF the hot path. Verification replays a bug many
7
+ * times (ddmin + validator), but the recording runs once, only when a PR is about
8
+ * to open — so it launches its own browser instead of reusing ReplayEngine's.
9
+ *
10
+ * GIF encoding is pure-JS (`gifenc` + `pngjs`), no ffmpeg, so it works for every
11
+ * `npm install aztrx-cli` user rather than only machines with ffmpeg on PATH.
12
+ */
13
+ import { createRequire } from "node:module";
14
+ import { mkdirSync, writeFileSync } from "node:fs";
15
+ import * as path from "node:path";
16
+ import { chromium } from "playwright";
17
+ import { PNG } from "pngjs";
18
+ import { replayActions } from "../replay.js";
19
+ const require = createRequire(import.meta.url);
20
+ const { GIFEncoder, quantize, applyPalette } = require("gifenc");
21
+ /**
22
+ * Captures one PNG screenshot before the replay and one after each action, so
23
+ * the crash (or its absence) is visible in the sequence. Replays `replayActions`
24
+ * one action at a time — reusing the exact detection semantics, not a parallel
25
+ * reimplementation that could drift.
26
+ */
27
+ export async function captureReproFrames(url, actions, opts = {}) {
28
+ const browser = await chromium.launch({ headless: true });
29
+ try {
30
+ const page = await browser.newPage({
31
+ viewport: opts.viewport ?? { width: 720, height: 430 },
32
+ });
33
+ await page.goto(url, { waitUntil: "load", timeout: 30000 }).catch(() => { });
34
+ await page.waitForTimeout(opts.settleMs ?? 1200);
35
+ const frames = [await page.screenshot()];
36
+ for (const action of actions) {
37
+ await replayActions(page, [action]);
38
+ await page.waitForTimeout(opts.stepMs ?? 300);
39
+ frames.push(await page.screenshot());
40
+ }
41
+ return frames;
42
+ }
43
+ finally {
44
+ await browser.close().catch(() => { });
45
+ }
46
+ }
47
+ /** Encodes a sequence of PNG frames into an animated GIF (pure JS, no ffmpeg). */
48
+ export function encodeGif(frames, opts = {}) {
49
+ if (frames.length === 0)
50
+ return Buffer.alloc(0);
51
+ const delay = opts.delay ?? 400;
52
+ const maxColors = opts.maxColors ?? 256;
53
+ const decoded = frames.map((f) => PNG.sync.read(f));
54
+ const { width, height } = decoded[0];
55
+ // One global palette across every frame → compact file, no per-frame flicker.
56
+ const combined = new Uint8Array(decoded.reduce((n, d) => n + d.data.length, 0));
57
+ let off = 0;
58
+ for (const d of decoded) {
59
+ combined.set(d.data, off);
60
+ off += d.data.length;
61
+ }
62
+ const palette = quantize(combined, maxColors);
63
+ const indexes = decoded.map((d) => applyPalette(d.data, palette));
64
+ const gif = GIFEncoder();
65
+ indexes.forEach((index, i) => {
66
+ gif.writeFrame(index, width, height, {
67
+ palette: i === 0 ? palette : undefined,
68
+ delay,
69
+ });
70
+ });
71
+ gif.finish();
72
+ return Buffer.from(gif.bytes());
73
+ }
74
+ /** The static "crash shot": the final frame, when the crash has fully rendered. */
75
+ export function crashFrame(frames) {
76
+ return frames[frames.length - 1];
77
+ }
78
+ /**
79
+ * Produces the recorded-repro GIF for a finding and writes it to
80
+ * `aztrx-media/<fp8>.gif`. That dir is deliberate: `media/` is in the npm
81
+ * `files` allowlist (so demo.gif/logo ship), but patrol GIFs must not publish —
82
+ * they're per-run artifacts. The path is committed to the PR branch so the PR
83
+ * body can inline it via a raw URL.
84
+ *
85
+ * Returns the repo-relative path, or null when there is nothing to record (no
86
+ * repro, or capture/encode failed) — the caller opens the PR regardless.
87
+ */
88
+ export async function recordFindingGif(repoRoot, url, finding) {
89
+ // Skip `navigate` actions — captureReproFrames navigates itself, so replaying a
90
+ // leading navigate would just add a duplicate frame of the same page.
91
+ const actions = (finding.repro?.actions ?? []).filter((a) => a.type !== "navigate");
92
+ if (actions.length === 0)
93
+ return null;
94
+ const frames = await captureReproFrames(url, actions);
95
+ if (frames.length < 2)
96
+ return null; // a single frame isn't an animation
97
+ const gif = encodeGif(frames, { delay: 700 });
98
+ const rel = path.join("aztrx-media", `${finding.fingerprint.slice(0, 8)}.gif`);
99
+ const abs = path.resolve(repoRoot, rel);
100
+ mkdirSync(path.dirname(abs), { recursive: true });
101
+ writeFileSync(abs, gif);
102
+ return rel;
103
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Cross-run memory for `aztrx patrol`. Where `RunLog` is append-only and reset
3
+ * every run, this is a small index of "have I seen this fingerprint before, and
4
+ * what happened to it?" — the thing that stops an autonomous loop from re-fixing
5
+ * the same bug and re-opening the same PR on every cycle.
6
+ *
7
+ * Lives at `.aztrx/patrol.json` (already gitignored via `.aztrx/`). A fingerprint
8
+ * absent from the map is implicitly "new".
9
+ */
10
+ import * as fs from "fs";
11
+ import * as path from "path";
12
+ export class PatrolState {
13
+ file;
14
+ cooldownMs;
15
+ data;
16
+ constructor(repoRoot, url, cooldownMs = 30 * 60 * 1000) {
17
+ const dir = path.join(repoRoot, ".aztrx");
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ this.file = path.join(dir, "patrol.json");
20
+ this.cooldownMs = cooldownMs;
21
+ this.data = this.read();
22
+ this.data.url = url;
23
+ }
24
+ read() {
25
+ try {
26
+ const raw = JSON.parse(fs.readFileSync(this.file, "utf-8"));
27
+ return { url: raw.url ?? "", fingerprints: raw.fingerprints ?? {} };
28
+ }
29
+ catch {
30
+ return { url: "", fingerprints: {} };
31
+ }
32
+ }
33
+ /**
34
+ * A fingerprint is "handled" while its PR is open, or while an `unfixed` mark
35
+ * is still within its cooldown. Once the cooldown lapses, an `unfixed` bug
36
+ * becomes retry-eligible again — it stops being "handled" and `run()` heals it
37
+ * afresh rather than skipping it forever.
38
+ */
39
+ isHandled(fp, now = Date.now()) {
40
+ const e = this.data.fingerprints[fp];
41
+ if (!e)
42
+ return false;
43
+ if (e.status === "pr-opened")
44
+ return true;
45
+ return now - Date.parse(e.lastSeen) < this.cooldownMs;
46
+ }
47
+ /** Every currently-handled fingerprint, so the supervisor can tell `run()` to skip healing them. */
48
+ handled(now = Date.now()) {
49
+ return Object.keys(this.data.fingerprints).filter((fp) => this.isHandled(fp, now));
50
+ }
51
+ markPr(fp, prUrl, branch) {
52
+ this.data.fingerprints[fp] = {
53
+ status: "pr-opened",
54
+ firstSeen: this.firstSeen(fp),
55
+ lastSeen: new Date().toISOString(),
56
+ prUrl,
57
+ branch,
58
+ attempts: this.attempts(fp) + 1,
59
+ };
60
+ }
61
+ markUnfixed(fp) {
62
+ this.data.fingerprints[fp] = {
63
+ status: "unfixed",
64
+ firstSeen: this.firstSeen(fp),
65
+ lastSeen: new Date().toISOString(),
66
+ attempts: this.attempts(fp) + 1,
67
+ };
68
+ }
69
+ firstSeen(fp) {
70
+ return this.data.fingerprints[fp]?.firstSeen ?? new Date().toISOString();
71
+ }
72
+ attempts(fp) {
73
+ return this.data.fingerprints[fp]?.attempts ?? 0;
74
+ }
75
+ save() {
76
+ fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2) + "\n", "utf-8");
77
+ }
78
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Minimal Markdown → ANSI renderer for the `--explain` / `--fix` human-language
3
+ * summary. Handles the small dialect Aztrx emits: headings, bold/`code` inline,
4
+ * bullet and numbered lists, and fenced code blocks with a light JS/TS syntax
5
+ * highlight. Plain prose without markdown passes through unchanged.
6
+ */
7
+ import pc from "picocolors";
8
+ const KEYWORDS = new Set([
9
+ "const", "let", "var", "function", "return", "if", "else", "for", "while", "do",
10
+ "new", "class", "extends", "super", "import", "export", "from", "async", "await",
11
+ "try", "catch", "finally", "throw", "switch", "case", "break", "continue",
12
+ "default", "typeof", "instanceof", "in", "of", "delete", "void", "this", "null",
13
+ "undefined", "true", "false", "static", "get", "set", "interface", "type",
14
+ "enum", "readonly", "as", "satisfies", "yield",
15
+ ]);
16
+ /** Single-pass tokenizer. Classify by first char: `/` comment, quote/backtick
17
+ * string, digit number, letter keyword, else plain identifier. */
18
+ const TOKEN = /(\/\/[^\n]*|\/\*[\s\S]*?\*\/|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`|\b\d+(?:\.\d+)?\b|\b[A-Za-z_$][\w$]*\b)/g;
19
+ function highlight(code) {
20
+ return code.replace(TOKEN, (raw) => {
21
+ const c = raw[0];
22
+ if (c === "/")
23
+ return pc.dim(pc.gray(raw));
24
+ if (c === "'" || c === '"' || c === "`")
25
+ return pc.green(raw);
26
+ if (c >= "0" && c <= "9")
27
+ return pc.yellow(raw);
28
+ if (KEYWORDS.has(raw))
29
+ return pc.magenta(raw);
30
+ return raw;
31
+ });
32
+ }
33
+ /** Inline formatting: `code`, **bold**. */
34
+ function renderInline(text) {
35
+ return text
36
+ .replace(/`([^`\n]+)`/g, (_, s) => pc.cyan(s))
37
+ .replace(/\*\*([^*]+)\*\*/g, (_, s) => pc.bold(s))
38
+ .replace(/__([^_]+)__/g, (_, s) => pc.bold(s));
39
+ }
40
+ export function renderMarkdown(md) {
41
+ const lines = md.split("\n");
42
+ const out = [];
43
+ let inCode = false;
44
+ let code = [];
45
+ const flushCode = () => {
46
+ if (!code.length)
47
+ return;
48
+ for (const l of code)
49
+ out.push(" " + pc.dim("│") + " " + highlight(l));
50
+ out.push("");
51
+ code = [];
52
+ };
53
+ for (const raw of lines) {
54
+ if (raw.trim().startsWith("```")) {
55
+ if (inCode) {
56
+ flushCode();
57
+ inCode = false;
58
+ }
59
+ else {
60
+ inCode = true;
61
+ }
62
+ continue;
63
+ }
64
+ if (inCode) {
65
+ code.push(raw);
66
+ continue;
67
+ }
68
+ const heading = raw.match(/^(#{1,4})\s+(.*)$/);
69
+ if (heading) {
70
+ out.push(pc.bold(pc.underline(renderInline(heading[2]))));
71
+ out.push("");
72
+ continue;
73
+ }
74
+ const bullet = raw.match(/^\s*[-*+]\s+(.*)$/);
75
+ if (bullet) {
76
+ out.push(" " + pc.cyan("•") + " " + renderInline(bullet[1]));
77
+ continue;
78
+ }
79
+ const numbered = raw.match(/^\s*(\d+)[.)]\s+(.*)$/);
80
+ if (numbered) {
81
+ out.push(" " + pc.cyan(numbered[1] + ".") + " " + renderInline(numbered[2]));
82
+ continue;
83
+ }
84
+ out.push(renderInline(raw));
85
+ }
86
+ flushCode();
87
+ while (out.length && out[0] === "")
88
+ out.shift();
89
+ while (out.length && out[out.length - 1] === "")
90
+ out.pop();
91
+ return out.join("\n");
92
+ }
@@ -2,6 +2,7 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { BASE_CSS, SEVERITY_COLOR, seismograph } from "./ui.js";
4
4
  import { sanitizeSecrets } from "./heal/redact.js";
5
+ import { diagnoseFinding } from "./diagnose.js";
5
6
  function escapeHtml(s) {
6
7
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7
8
  }
@@ -16,7 +17,7 @@ const SEV_ORDER = ["crash", "error", "warning", "noise"];
16
17
  * CDN), rendered in the shared "crash seismograph" identity: one red spike per
17
18
  * crash, severity chips, and colored repro verdicts.
18
19
  */
19
- export function renderReport(targetUrl, findings) {
20
+ export function renderReport(targetUrl, findings, lang) {
20
21
  const sorted = [...findings].sort((a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity));
21
22
  const counts = { crash: 0, error: 0, warning: 0 };
22
23
  for (const f of sorted)
@@ -28,6 +29,7 @@ export function renderReport(targetUrl, findings) {
28
29
  ? `${clean(f.mappedLocation.filePath)}:${f.mappedLocation.line}:${f.mappedLocation.column}`
29
30
  : "";
30
31
  const snippet = f.mappedLocation ? clean(f.mappedLocation.codeContext) : "";
32
+ const dx = diagnoseFinding(f, lang);
31
33
  const serverErr = f.serverError
32
34
  ? `<div class="server">server: ${clean(f.serverError.message)}</div>` +
33
35
  (f.serverError.body
@@ -53,6 +55,7 @@ export function renderReport(targetUrl, findings) {
53
55
  <h2>${clean(f.rawMessage.split("\n")[0])}</h2>
54
56
  </header>
55
57
  ${loc ? `<div class="loc">${loc}</div>` : ""}
58
+ ${dx ? `<div class="dx">↳ ${clean(dx)}</div>` : ""}
56
59
  ${snippet ? `<pre class="snippet">${snippet}</pre>` : ""}
57
60
  ${serverErr}
58
61
  ${f.occurrences > 1 ? `<div class="occ">seen ×${f.occurrences}</div>` : ""}
@@ -88,10 +91,10 @@ export function renderReport(targetUrl, findings) {
88
91
  </body>
89
92
  </html>`;
90
93
  }
91
- export function writeReport(repoRoot, targetUrl, findings) {
94
+ export function writeReport(repoRoot, targetUrl, findings, lang) {
92
95
  const dir = path.join(repoRoot, ".aztrx");
93
96
  fs.mkdirSync(dir, { recursive: true });
94
97
  const file = path.join(dir, "report.html");
95
- fs.writeFileSync(file, renderReport(targetUrl, findings), "utf-8");
98
+ fs.writeFileSync(file, renderReport(targetUrl, findings, lang), "utf-8");
96
99
  return file;
97
100
  }
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { TraceMap, originalPositionFor, } from "@jridgewell/trace-mapping";
3
+ import { FlattenMap, originalPositionFor, } from "@jridgewell/trace-mapping";
4
4
  /** Framework-internal frames to skip when hunting the throw site. */
5
5
  const FRAMEWORK_FRAME = /node_modules|webpack-runtime|\.next[\\/]|next[\\/]dist[\\/]/;
6
6
  /**
@@ -80,6 +80,15 @@ function isFile(p) {
80
80
  return false;
81
81
  }
82
82
  }
83
+ /** True only for a real directory. */
84
+ function isDirectory(p) {
85
+ try {
86
+ return fs.existsSync(p) && fs.statSync(p).isDirectory();
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
83
92
  /** Secret-bearing filenames that must never be read, even inside the repo — a
84
93
  * hostile sourcemap could otherwise point `source` at `.env`, an npmrc, or a
85
94
  * private key and exfiltrate it into the report / PR comment. */
@@ -135,6 +144,8 @@ function sourceCandidates(source, repoRoot) {
135
144
  .replace(/^webpack:\/\/[^/]+\//, "") // webpack://namespace/src/...
136
145
  .replace(/^webpack:\/\//, "")
137
146
  .replace(/^\/@fs\//, "")
147
+ .replace(/^file:\/\/\/([A-Za-z]:)/, "$1") // file:///C:/x → C:/x
148
+ .replace(/^file:\/\//, "")
138
149
  .replace(/^\//, "")
139
150
  .split("?")[0];
140
151
  const prefixes = ["", "apps/web/", "src/", "app/"];
@@ -150,13 +161,25 @@ function locateFile(candidates) {
150
161
  return null;
151
162
  }
152
163
  export async function resolveFrame(frame, repoRoot) {
164
+ const viaServerAction = await tryServerActionSourceMap(frame, repoRoot);
165
+ if (viaServerAction)
166
+ return viaServerAction;
153
167
  const viaMap = await trySourceMap(frame, repoRoot);
154
168
  if (viaMap)
155
169
  return viaMap;
156
170
  // Fallback: dev servers (Vite, Next) serve real source files at their URL
157
171
  // path, so the bundle URL is already the source path — no sourcemap needed.
158
172
  const relative = normalizeFrameUrl(frame.url);
159
- const directPath = resolveWithin(repoRoot, relative);
173
+ let directPath = resolveWithin(repoRoot, relative);
174
+ // A frame URL pointing at a directory — e.g. an inline `<script>` whose V8
175
+ // frame carries the page URL (`http://localhost:3000/`, normalized to "") —
176
+ // resolves to the repo root. Map it to `index.html`, mirroring the static
177
+ // serve fallback, so the crash gets a real filename + snippet + own-code flag.
178
+ if (directPath && isDirectory(directPath)) {
179
+ const withIndex = resolveWithin(repoRoot, relative, "index.html");
180
+ if (withIndex)
181
+ directPath = withIndex;
182
+ }
160
183
  if (!directPath) {
161
184
  return {
162
185
  message: frame.message,
@@ -227,6 +250,35 @@ function isLoopback(host) {
227
250
  const h = host.replace(/^\[|\]$/g, "").toLowerCase();
228
251
  return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0";
229
252
  }
253
+ /** Shared tail of sourcemap resolution: run `originalPositionFor` through a
254
+ * (possibly sectioned) map, resolve the `source` it names to a repo file, and
255
+ * build the `MappedError`. Returns null when the position maps to no known
256
+ * source. */
257
+ function resolveFromMap(rawMap, line, column, message, repoRoot) {
258
+ const map = new FlattenMap(rawMap);
259
+ const pos = originalPositionFor(map, { line, column });
260
+ if (!pos.source || pos.line == null)
261
+ return null;
262
+ const absolute = locateFile(sourceCandidates(pos.source, repoRoot));
263
+ if (!absolute) {
264
+ return {
265
+ message,
266
+ sourceFile: pos.source,
267
+ line: pos.line,
268
+ column: pos.column ?? 0,
269
+ codeSnippet: `<file not accessible locally: ${pos.source}>`,
270
+ resolvedFrom: "unresolved",
271
+ };
272
+ }
273
+ return {
274
+ message,
275
+ sourceFile: path.relative(repoRoot, absolute),
276
+ line: pos.line,
277
+ column: pos.column ?? 0,
278
+ codeSnippet: extractSnippet(absolute, pos.line),
279
+ resolvedFrom: "sourcemap",
280
+ };
281
+ }
230
282
  async function trySourceMap(frame, repoRoot) {
231
283
  const mapUrl = stripQuery(frame.url) + ".map";
232
284
  // SSRF guard: the sourcemap URL is derived from an untrusted stack frame, so
@@ -252,29 +304,38 @@ async function trySourceMap(frame, repoRoot) {
252
304
  return null;
253
305
  }
254
306
  try {
255
- const map = new TraceMap(rawMap);
256
- const pos = originalPositionFor(map, { line: frame.line, column: frame.column });
257
- if (!pos.source || pos.line == null)
258
- return null;
259
- const absolute = locateFile(sourceCandidates(pos.source, repoRoot));
260
- if (!absolute) {
261
- return {
262
- message: frame.message,
263
- sourceFile: pos.source,
264
- line: pos.line,
265
- column: pos.column ?? 0,
266
- codeSnippet: `<file not accessible locally: ${pos.source}>`,
267
- resolvedFrom: "unresolved",
268
- };
269
- }
270
- return {
271
- message: frame.message,
272
- sourceFile: path.relative(repoRoot, absolute),
273
- line: pos.line,
274
- column: pos.column ?? 0,
275
- codeSnippet: extractSnippet(absolute, pos.line),
276
- resolvedFrom: "sourcemap",
277
- };
307
+ return resolveFromMap(rawMap, frame.line, frame.column, frame.message, repoRoot);
308
+ }
309
+ catch {
310
+ return null;
311
+ }
312
+ }
313
+ /**
314
+ * Map a Next.js Server Action throw site to its original source. The browser
315
+ * sees the throw as `about://React/Server/<url-encoded chunk path>?<n>:<line>:<col>`,
316
+ * where the encoded path is the compiled Turbopack chunk on disk. That chunk's
317
+ * `.map` is a *sectioned* (indexed) sourcemap whose sections point at the real
318
+ * source (e.g. `app/actions.ts`); `FlattenMap` walks the sections for us.
319
+ */
320
+ async function tryServerActionSourceMap(frame, repoRoot) {
321
+ const marker = "about://React/Server/";
322
+ if (!frame.url.startsWith(marker))
323
+ return null;
324
+ let chunkPath;
325
+ try {
326
+ chunkPath = stripQuery(decodeURIComponent(frame.url.slice(marker.length)));
327
+ }
328
+ catch {
329
+ return null;
330
+ }
331
+ if (!isFile(chunkPath))
332
+ return null;
333
+ const mapPath = chunkPath + ".map";
334
+ if (!isFile(mapPath) || isSensitive(mapPath))
335
+ return null;
336
+ try {
337
+ const rawMap = JSON.parse(fs.readFileSync(mapPath, "utf-8"));
338
+ return resolveFromMap(rawMap, frame.line, frame.column, frame.message, repoRoot);
278
339
  }
279
340
  catch {
280
341
  return null;
@@ -118,11 +118,11 @@ function buildLlmPrompt(findings, lang, hasHealed) {
118
118
  `A QA tool scanned a web app and found the following findings:`,
119
119
  rows,
120
120
  "",
121
- `Write a short, friendly, plain-language summary for a developer (${lang}): what was found, what each problem means in simple words, and — per the note below — whether fixes are ready. Do not invent details that are not listed. Keep it to a few short paragraphs or a tight bullet list.`,
121
+ `Write a short, friendly summary for a developer (${lang}) in Markdown — use headings, bold, bullet lists, and short \`\`\`code\`\`\` snippets where helpful. Explain what was found, what each problem means in simple words, and — per the note below — whether fixes are ready. Do not invent details that are not listed. Keep it concise.`,
122
122
  fixLine,
123
123
  ].join("\n");
124
124
  }
125
- const SYSTEM = "You are the plain-spoken explainer for a QA tool called Aztrx AI. You turn raw runtime-finding data into a concise, human-language summary for a developer. Never invent details absent from the data. Respond in the requested language only.";
125
+ const SYSTEM = "You are the plain-spoken explainer for a QA tool called Aztrx AI. You turn raw runtime-finding data into a concise Markdown summary for a developer (headings, bold, bullet lists, short code snippets). Never invent details absent from the data. Respond in the requested language only.";
126
126
  async function summarizeFindingsLlm(findings, lang) {
127
127
  const hasHealed = findings.some((f) => f.heal?.status === "healed");
128
128
  const text = (await complete({
@@ -15,7 +15,7 @@ import { launchChromium } from "./browser.js";
15
15
  import { EventBus } from "./eventBus.js";
16
16
  import { attachInterceptor } from "./interceptor.js";
17
17
  import { establishLogin } from "./auth.js";
18
- import { SignalClassifier } from "./classifier.js";
18
+ import { SignalClassifier, collapseSignals } from "./classifier.js";
19
19
  import { ActionRecorder } from "./recorder.js";
20
20
  import { walkDom } from "./domWalker.js";
21
21
  import { fuzz } from "./fuzzer.js";
@@ -75,6 +75,20 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
75
75
  const context = await browser.newContext(opts.storageState ? { storageState: opts.storageState } : {});
76
76
  const page = await context.newPage();
77
77
  attachInterceptor(page, workerBus);
78
+ // Collect every same-origin URL the page issues — including `fetch()` fired
79
+ // from click handlers — so the folded HTTP fuzzer can probe endpoints a fresh
80
+ // page never sees (performance resources only capture on-load fetches).
81
+ const observedUrls = new Set();
82
+ const targetOrigin = new URL(opts.url).origin;
83
+ page.on("request", (req) => {
84
+ try {
85
+ if (new URL(req.url()).origin === targetOrigin)
86
+ observedUrls.add(req.url());
87
+ }
88
+ catch {
89
+ // malformed URL — skip
90
+ }
91
+ });
78
92
  if (opts.guardOn) {
79
93
  await attachNetworkGuard(page, {
80
94
  allowHosts: opts.allowHosts,
@@ -94,10 +108,9 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
94
108
  // Settle for hydration and mount-time effects before acting.
95
109
  await page.waitForTimeout(2000);
96
110
  }
97
- // Auto-login (best-effort). The server-side HTTP fuzzer uses Node-side fetch,
98
- // so it doesn't benefit from a browser session — skip it there.
111
+ // Auto-login (best-effort).
99
112
  let replayStorageState;
100
- if (loaded && strategy.kind !== "http-fuzz" && opts.login && opts.loginEmail && opts.loginPassword) {
113
+ if (loaded && opts.login && opts.loginEmail && opts.loginPassword) {
101
114
  const res = await establishLogin(page, {
102
115
  email: opts.loginEmail,
103
116
  password: opts.loginPassword,
@@ -134,21 +147,27 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
134
147
  let sawLoginForm = false;
135
148
  if (loaded) {
136
149
  if (strategy.kind === "walk") {
137
- const wr = await walkDom(page, workerBus, { maxActions: opts.maxActions, dryRun: opts.dryRun });
150
+ const wr = await walkDom(page, workerBus, { maxActions: opts.maxActions, dryRun: opts.dryRun, allowDestructive: opts.allowDestructive });
138
151
  actions = wr.actions;
139
152
  sawLoginForm = wr.sawLoginForm;
140
153
  }
141
- else if (strategy.kind === "fuzz") {
142
- const fr = await fuzz(page, workerBus, { seed: strategy.seed, maxActions: opts.maxActions, dryRun: opts.dryRun });
154
+ else {
155
+ const fr = await fuzz(page, workerBus, { seed: strategy.seed, maxActions: opts.maxActions, dryRun: opts.dryRun, allowDestructive: opts.allowDestructive });
143
156
  actions = fr.actions;
144
157
  newCoverage = fr.newCoverage;
145
158
  }
146
- else {
147
- actions = await httpFuzz(page, opts.url, workerBus, {
159
+ // Folded HTTP fuzzer: post-pass on this same page, seeded with every URL the
160
+ // walk/fuzz actually issued including JS-fetch-only endpoints a standalone
161
+ // worker (snapshot before clicks) would never discover.
162
+ if (opts.httpFuzz) {
163
+ actions += await httpFuzz(page, opts.url, workerBus, {
148
164
  maxRequests: opts.maxActions,
149
165
  dryRun: opts.dryRun,
150
166
  allowHosts: opts.allowHosts,
151
167
  mutations: opts.httpFuzzMutations,
168
+ allowDestructive: opts.allowDestructive,
169
+ seedUrls: [...observedUrls],
170
+ navigate: false,
152
171
  });
153
172
  }
154
173
  }
@@ -175,12 +194,11 @@ export function mergeFindings(arrays) {
175
194
  }
176
195
  return [...byFingerprint.values()];
177
196
  }
178
- /** Build the worker roster for a run. `workers = 1` with no http-fuzz is the
179
- * legacy single pass; `--http-fuzz` and/or `workers > 1` fan out. */
197
+ /** Build the worker roster for a run. `workers = 1` is the legacy single pass;
198
+ * `workers > 1` fans out. `--http-fuzz` is not a worker here it folds into
199
+ * whichever pass runs (see `detectWorker`). */
180
200
  function buildStrategies(opts) {
181
201
  const strategies = [];
182
- if (opts.httpFuzz)
183
- strategies.push({ kind: "http-fuzz" });
184
202
  const w = Math.max(1, opts.workers);
185
203
  if (w === 1) {
186
204
  strategies.push(opts.fuzz ? { kind: "fuzz", seed: opts.seed } : { kind: "walk" });
@@ -202,8 +220,6 @@ function strategyLabel(s) {
202
220
  switch (s.kind) {
203
221
  case "walk":
204
222
  return "walk";
205
- case "http-fuzz":
206
- return "http-fuzz";
207
223
  case "fuzz":
208
224
  return `fuzz seed ${s.seed}`;
209
225
  }
@@ -228,6 +244,8 @@ export async function swarmDetect(opts) {
228
244
  crashTest: i === 0 ? opts.crashTest : false,
229
245
  saveAuthState: i === 0,
230
246
  httpFuzzMutations: opts.httpFuzzMutations,
247
+ httpFuzz: opts.httpFuzz,
248
+ allowDestructive: opts.allowDestructive,
231
249
  baseline: opts.baseline,
232
250
  log: (m) => opts.log(strategies.length > 1 ? `[w${i}] ${m}` : m),
233
251
  }, strategy, opts.forwardBus)));
@@ -242,7 +260,9 @@ export async function swarmDetect(opts) {
242
260
  for (const r of results)
243
261
  if (r.replayStorageState)
244
262
  replayStorageState = r.replayStorageState;
245
- const findings = mergeFindings(results.map((r) => r.findings));
263
+ // Merge identical fingerprints across workers, then collapse distinct
264
+ // capture paths of the same fault (5xx + console + timeout + throw) into one.
265
+ const findings = collapseSignals(mergeFindings(results.map((r) => r.findings)));
246
266
  const totalActions = results.reduce((sum, r) => sum + r.actions, 0);
247
267
  const totalCoverage = results.reduce((sum, r) => sum + r.newCoverage, 0);
248
268
  return {
package/dist/core/ui.js CHANGED
@@ -52,6 +52,7 @@ h1 .brand-sub{color:var(--dim)}
52
52
  .sev{font:600 11px/1 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--sev);border:1px solid var(--sev);border-radius:999px;padding:3px 9px;flex:none}
53
53
  h2{font-size:15px;margin:0;font-weight:600;word-break:break-word}
54
54
  .loc{color:var(--dim);font-size:12.5px;margin-top:8px}
55
+ .dx{color:var(--azure);font-size:12.5px;margin-top:6px}
55
56
  .snippet{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;overflow-x:auto;font:12px/1.6 ui-monospace,monospace;color:var(--muted);margin:12px 0 0;white-space:pre}
56
57
  .server{color:var(--amber);font-size:12.5px;margin-top:8px}
57
58
  .server-body{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;overflow-x:auto;font:12px/1.6 ui-monospace,monospace;color:var(--muted);margin:8px 0 0;white-space:pre;max-height:240px;overflow-y:auto}