aztrx-cli 0.4.2 → 0.4.4

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,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({
@@ -11,11 +11,11 @@
11
11
  */
12
12
  import * as fs from "fs";
13
13
  import * as path from "path";
14
- import { chromium } from "playwright";
14
+ 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
  }
@@ -211,7 +227,7 @@ function strategyLabel(s) {
211
227
  /** Launch one browser, run the worker roster concurrently, merge findings. */
212
228
  export async function swarmDetect(opts) {
213
229
  const strategies = buildStrategies(opts);
214
- const browser = await chromium.launch({ headless: true });
230
+ const browser = await launchChromium();
215
231
  try {
216
232
  const settled = await Promise.allSettled(strategies.map((strategy, i) => detectWorker(browser, {
217
233
  url: opts.url,
@@ -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}
package/dist/ui/app.js CHANGED
@@ -2,6 +2,8 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useReducer, useRef, useState } from "react";
3
3
  import { render, Box, Text, useApp } from "ink";
4
4
  import { VERSION } from "../core/version.js";
5
+ import { diagnoseFinding } from "../core/diagnose.js";
6
+ import { diffHunks } from "../core/diff.js";
5
7
  // Palette — mirrors web/app/globals.css "crash seismograph" tokens, mapped to
6
8
  // the nearest ANSI colors so the terminal panel reads as the same instrument.
7
9
  const C = {
@@ -47,6 +49,11 @@ function reducer(state, msg) {
47
49
  }
48
50
  case "repro":
49
51
  return { ...state, repros: { ...state.repros, [msg.repro.finding.fingerprint]: msg.repro } };
52
+ case "heal":
53
+ return {
54
+ ...state,
55
+ findings: state.findings.map((f) => f.fingerprint === msg.heal.finding.fingerprint ? msg.heal.finding : f),
56
+ };
50
57
  default:
51
58
  return state;
52
59
  }
@@ -78,6 +85,7 @@ function useAztrx(bus) {
78
85
  bus.on("noise", () => dispatch({ type: "noise" })),
79
86
  bus.on("route", (r) => dispatch({ type: "route", url: r.url })),
80
87
  bus.on("repro", (r) => dispatch({ type: "repro", repro: r })),
88
+ bus.on("heal", (h) => dispatch({ type: "heal", heal: h })),
81
89
  ];
82
90
  return () => offs.forEach((off) => off());
83
91
  }, [bus]);
@@ -107,11 +115,28 @@ function ReproBadge({ repro }) {
107
115
  return (_jsxs(Text, { color: color, children: ["[", repro.verdict, " ", repro.reproductions, "/", repro.runs, "]"] }));
108
116
  }
109
117
  function FindingRow({ finding, repro }) {
110
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(SeverityMark, { severity: finding.severity }), _jsx(Text, { color: C.dim, children: " " }), _jsx(Text, { color: C.fg, children: firstLine(finding.rawMessage) })] }), finding.mappedLocation ? (_jsxs(Text, { color: C.dim, children: [" ", finding.mappedLocation.filePath, ":", finding.mappedLocation.line, ":", finding.mappedLocation.column] })) : null, repro ? (_jsxs(Box, { children: [_jsx(Text, { color: C.dim, children: " " }), _jsx(ReproBadge, { repro: repro }), _jsxs(Text, { color: C.dim, children: [" ", "spec ", repro.specPath, " \u00B7 ", repro.steps, "/", repro.totalSteps, " steps"] })] })) : null] }));
118
+ const dx = diagnoseFinding(finding);
119
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(SeverityMark, { severity: finding.severity }), _jsx(Text, { color: C.dim, children: " " }), _jsx(Text, { color: C.fg, children: firstLine(finding.rawMessage) })] }), finding.mappedLocation ? (_jsxs(Text, { color: C.dim, children: [" ", finding.mappedLocation.filePath, ":", finding.mappedLocation.line, ":", finding.mappedLocation.column] })) : null, dx ? (_jsxs(Text, { color: C.azure, children: [" ↳ ", dx] })) : null, repro ? (_jsxs(Box, { children: [_jsx(Text, { color: C.dim, children: " " }), _jsx(ReproBadge, { repro: repro }), _jsxs(Text, { color: C.dim, children: [" ", "spec ", repro.specPath, " \u00B7 ", repro.steps, "/", repro.totalSteps, " steps"] })] })) : null, finding.heal ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: finding.heal.status === "healed" ? C.green : C.amber, children: [" ", finding.heal.status === "healed" ? "✓" : "◐", " ", finding.heal.status, finding.heal.model ? ` · ${finding.heal.model}` : ""] }), finding.heal.status === "healed" && finding.heal.hunks.length > 0 ? (_jsx(DiffView, { hunks: finding.heal.hunks, filePath: finding.heal.filePath })) : null] })) : null] }));
120
+ }
121
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
122
+ function DiffView({ hunks, filePath }) {
123
+ const groups = diffHunks(hunks);
124
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: C.dim, children: [" ", filePath] }), groups.map((group, gi) => (_jsx(Box, { flexDirection: "column", children: group.map((l, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: l.type === "add" ? C.green : C.red, children: [" ", l.type === "add" ? "+" : "-", " "] }), _jsx(Text, { children: l.tokens.map((t, j) => {
125
+ if (t.kind === "del")
126
+ return _jsx(Text, { backgroundColor: "red", color: "white", children: t.text }, j);
127
+ if (t.kind === "add")
128
+ return _jsx(Text, { backgroundColor: "green", color: "black", children: t.text }, j);
129
+ return _jsx(Text, { color: l.type === "add" ? C.green : C.red, children: t.text }, j);
130
+ }) })] }, i))) }, gi)))] }));
111
131
  }
112
132
  function AztrxApp({ bus, done, targetUrl, repoRoot, mode }) {
113
133
  const { exit } = useApp();
114
134
  const { state, rate } = useAztrx(bus);
135
+ const [spin, setSpin] = useState(0);
136
+ useEffect(() => {
137
+ const id = setInterval(() => setSpin((s) => (s + 1) % SPINNER.length), 80);
138
+ return () => clearInterval(id);
139
+ }, []);
115
140
  useEffect(() => {
116
141
  let cancelled = false;
117
142
  const finish = () => {
@@ -125,7 +150,7 @@ function AztrxApp({ bus, done, targetUrl, repoRoot, mode }) {
125
150
  }, [done, exit]);
126
151
  const phase = PHASE_LABEL[state.phase];
127
152
  const currentRoute = state.routes[state.routes.length - 1] ?? targetUrl;
128
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: C.azure, bold: true, children: "Aztrx AI" }), _jsx(Text, { color: C.dim, children: " \u2014 Runtime Detector" }), _jsxs(Text, { color: C.dim, children: [" v", VERSION] })] }), _jsxs(Text, { color: C.dim, children: [" target ", targetUrl, " repo ", repoRoot] }), _jsxs(Text, { color: C.dim, children: [" mode ", mode] }), _jsx(Text, { color: C.dim, children: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: phase.color, children: phase.text }), _jsx(Text, { color: C.dim, children: " " }), _jsx(Text, { color: C.azureBright, bold: true, children: rate.toFixed(1) }), _jsx(Text, { color: C.dim, children: " ops/s \u00B7 " }), _jsx(Text, { color: C.fg, children: state.actions }), _jsx(Text, { color: C.dim, children: " actions \u00B7 " }), _jsx(Text, { color: C.fg, children: state.clicks }), _jsx(Text, { color: C.dim, children: " clicks" })] }), _jsxs(Box, { children: [_jsx(Text, { color: C.dim, children: " route " }), _jsx(Text, { color: C.muted, children: currentRoute }), _jsxs(Text, { color: C.dim, children: [" \u00B7 ", state.routes.length, " route(s)"] })] }), state.findings.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: C.muted, bold: true, children: ["findings (", state.findings.length, ")"] }), state.findings.map((f) => (_jsx(FindingRow, { finding: f, repro: state.repros[f.fingerprint] }, f.fingerprint)))] })) : null, state.noise > 0 ? (_jsxs(Text, { color: C.dim, children: [" \u25B8 ", state.noise, " noise event(s) suppressed"] })) : null] }));
153
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: C.azure, bold: true, children: "Aztrx AI" }), _jsx(Text, { color: C.dim, children: " \u2014 Runtime Detector" }), _jsxs(Text, { color: C.dim, children: [" v", VERSION] })] }), _jsxs(Text, { color: C.dim, children: [" target ", targetUrl, " repo ", repoRoot] }), _jsxs(Text, { color: C.dim, children: [" mode ", mode] }), _jsx(Text, { color: C.dim, children: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: phase.color, children: [state.phase === "done" ? "✓" : SPINNER[spin], " ", phase.text.replace(/^[◉✓]\s*/, "")] }), _jsx(Text, { color: C.dim, children: " " }), _jsx(Text, { color: C.azureBright, bold: true, children: rate.toFixed(1) }), _jsx(Text, { color: C.dim, children: " ops/s \u00B7 " }), _jsx(Text, { color: C.fg, children: state.actions }), _jsx(Text, { color: C.dim, children: " actions \u00B7 " }), _jsx(Text, { color: C.fg, children: state.clicks }), _jsx(Text, { color: C.dim, children: " clicks" })] }), _jsxs(Box, { children: [_jsx(Text, { color: C.dim, children: " route " }), _jsx(Text, { color: C.muted, children: currentRoute }), _jsxs(Text, { color: C.dim, children: [" \u00B7 ", state.routes.length, " route(s)"] })] }), state.findings.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: C.muted, bold: true, children: ["findings (", state.findings.length, ")"] }), state.findings.map((f) => (_jsx(FindingRow, { finding: f, repro: state.repros[f.fingerprint] }, f.fingerprint)))] })) : null, state.noise > 0 ? (_jsxs(Text, { color: C.dim, children: [" \u25B8 ", state.noise, " noise event(s) suppressed"] })) : null] }));
129
154
  }
130
155
  /** Mount the live terminal panel and resolve once the run (or a failure) ends. */
131
156
  export function renderTui(props) {
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aztrx-cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "private": false,
5
5
  "description": "Aztrx AI — runtime stress-tester for web apps. Detect bugs, then prove them with an executable repro.",
6
6
  "license": "Apache-2.0",
@@ -38,6 +38,7 @@
38
38
  "build": "tsc",
39
39
  "dev": "node dist/cli.js",
40
40
  "bench": "npm run build && tsx bench/run.ts",
41
+ "test": "tsx --test \"tests/**/*.test.ts\"",
41
42
  "typecheck": "tsc --noEmit && tsc -p server/tsconfig.json",
42
43
  "server": "tsx server/index.ts",
43
44
  "server:typecheck": "tsc -p server/tsconfig.json",
@@ -46,14 +47,17 @@
46
47
  "dependencies": {
47
48
  "@jridgewell/trace-mapping": "^0.3.25",
48
49
  "commander": "^12.1.0",
50
+ "gifenc": "^1.0.3",
49
51
  "ink": "^5.2.0",
50
52
  "picocolors": "^1.1.1",
51
- "playwright": "^1.49.0",
53
+ "playwright": "1.62.1",
54
+ "pngjs": "^7.0.0",
52
55
  "react": "^18.3.1",
53
56
  "typescript": "^5.7.0"
54
57
  },
55
58
  "devDependencies": {
56
59
  "@types/node": "^22.10.0",
60
+ "@types/pngjs": "^6.0.5",
57
61
  "@types/react": "^18.3.12",
58
62
  "tsx": "^4.19.0"
59
63
  }