aztrx-cli 0.1.1 → 0.2.1

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.
@@ -1,15 +1,10 @@
1
1
  import * as path from "path";
2
- import { chromium } from "playwright";
3
2
  import pc from "picocolors";
3
+ import { VERSION } from "./version.js";
4
4
  import { EventBus } from "./eventBus.js";
5
- import { attachInterceptor } from "./interceptor.js";
6
- import { SignalClassifier, loadBaseline } from "./classifier.js";
7
- import { ActionRecorder } from "./recorder.js";
8
- import { walkDom } from "./domWalker.js";
9
- import { fuzz } from "./fuzzer.js";
10
- import { httpFuzz } from "./httpFuzzer.js";
5
+ import { loadBaseline } from "./classifier.js";
11
6
  import { attachNetworkGuard, allowHostsFrom } from "./networkGuard.js";
12
- import { resolveFrame, resolveServerFrame } from "./resolver.js";
7
+ import { swarmDetect } from "./swarm.js";
13
8
  import { ReplayEngine } from "./replay.js";
14
9
  import { minimize } from "./minimizer.js";
15
10
  import { writeSpec } from "./specCompiler.js";
@@ -40,6 +35,8 @@ function printFinding(f, write) {
40
35
  }
41
36
  /** Human-readable run mode, surfaced in the cloud dashboard. */
42
37
  function runMode(o) {
38
+ if ((o.workers ?? 1) > 1)
39
+ return `swarm (${o.workers} workers)`;
43
40
  if (o.fuzz)
44
41
  return `fuzz (seed ${o.seed ?? 42})`;
45
42
  if (o.httpFuzz)
@@ -61,7 +58,7 @@ function runMode(o) {
61
58
  export async function run(options) {
62
59
  const { url, repoRoot } = options;
63
60
  const maxActions = options.maxActions ?? 100;
64
- const guardOn = Boolean(options.fuzz || options.repro);
61
+ const guardOn = Boolean(options.fuzz || options.repro || (options.workers ?? 1) > 1);
65
62
  const allowHosts = allowHostsFrom(url, options.allowHosts ?? []);
66
63
  const ui = options.ui === true;
67
64
  const bus = options.bus ?? new EventBus();
@@ -70,7 +67,7 @@ export async function run(options) {
70
67
  console.log(parts.join(" "));
71
68
  };
72
69
  const emitPhase = (phase, detail) => bus.emit("phase", { phase, detail, ts: Date.now() });
73
- say(pc.cyan("\nAztrx AI v0.1.1 — Runtime Detector"));
70
+ say(pc.cyan(`\nAztrx AI v${VERSION} — Runtime Detector`));
74
71
  say(pc.dim(`Target: ${url}`));
75
72
  say(pc.dim(`Repo: ${repoRoot}`));
76
73
  if (options.fuzz)
@@ -83,109 +80,64 @@ export async function run(options) {
83
80
  say(pc.dim(`Auth: ${options.storageState}`));
84
81
  say("");
85
82
  emitPhase("launch", url);
86
- const classifier = new SignalClassifier(await loadBaseline(repoRoot));
87
- const recorder = new ActionRecorder();
88
83
  const runLog = new RunLog(repoRoot);
89
84
  runLog.reset();
90
85
  runLog.append({ type: "run_start", url, ts: Date.now() });
91
- bus.on("action", (a) => recorder.record(a));
92
- bus.on("telemetry", async (payload) => {
93
- const finding = classifier.classify(payload);
94
- if (!finding)
95
- return;
96
- finding.actionHistory = recorder.snapshot();
97
- if (finding.severity === "noise") {
98
- bus.emit("noise", { ts: Date.now() });
99
- return;
100
- }
101
- runLog.append({ type: "finding", finding });
102
- if (payload.serverError) {
103
- finding.serverError = { message: payload.serverError.message, body: payload.serverError.body };
104
- }
105
- if (payload.url && payload.line) {
106
- const resolved = await resolveFrame({ url: payload.url, line: payload.line, column: payload.column ?? 0, message: payload.rawMessage }, repoRoot);
107
- finding.mappedLocation = {
108
- filePath: resolved.sourceFile,
109
- line: resolved.line,
110
- column: resolved.column,
111
- codeContext: resolved.codeSnippet,
112
- isOwnCode: resolved.resolvedFrom !== "unresolved",
113
- };
114
- }
115
- else if (payload.serverError?.frame) {
116
- const resolved = resolveServerFrame(payload.serverError.frame, repoRoot);
117
- finding.mappedLocation = {
118
- filePath: resolved.sourceFile,
119
- line: resolved.line,
120
- column: resolved.column,
121
- codeContext: resolved.codeSnippet,
122
- isOwnCode: resolved.resolvedFrom !== "unresolved",
123
- };
124
- }
125
- bus.emit("finding", finding);
126
- printFinding(finding, say);
127
- });
128
- const browser = await chromium.launch({ headless: true });
129
- const context = await browser.newContext(options.storageState ? { storageState: options.storageState } : {});
130
- const page = await context.newPage();
131
- attachInterceptor(page, bus);
132
- if (guardOn) {
133
- await attachNetworkGuard(page, {
134
- allowHosts,
135
- onBlock: (u) => say(pc.dim(` [guard] blocked ${u}`)),
136
- });
86
+ const baseline = await loadBaseline(repoRoot);
87
+ const workers = options.workers ?? 1;
88
+ // F-swarm parallel detection. One worker is the legacy pass; `--http-fuzz`
89
+ // or `workers > 1` fan out into a swarm. Findings come back merged by
90
+ // fingerprint, with per-worker action history already attached.
91
+ if (workers > 1 || options.httpFuzz) {
92
+ emitPhase("swarm", `${workers} worker(s)`);
137
93
  }
138
- page.on("framenavigated", (frame) => {
139
- if (frame === page.mainFrame())
140
- bus.emit("route", { url: frame.url(), ts: Date.now() });
141
- });
142
- let loaded = true;
143
- await page.goto(url, { waitUntil: "load", timeout: 30000 }).catch((e) => {
144
- loaded = false;
145
- say(pc.red("Failed to load target: ") + pc.dim(e.message));
146
- });
147
- if (loaded) {
148
- // Settle: wait for hydration and mount-time effects (async fetches,
149
- // unhandled rejections, React warnings) to fire before we act. A page whose
150
- // only bugs are mount-time would otherwise be closed before they happen —
151
- // and a page with no interactive elements fuzzes zero actions, so it relies
152
- // on this window.
153
- await page.waitForTimeout(2000);
94
+ else if (options.fuzz) {
95
+ emitPhase("fuzz");
154
96
  }
155
- if (loaded && options.crashTest) {
156
- await page.evaluate(() => {
157
- setTimeout(() => {
158
- throw new Error("Aztrx test: Cannot read properties of undefined (reading 'token')");
159
- }, 300);
160
- });
161
- await page.waitForTimeout(800);
97
+ else {
98
+ emitPhase("walk");
162
99
  }
163
- if (loaded) {
164
- emitPhase(options.fuzz ? "fuzz" : "walk");
165
- const acted = options.fuzz
166
- ? await fuzz(page, bus, { seed: options.seed, maxActions, dryRun: options.dryRun })
167
- : await walkDom(page, bus, { maxActions, dryRun: options.dryRun });
168
- say(pc.dim(`\n${options.fuzz ? "Fuzzed" : "Walked"} ${acted} action(s).\n`));
100
+ const { findings, replayStorageState: swarmAuthState, totalActions, workerCount, } = await swarmDetect({
101
+ url,
102
+ repoRoot,
103
+ maxActions,
104
+ dryRun: options.dryRun,
105
+ fuzz: options.fuzz,
106
+ httpFuzz: options.httpFuzz,
107
+ httpFuzzMutations: options.httpFuzzMutations,
108
+ seed: options.seed ?? 42,
109
+ workers,
110
+ allowHosts,
111
+ storageState: options.storageState,
112
+ login: options.login,
113
+ loginEmail: options.loginEmail,
114
+ loginPassword: options.loginPassword,
115
+ loginUrl: options.loginUrl,
116
+ crashTest: options.crashTest,
117
+ baseline,
118
+ guardOn,
119
+ log: say,
120
+ });
121
+ // Replays reuse the swarm-captured auth state, or the explicit --storage-state.
122
+ const replayStorageState = swarmAuthState ?? options.storageState;
123
+ // Surface the merged findings to the live panel, run log, and console.
124
+ for (const f of findings) {
125
+ bus.emit("finding", f);
126
+ runLog.append({ type: "finding", finding: f });
127
+ printFinding(f, say);
169
128
  }
170
- if (loaded && options.httpFuzz) {
171
- emitPhase("http-fuzz");
172
- const sent = await httpFuzz(page, url, bus, {
173
- maxRequests: maxActions,
174
- dryRun: options.dryRun,
175
- allowHosts,
176
- mutations: options.httpFuzzMutations,
177
- });
178
- say(pc.dim(`\nHTTP-fuzzed ${sent} request(s).\n`));
129
+ if (workerCount > 1) {
130
+ say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s).\n`));
131
+ }
132
+ else {
133
+ say(pc.dim(`\n${options.fuzz ? "Fuzzed" : "Walked"} ${totalActions} action(s).\n`));
179
134
  }
180
- await page.waitForTimeout(500);
181
- await browser.close();
182
- const findings = classifier.findings();
183
135
  // F7 → F8 → F9: minimize each finding, compile an executable spec, validate
184
136
  // the flake rate. Only crash/error findings with a recorded action history.
185
137
  if (options.repro) {
186
138
  const engine = new ReplayEngine({
187
139
  attachGuard: async (p) => attachNetworkGuard(p, { allowHosts }),
188
- storageState: options.storageState,
140
+ storageState: replayStorageState,
189
141
  });
190
142
  try {
191
143
  const targets = findings.filter((f) => (f.severity === "crash" || f.severity === "error") && f.actionHistory.length > 0);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A single yes/no confirmation for `--fix`. Kept tiny and side-effect free:
3
+ *
4
+ * - `--yes` short-circuits to `true` (scripts/CI).
5
+ * - a non-TTY stdout without `--yes` short-circuits to `false` — an unattended
6
+ * run never mutates the working tree, it just leaves the `.patch` files.
7
+ * - otherwise prompts on stdin, defaulting to "no".
8
+ */
9
+ import * as readline from "readline";
10
+ export function promptYesNo(question, opts = {}) {
11
+ if (opts.yes === true)
12
+ return Promise.resolve(true);
13
+ if (process.stdout.isTTY !== true)
14
+ return Promise.resolve(false);
15
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
16
+ return new Promise((resolve) => {
17
+ rl.question(question + " ", (answer) => {
18
+ rl.close();
19
+ resolve(/^y(es)?$/i.test(answer.trim()));
20
+ });
21
+ });
22
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * F13 — the "X-ray report": a plain-language summary of a run's findings,
3
+ * instead of a wall of stack traces. Two engines behind one entry point:
4
+ *
5
+ * - LLM (Anthropic Messages API) when `ANTHROPIC_API_KEY` is set — friendly
6
+ * prose in the requested language, mirroring the transport in `heal/llm.ts`.
7
+ * - deterministic template when there is no key (or the call fails) — a
8
+ * readable, structured list that needs no network.
9
+ *
10
+ * An empty run short-circuits to the template (no reason to pay for "all clear").
11
+ * The offer-to-apply line is emitted only when verified fixes are actually ready,
12
+ * so `--explain` (no healing) never promises a fix it doesn't have.
13
+ */
14
+ const MODEL = process.env.AZTRX_MODEL || "claude-sonnet-5";
15
+ const API_URL = "https://api.anthropic.com/v1/messages";
16
+ function normalizeLang(lang) {
17
+ return lang === "ru" ? "ru" : "en";
18
+ }
19
+ /** Russian plural form picker: [one, few, many]. */
20
+ function ruPlural(n, forms) {
21
+ const mod10 = n % 10;
22
+ const mod100 = n % 100;
23
+ if (mod10 === 1 && mod100 !== 11)
24
+ return forms[0];
25
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
26
+ return forms[1];
27
+ return forms[2];
28
+ }
29
+ const PHRASES = {
30
+ en: {
31
+ none: "No problems found — the app survived this pass.",
32
+ found: (n) => `I scanned your code and found ${n} problem${n === 1 ? "" : "s"}:`,
33
+ offer: (n) => `I've prepared ${n} verified fix${n === 1 ? "" : "es"}.`,
34
+ severity: { crash: "crash", error: "error", warning: "warning", noise: "noise" },
35
+ type: {
36
+ uncaught_exception: "uncaught exception",
37
+ unhandled_rejection: "unhandled promise rejection",
38
+ console_error: "console error",
39
+ network_5xx: "server error (5xx)",
40
+ network_timeout: "network timeout",
41
+ },
42
+ repro: "repro",
43
+ fix: "fix",
44
+ healed: "healed",
45
+ },
46
+ ru: {
47
+ none: "Проблем не найдено — приложение пережило этот проход.",
48
+ found: (n) => `Привет! Я просканировал твой код и нашёл ${n} ${ruPlural(n, ["проблему", "проблемы", "проблем"])}:`,
49
+ offer: (n) => `Я подготовил ${n} ${ruPlural(n, ["исправление", "исправления", "исправлений"])}, каждое проверено тестами.`,
50
+ severity: { crash: "краш", error: "ошибка", warning: "предупреждение", noise: "шум" },
51
+ type: {
52
+ uncaught_exception: "необработанное исключение",
53
+ unhandled_rejection: "необработанный reject промиса",
54
+ console_error: "ошибка в консоли",
55
+ network_5xx: "ошибка сервера (5xx)",
56
+ network_timeout: "таймаут сети",
57
+ },
58
+ repro: "воспроизведение",
59
+ fix: "фикс",
60
+ healed: "вылечено",
61
+ },
62
+ };
63
+ function location(f) {
64
+ const m = f.mappedLocation;
65
+ if (!m || !m.filePath)
66
+ return "";
67
+ return `${m.filePath}:${m.line}:${m.column}`;
68
+ }
69
+ function shortMessage(f) {
70
+ const line = f.rawMessage.split("\n")[0].trim();
71
+ return line.length > 120 ? line.slice(0, 117) + "…" : line;
72
+ }
73
+ /** One bullet for the template engine: location, kind, and the one-line message. */
74
+ function describeFinding(f, p) {
75
+ const loc = location(f);
76
+ const head = loc ? `${loc} — ` : "";
77
+ const kind = `${p.type[f.type]} (${p.severity[f.severity]})`;
78
+ const lines = [`${head}${kind}: "${shortMessage(f)}"`];
79
+ const tail = [];
80
+ if (f.repro && f.repro.verdict !== "unreliable") {
81
+ tail.push(`${p.repro}: ${f.repro.verdict} (${f.repro.reproductions}/${f.repro.runs})`);
82
+ }
83
+ if (f.heal) {
84
+ tail.push(`${p.fix}: ${f.heal.status === "healed" ? p.healed : f.heal.status}`);
85
+ }
86
+ if (tail.length)
87
+ lines.push(` ${tail.join(" · ")}`);
88
+ return lines.join("\n");
89
+ }
90
+ /** Deterministic, offline summary. Used as the no-key fallback and for empty runs. */
91
+ export function summarizeFindingsTemplate(findings, lang = "en") {
92
+ const p = PHRASES[lang];
93
+ if (findings.length === 0)
94
+ return p.none;
95
+ const healed = findings.filter((f) => f.heal?.status === "healed");
96
+ const bullets = findings
97
+ .map((f, i) => ` ${i + 1}. ${describeFinding(f, p)}`)
98
+ .join("\n");
99
+ const lines = [p.found(findings.length), "", bullets];
100
+ if (healed.length)
101
+ lines.push("", p.offer(healed.length));
102
+ return lines.join("\n");
103
+ }
104
+ function buildLlmPrompt(findings, lang, hasHealed) {
105
+ const rows = findings
106
+ .map((f) => {
107
+ const loc = location(f) || "(no source location)";
108
+ const repro = f.repro ? `${f.repro.verdict} ${f.repro.reproductions}/${f.repro.runs}` : "none";
109
+ const heal = f.heal
110
+ ? f.heal.status + (f.heal.explanation ? ` — ${f.heal.explanation}` : "")
111
+ : "n/a";
112
+ return `- ${loc} | ${f.type} | severity=${f.severity} | "${shortMessage(f)}" | repro=${repro} | heal=${heal}`;
113
+ })
114
+ .join("\n");
115
+ const fixLine = hasHealed
116
+ ? "Verified fixes ARE ready to apply — mention that they are prepared."
117
+ : "No fixes were prepared — do not offer to apply anything.";
118
+ return [
119
+ `A QA tool scanned a web app and found the following findings:`,
120
+ rows,
121
+ "",
122
+ `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.`,
123
+ fixLine,
124
+ ].join("\n");
125
+ }
126
+ 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.";
127
+ async function summarizeFindingsLlm(findings, lang) {
128
+ const hasHealed = findings.some((f) => f.heal?.status === "healed");
129
+ const res = await fetch(API_URL, {
130
+ method: "POST",
131
+ headers: {
132
+ "content-type": "application/json",
133
+ "x-api-key": process.env.ANTHROPIC_API_KEY,
134
+ "anthropic-version": "2023-06-01",
135
+ },
136
+ body: JSON.stringify({
137
+ model: MODEL,
138
+ max_tokens: 1024,
139
+ temperature: 0.2,
140
+ system: SYSTEM,
141
+ messages: [{ role: "user", content: buildLlmPrompt(findings, lang, hasHealed) }],
142
+ }),
143
+ });
144
+ if (!res.ok) {
145
+ const body = await res.text().catch(() => "");
146
+ throw new Error(`summarize: LLM request failed (${res.status}): ${body.slice(0, 300)}`);
147
+ }
148
+ const data = (await res.json());
149
+ const text = (data.content ?? [])
150
+ .filter((c) => c.type === "text")
151
+ .map((c) => c.text ?? "")
152
+ .join("\n")
153
+ .trim();
154
+ return text || summarizeFindingsTemplate(findings, lang);
155
+ }
156
+ /**
157
+ * Entry point. Empty run → template (no API cost). Otherwise LLM when a key is
158
+ * present; falls back to the deterministic template on any transport failure, so
159
+ * the report never crashes the run.
160
+ */
161
+ export async function summarizeFindings(findings, opts = {}) {
162
+ const lang = normalizeLang(opts.lang);
163
+ if (findings.length === 0)
164
+ return summarizeFindingsTemplate(findings, lang);
165
+ if (!process.env.ANTHROPIC_API_KEY)
166
+ return summarizeFindingsTemplate(findings, lang);
167
+ try {
168
+ return await summarizeFindingsLlm(findings, lang);
169
+ }
170
+ catch {
171
+ return summarizeFindingsTemplate(findings, lang);
172
+ }
173
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * F-swarm — parallel detection. Runs N workers at once, each with its own
3
+ * browser context, event bus, action recorder, and classifier, so the action
4
+ * history attached to a finding belongs to the worker that saw it (never
5
+ * interleaved). Workers attack different sides: a deterministic walk, several
6
+ * chaos-fuzz seeds, and — optionally — the server-side HTTP fuzzer.
7
+ *
8
+ * Findings are merged by fingerprint at the end (occurrences summed, the richest
9
+ * action history / source mapping kept); the caller then runs repro/heal on the
10
+ * merged set as usual.
11
+ */
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import { chromium } from "playwright";
15
+ import { EventBus } from "./eventBus.js";
16
+ import { attachInterceptor } from "./interceptor.js";
17
+ import { establishLogin } from "./auth.js";
18
+ import { SignalClassifier } from "./classifier.js";
19
+ import { ActionRecorder } from "./recorder.js";
20
+ import { walkDom } from "./domWalker.js";
21
+ import { fuzz } from "./fuzzer.js";
22
+ import { httpFuzz } from "./httpFuzzer.js";
23
+ import { attachNetworkGuard } from "./networkGuard.js";
24
+ import { resolveFrame, resolveServerFrame } from "./resolver.js";
25
+ /**
26
+ * Run one worker's detection pass and return its findings. All internal events
27
+ * flow through a local bus (isolation); only `action`/`route`/`noise` are
28
+ * forwarded to `forwardBus` so a live panel can aggregate, never per-worker
29
+ * findings (those are merged by the caller first).
30
+ */
31
+ export async function detectWorker(browser, opts, strategy, forwardBus) {
32
+ const workerBus = new EventBus();
33
+ const recorder = new ActionRecorder();
34
+ const classifier = new SignalClassifier(opts.baseline);
35
+ workerBus.on("action", (a) => {
36
+ recorder.record(a);
37
+ forwardBus?.emit("action", a);
38
+ });
39
+ workerBus.on("route", (r) => forwardBus?.emit("route", r));
40
+ workerBus.on("noise", (n) => forwardBus?.emit("noise", n));
41
+ // Classify telemetry with THIS worker's recorder, so action history is correct.
42
+ workerBus.on("telemetry", async (payload) => {
43
+ const finding = classifier.classify(payload);
44
+ if (!finding)
45
+ return;
46
+ finding.actionHistory = recorder.snapshot();
47
+ if (finding.severity === "noise") {
48
+ workerBus.emit("noise", { ts: Date.now() });
49
+ return;
50
+ }
51
+ if (payload.serverError) {
52
+ finding.serverError = { message: payload.serverError.message, body: payload.serverError.body };
53
+ }
54
+ if (payload.url && payload.line) {
55
+ const resolved = await resolveFrame({ url: payload.url, line: payload.line, column: payload.column ?? 0, message: payload.rawMessage }, opts.repoRoot);
56
+ finding.mappedLocation = {
57
+ filePath: resolved.sourceFile,
58
+ line: resolved.line,
59
+ column: resolved.column,
60
+ codeContext: resolved.codeSnippet,
61
+ isOwnCode: resolved.resolvedFrom !== "unresolved",
62
+ };
63
+ }
64
+ else if (payload.serverError?.frame) {
65
+ const resolved = resolveServerFrame(payload.serverError.frame, opts.repoRoot);
66
+ finding.mappedLocation = {
67
+ filePath: resolved.sourceFile,
68
+ line: resolved.line,
69
+ column: resolved.column,
70
+ codeContext: resolved.codeSnippet,
71
+ isOwnCode: resolved.resolvedFrom !== "unresolved",
72
+ };
73
+ }
74
+ });
75
+ const context = await browser.newContext(opts.storageState ? { storageState: opts.storageState } : {});
76
+ const page = await context.newPage();
77
+ attachInterceptor(page, workerBus);
78
+ if (opts.guardOn) {
79
+ await attachNetworkGuard(page, {
80
+ allowHosts: opts.allowHosts,
81
+ onBlock: (u) => opts.log(`[guard] blocked ${u}`),
82
+ });
83
+ }
84
+ page.on("framenavigated", (frame) => {
85
+ if (frame === page.mainFrame())
86
+ workerBus.emit("route", { url: frame.url(), ts: Date.now() });
87
+ });
88
+ let loaded = true;
89
+ await page.goto(opts.url, { waitUntil: "load", timeout: 30000 }).catch((e) => {
90
+ loaded = false;
91
+ opts.log(`Failed to load target: ${e.message}`);
92
+ });
93
+ if (loaded) {
94
+ // Settle for hydration and mount-time effects before acting.
95
+ await page.waitForTimeout(2000);
96
+ }
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.
99
+ let replayStorageState;
100
+ if (loaded && strategy.kind !== "http-fuzz" && opts.login && opts.loginEmail && opts.loginPassword) {
101
+ const res = await establishLogin(page, {
102
+ email: opts.loginEmail,
103
+ password: opts.loginPassword,
104
+ loginUrl: opts.loginUrl,
105
+ });
106
+ if (res.ok) {
107
+ if (opts.saveAuthState) {
108
+ const state = await context.storageState();
109
+ const authStatePath = path.join(opts.repoRoot, ".aztrx", "auth-state.json");
110
+ fs.mkdirSync(path.dirname(authStatePath), { recursive: true });
111
+ fs.writeFileSync(authStatePath, JSON.stringify(state, null, 2), "utf-8");
112
+ replayStorageState = authStatePath;
113
+ opts.log(`[auth] logged in → ${path.relative(opts.repoRoot, authStatePath)}`);
114
+ }
115
+ else {
116
+ opts.log("[auth] logged in");
117
+ }
118
+ await page.goto(opts.url, { waitUntil: "load", timeout: 30000 }).catch(() => { });
119
+ }
120
+ else {
121
+ opts.log(`[auth] skipped: ${res.reason}`);
122
+ }
123
+ }
124
+ if (loaded && opts.crashTest) {
125
+ await page.evaluate(() => {
126
+ setTimeout(() => {
127
+ throw new Error("Aztrx test: Cannot read properties of undefined (reading 'token')");
128
+ }, 300);
129
+ });
130
+ await page.waitForTimeout(800);
131
+ }
132
+ let actions = 0;
133
+ if (loaded) {
134
+ if (strategy.kind === "walk") {
135
+ actions = await walkDom(page, workerBus, { maxActions: opts.maxActions, dryRun: opts.dryRun });
136
+ }
137
+ else if (strategy.kind === "fuzz") {
138
+ actions = await fuzz(page, workerBus, { seed: strategy.seed, maxActions: opts.maxActions, dryRun: opts.dryRun });
139
+ }
140
+ else {
141
+ actions = await httpFuzz(page, opts.url, workerBus, {
142
+ maxRequests: opts.maxActions,
143
+ dryRun: opts.dryRun,
144
+ allowHosts: opts.allowHosts,
145
+ mutations: opts.httpFuzzMutations,
146
+ });
147
+ }
148
+ }
149
+ await page.waitForTimeout(500);
150
+ await context.close();
151
+ return { findings: classifier.findings(), actions, replayStorageState };
152
+ }
153
+ /** Dedup findings across workers by fingerprint: sum occurrences, keep the richest. */
154
+ export function mergeFindings(arrays) {
155
+ const byFingerprint = new Map();
156
+ for (const arr of arrays) {
157
+ for (const f of arr) {
158
+ const existing = byFingerprint.get(f.fingerprint);
159
+ if (!existing) {
160
+ byFingerprint.set(f.fingerprint, { ...f, actionHistory: [...f.actionHistory] });
161
+ continue;
162
+ }
163
+ existing.occurrences += f.occurrences;
164
+ if (!existing.mappedLocation && f.mappedLocation)
165
+ existing.mappedLocation = f.mappedLocation;
166
+ if (existing.actionHistory.length < f.actionHistory.length)
167
+ existing.actionHistory = f.actionHistory;
168
+ }
169
+ }
170
+ return [...byFingerprint.values()];
171
+ }
172
+ /** Build the worker roster for a run. `workers = 1` with no http-fuzz is the
173
+ * legacy single pass; `--http-fuzz` and/or `workers > 1` fan out. */
174
+ function buildStrategies(opts) {
175
+ const strategies = [];
176
+ if (opts.httpFuzz)
177
+ strategies.push({ kind: "http-fuzz" });
178
+ const w = Math.max(1, opts.workers);
179
+ if (w === 1) {
180
+ strategies.push(opts.fuzz ? { kind: "fuzz", seed: opts.seed } : { kind: "walk" });
181
+ return strategies;
182
+ }
183
+ if (opts.fuzz) {
184
+ for (let i = 0; i < w; i++)
185
+ strategies.push({ kind: "fuzz", seed: opts.seed + i });
186
+ }
187
+ else {
188
+ strategies.push({ kind: "walk" });
189
+ for (let i = 1; i < w; i++)
190
+ strategies.push({ kind: "fuzz", seed: opts.seed + i });
191
+ }
192
+ return strategies;
193
+ }
194
+ /** Launch one browser, run the worker roster concurrently, merge findings. */
195
+ export async function swarmDetect(opts) {
196
+ const strategies = buildStrategies(opts);
197
+ const browser = await chromium.launch({ headless: true });
198
+ try {
199
+ const settled = await Promise.allSettled(strategies.map((strategy, i) => detectWorker(browser, {
200
+ url: opts.url,
201
+ repoRoot: opts.repoRoot,
202
+ allowHosts: opts.allowHosts,
203
+ maxActions: opts.maxActions,
204
+ dryRun: opts.dryRun,
205
+ guardOn: opts.guardOn,
206
+ storageState: opts.storageState,
207
+ login: opts.login,
208
+ loginEmail: opts.loginEmail,
209
+ loginPassword: opts.loginPassword,
210
+ loginUrl: opts.loginUrl,
211
+ crashTest: i === 0 ? opts.crashTest : false,
212
+ saveAuthState: i === 0,
213
+ httpFuzzMutations: opts.httpFuzzMutations,
214
+ baseline: opts.baseline,
215
+ log: (m) => opts.log(strategies.length > 1 ? `[w${i}] ${m}` : m),
216
+ }, strategy)));
217
+ const results = [];
218
+ settled.forEach((r, i) => {
219
+ if (r.status === "fulfilled")
220
+ results.push(r.value);
221
+ else
222
+ opts.log(`worker ${i} failed: ${r.reason?.message ?? String(r.reason)}`);
223
+ });
224
+ let replayStorageState;
225
+ for (const r of results)
226
+ if (r.replayStorageState)
227
+ replayStorageState = r.replayStorageState;
228
+ const findings = mergeFindings(results.map((r) => r.findings));
229
+ const totalActions = results.reduce((sum, r) => sum + r.actions, 0);
230
+ return { findings, replayStorageState, totalActions, workerCount: strategies.length };
231
+ }
232
+ finally {
233
+ await browser.close();
234
+ }
235
+ }
@@ -0,0 +1,15 @@
1
+ import { readFileSync } from "fs";
2
+ import { fileURLToPath } from "url";
3
+ // Single source of truth for the CLI's self-reported version. Read from the
4
+ // installed package.json so a future `npm version` bump never drifts from the
5
+ // printed banner — a hardcoded "v0.1.1" previously survived the 0.2.0 bump.
6
+ function readVersion() {
7
+ try {
8
+ const url = new URL("../../package.json", import.meta.url);
9
+ return JSON.parse(readFileSync(fileURLToPath(url), "utf-8")).version;
10
+ }
11
+ catch {
12
+ return "0.0.0"; // cosmetic only — package.json not resolvable (raw dist/ checkout)
13
+ }
14
+ }
15
+ export const VERSION = readVersion();