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.
- package/README.md +62 -4
- package/dist/cli.js +61 -1
- package/dist/core/browser.js +47 -0
- package/dist/core/classifier.js +92 -2
- package/dist/core/diagnose.js +91 -0
- package/dist/core/diff.js +137 -0
- package/dist/core/domWalker.js +1 -1
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/index.js +7 -5
- package/dist/core/heal/llm.js +16 -0
- package/dist/core/heal/redact.js +5 -1
- package/dist/core/heal/sandbox.js +5 -1
- package/dist/core/httpFuzzer.js +8 -4
- package/dist/core/orchestrator.js +33 -11
- package/dist/core/patrol/loop.js +218 -0
- package/dist/core/patrol/pr.js +233 -0
- package/dist/core/patrol/record.js +103 -0
- package/dist/core/patrol/state.js +78 -0
- package/dist/core/renderMarkdown.js +92 -0
- package/dist/core/replay.js +2 -2
- package/dist/core/report.js +6 -3
- package/dist/core/resolver.js +86 -25
- package/dist/core/summarize.js +2 -2
- package/dist/core/swarm.js +38 -18
- package/dist/core/ui.js +1 -0
- package/dist/ui/app.js +27 -2
- package/media/repro-demo/crash.gif +0 -0
- package/media/repro-demo/crash.png +0 -0
- package/media/repro-demo/frame_0.png +0 -0
- package/media/repro-demo/frame_1.png +0 -0
- package/package.json +6 -2
package/dist/core/resolver.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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;
|
package/dist/core/summarize.js
CHANGED
|
@@ -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
|
|
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
|
|
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({
|
package/dist/core/swarm.js
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from "fs";
|
|
13
13
|
import * as path from "path";
|
|
14
|
-
import {
|
|
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).
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
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`
|
|
179
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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.
|
|
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": "
|
|
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
|
}
|