aztrx-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +292 -0
- package/dist/cli.js +136 -0
- package/dist/core/classifier.js +139 -0
- package/dist/core/cloud/index.js +103 -0
- package/dist/core/domWalker.js +80 -0
- package/dist/core/eventBus.js +22 -0
- package/dist/core/events.js +25 -0
- package/dist/core/fuzzer.js +175 -0
- package/dist/core/heal/gates.js +165 -0
- package/dist/core/heal/index.js +242 -0
- package/dist/core/heal/llm.js +108 -0
- package/dist/core/heal/redact.js +63 -0
- package/dist/core/heal/sandbox.js +99 -0
- package/dist/core/heal/types.js +1 -0
- package/dist/core/heal/verify.js +25 -0
- package/dist/core/init.js +74 -0
- package/dist/core/interceptor.js +93 -0
- package/dist/core/minimizer.js +48 -0
- package/dist/core/networkGuard.js +49 -0
- package/dist/core/orchestrator.js +337 -0
- package/dist/core/pr.js +141 -0
- package/dist/core/recorder.js +93 -0
- package/dist/core/replay.js +112 -0
- package/dist/core/report.js +84 -0
- package/dist/core/resolver.js +124 -0
- package/dist/core/rng.js +11 -0
- package/dist/core/specCompiler.js +69 -0
- package/dist/core/studio.js +219 -0
- package/dist/core/telemetry/index.js +103 -0
- package/dist/core/telemetry/sanitize.js +57 -0
- package/dist/core/telemetry/types.js +4 -0
- package/dist/core/types.js +1 -0
- package/dist/core/ui.js +93 -0
- package/dist/core/validator.js +16 -0
- package/dist/ui/app.js +131 -0
- package/package.json +41 -0
package/dist/core/pr.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
/**
|
|
4
|
+
* F-report โ the PR bot's markdown comment. Same findings as the HTML report,
|
|
5
|
+
* reshaped for a GitHub PR: a status badge, one `<details>` per finding, the
|
|
6
|
+
* minimized reproduction steps, the compiled Playwright spec inlined, and the
|
|
7
|
+
* gated patch as a `diff` view. Self-contained โ no external data beyond the
|
|
8
|
+
* shields.io badges, which GitHub renders natively.
|
|
9
|
+
*/
|
|
10
|
+
const SEV_ORDER = ["crash", "error", "warning", "noise"];
|
|
11
|
+
const SEV_BADGE = {
|
|
12
|
+
crash: "ff5a5f",
|
|
13
|
+
error: "ff5a5f",
|
|
14
|
+
warning: "f5a623",
|
|
15
|
+
noise: "5b6573",
|
|
16
|
+
};
|
|
17
|
+
const SEV_ICON = {
|
|
18
|
+
crash: "๐ฅ",
|
|
19
|
+
error: "๐จ",
|
|
20
|
+
warning: "โ ๏ธ",
|
|
21
|
+
noise: "ยท",
|
|
22
|
+
};
|
|
23
|
+
/** Shields.io badge-path escaping: literal `-` โ `--`, `/` โ `%2F`, space โ `_`. */
|
|
24
|
+
function shield(s) {
|
|
25
|
+
return s.replace(/-/g, "--").replace(/\//g, "%2F").replace(/ /g, "_");
|
|
26
|
+
}
|
|
27
|
+
function badge(label, value, color) {
|
|
28
|
+
return `}-${shield(value)}-${shield(color)})`;
|
|
29
|
+
}
|
|
30
|
+
function escapeHtml(s) {
|
|
31
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
32
|
+
}
|
|
33
|
+
function readIfExists(p) {
|
|
34
|
+
try {
|
|
35
|
+
return fs.readFileSync(p, "utf-8");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function describeAction(a) {
|
|
42
|
+
const sel = a.selectors[0] ? ` \`${a.selectors[0]}\`` : "";
|
|
43
|
+
const val = a.value ? ` \`${a.value}\`` : "";
|
|
44
|
+
return `**${a.type}**${val}${sel}`;
|
|
45
|
+
}
|
|
46
|
+
function healBlock(f) {
|
|
47
|
+
const h = f.heal;
|
|
48
|
+
if (!h)
|
|
49
|
+
return "";
|
|
50
|
+
const labels = {
|
|
51
|
+
healed: { text: "healed", color: "43e58a" },
|
|
52
|
+
unfixed: { text: "unfixed", color: "f5a623" },
|
|
53
|
+
rejected: { text: "rejected", color: "ff5a5f" },
|
|
54
|
+
"compile-failed": { text: "compile-failed", color: "ff5a5f" },
|
|
55
|
+
"apply-failed": { text: "apply-failed", color: "ff5a5f" },
|
|
56
|
+
skipped: { text: "skipped", color: "5b6573" },
|
|
57
|
+
"no-llm": { text: "no-llm", color: "5b6573" },
|
|
58
|
+
};
|
|
59
|
+
const meta = labels[h.status] ?? { text: h.status, color: "5b6573" };
|
|
60
|
+
const via = h.model ? ` ยท \`${h.model}\`` : "";
|
|
61
|
+
const tiers = h.tiers && h.tiers.length > 1 ? ` ยท router: ${h.tiers.join(" โ ")}` : "";
|
|
62
|
+
const body = [];
|
|
63
|
+
// The saved unified diff is only produced for a patch that reached verify.
|
|
64
|
+
const diff = h.patchPath ? readIfExists(path.resolve(h.patchPath)) : null;
|
|
65
|
+
if (diff) {
|
|
66
|
+
body.push("```diff");
|
|
67
|
+
body.push(diff.trimEnd());
|
|
68
|
+
body.push("```");
|
|
69
|
+
}
|
|
70
|
+
else if (h.explanation) {
|
|
71
|
+
body.push(`> ${h.explanation}`);
|
|
72
|
+
}
|
|
73
|
+
if (h.error)
|
|
74
|
+
body.push(`\n_${escapeHtml(h.error)}_`);
|
|
75
|
+
return `\n<details>\n<summary>${badge("heal", meta.text, meta.color)} proposed patch${via}${tiers}</summary>\n\n${body.join("\n")}\n</details>`;
|
|
76
|
+
}
|
|
77
|
+
function reproBlock(f) {
|
|
78
|
+
const r = f.repro;
|
|
79
|
+
if (!r || !r.specPath)
|
|
80
|
+
return "";
|
|
81
|
+
const verdict = badge("repro", `${r.verdict} ${r.reproductions}/${r.runs}`, r.verdict === "deterministic" ? "43e58a" : "f5a623");
|
|
82
|
+
const steps = r.actions.length
|
|
83
|
+
? `\n${r.actions.map((a, i) => `${i + 1}. ${describeAction(a)}`).join("\n")}`
|
|
84
|
+
: "";
|
|
85
|
+
const spec = readIfExists(path.resolve(r.specPath));
|
|
86
|
+
const specBlock = spec ? `\n\`\`\`ts\n${spec.trimEnd()}\n\`\`\`` : "";
|
|
87
|
+
return `\n<details>\n<summary>โถ ${verdict} ยท ${r.actions.length} step(s)</summary>\n\n**Reproduce**${steps}${specBlock}\n</details>`;
|
|
88
|
+
}
|
|
89
|
+
function findingBlock(f) {
|
|
90
|
+
const sev = f.severity;
|
|
91
|
+
const icon = SEV_ICON[sev] ?? "ยท";
|
|
92
|
+
const first = f.rawMessage.split("\n")[0];
|
|
93
|
+
const loc = f.mappedLocation
|
|
94
|
+
? `\n**Location** \`${f.mappedLocation.filePath}:${f.mappedLocation.line}:${f.mappedLocation.column}\``
|
|
95
|
+
: "";
|
|
96
|
+
const snippet = f.mappedLocation?.codeContext
|
|
97
|
+
? `\n\n\`\`\`${path.extname(f.mappedLocation.filePath).replace(".", "") || "ts"}\n${f.mappedLocation.codeContext.trimEnd()}\n\`\`\``
|
|
98
|
+
: "";
|
|
99
|
+
return `<details open>\n<summary>${icon} <code>${escapeHtml(sev)}</code> โ ${escapeHtml(first)}</summary>\n${loc}${snippet}${reproBlock(f)}${healBlock(f)}\n</details>`;
|
|
100
|
+
}
|
|
101
|
+
export function renderPrComment(targetUrl, findings, opts = {}) {
|
|
102
|
+
void opts;
|
|
103
|
+
const sorted = [...findings].sort((a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity));
|
|
104
|
+
const counts = { crash: 0, error: 0, warning: 0, noise: 0 };
|
|
105
|
+
for (const f of sorted)
|
|
106
|
+
counts[f.severity] = (counts[f.severity] ?? 0) + 1;
|
|
107
|
+
const critical = (counts.crash ?? 0) + (counts.error ?? 0);
|
|
108
|
+
const healed = sorted.filter((f) => f.heal?.status === "healed").length;
|
|
109
|
+
const repros = sorted.filter((f) => f.repro?.specPath).length;
|
|
110
|
+
const deterministic = sorted.filter((f) => f.repro?.verdict === "deterministic").length;
|
|
111
|
+
const statusBadge = critical
|
|
112
|
+
? badge("aztrx", `${critical} critical`, "ff5a5f")
|
|
113
|
+
: badge("aztrx", "clean", "43e58a");
|
|
114
|
+
const summaryBadges = [
|
|
115
|
+
statusBadge,
|
|
116
|
+
repros ? badge("repro", `${deterministic}/${repros} deterministic`, deterministic === repros ? "43e58a" : "f5a623") : "",
|
|
117
|
+
healed ? badge("heal", `${healed} patched`, "43e58a") : "",
|
|
118
|
+
]
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.join(" ");
|
|
121
|
+
const body = sorted.length
|
|
122
|
+
? sorted.map(findingBlock).join("\n\n")
|
|
123
|
+
: "> โ
No crash, error, or warning surfaced โ the app survived this pass.";
|
|
124
|
+
return `<!-- aztrx -->
|
|
125
|
+
## โก Aztrx โ runtime stress-test
|
|
126
|
+
|
|
127
|
+
${summaryBadges}
|
|
128
|
+
|
|
129
|
+
**Target** \`${targetUrl}\` ยท **${counts.crash ?? 0} crash** ยท **${counts.error ?? 0} error** ยท **${counts.warning ?? 0} warning**
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
${body}
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
export function writePrComment(repoRoot, targetUrl, findings, filePath) {
|
|
137
|
+
const file = filePath ?? path.join(repoRoot, ".aztrx", "pr-comment.md");
|
|
138
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
139
|
+
fs.writeFileSync(file, renderPrComment(targetUrl, findings, { repoRoot }), "utf-8");
|
|
140
|
+
return file;
|
|
141
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F2 โ ring buffer of the last 25 actions. The Repro Minimizer (F7) later
|
|
3
|
+
* shrinks this history; for now it's the action context attached to findings.
|
|
4
|
+
*/
|
|
5
|
+
export class ActionRecorder {
|
|
6
|
+
buffer = [];
|
|
7
|
+
capacity = 25;
|
|
8
|
+
record(action) {
|
|
9
|
+
this.buffer.push(action);
|
|
10
|
+
if (this.buffer.length > this.capacity)
|
|
11
|
+
this.buffer.shift();
|
|
12
|
+
}
|
|
13
|
+
snapshot() {
|
|
14
|
+
return [...this.buffer];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Selector cascade, most-reliable first: data-testid โ text โ css path.
|
|
19
|
+
*
|
|
20
|
+
* Each `evaluate` is wrapped defensively: selector resolution is best-effort
|
|
21
|
+
* and must never take down a whole run if the page tears the node down
|
|
22
|
+
* mid-query or the browser rejects a serialized function. A failed probe just
|
|
23
|
+
* degrades to the next (weaker) selector in the cascade.
|
|
24
|
+
*/
|
|
25
|
+
export async function selectorCascade(page, handle) {
|
|
26
|
+
const out = [];
|
|
27
|
+
try {
|
|
28
|
+
const testId = await handle.evaluate((el) => {
|
|
29
|
+
const t = el.getAttribute("data-testid");
|
|
30
|
+
return t ? `[data-testid="${t}"]` : null;
|
|
31
|
+
});
|
|
32
|
+
if (testId)
|
|
33
|
+
out.push(testId);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// degraded โ no data-testid selector
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const text = await handle.evaluate((el) => {
|
|
40
|
+
const t = el.innerText?.trim().replace(/\s+/g, " ").slice(0, 60);
|
|
41
|
+
return t ?? "";
|
|
42
|
+
});
|
|
43
|
+
if (text)
|
|
44
|
+
out.push(`text=${JSON.stringify(text)}`);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// degraded โ no text selector
|
|
48
|
+
}
|
|
49
|
+
const css = await cssPathOf(handle);
|
|
50
|
+
if (css)
|
|
51
|
+
out.push(css);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build a CSS path to `handle`, preferring an `#id`. The logic is inlined in
|
|
56
|
+
* the `evaluate` callback (no nested named function) because V8/Playwright's
|
|
57
|
+
* `Function.prototype.toString()` serialization of a *named* inner function
|
|
58
|
+
* emits a `__name` helper reference that is undefined in the page context โ
|
|
59
|
+
* which silently collapses the whole cascade to `[]`.
|
|
60
|
+
*/
|
|
61
|
+
async function cssPathOf(handle) {
|
|
62
|
+
try {
|
|
63
|
+
return await handle.evaluate((el) => {
|
|
64
|
+
if (el.id)
|
|
65
|
+
return `#${CSS.escape(el.id)}`;
|
|
66
|
+
const parts = [];
|
|
67
|
+
let current = el;
|
|
68
|
+
while (current !== null && current !== document.body) {
|
|
69
|
+
const tag = current.tagName.toLowerCase();
|
|
70
|
+
if (current.id) {
|
|
71
|
+
parts.unshift(`#${CSS.escape(current.id)}`);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
const parentElement = current.parentElement;
|
|
75
|
+
if (parentElement !== null) {
|
|
76
|
+
const siblings = Array.from(parentElement.children).filter((child) => child.tagName === tag);
|
|
77
|
+
if (siblings.length > 1) {
|
|
78
|
+
parts.unshift(`${tag}:nth-of-type(${siblings.indexOf(current) + 1})`);
|
|
79
|
+
current = parentElement;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
parts.unshift(tag);
|
|
84
|
+
current = parentElement;
|
|
85
|
+
}
|
|
86
|
+
return parts.join(" > ");
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// degraded โ no css-path selector
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
import { EventBus } from "./eventBus.js";
|
|
3
|
+
import { attachInterceptor } from "./interceptor.js";
|
|
4
|
+
import { fingerprintOf } from "./classifier.js";
|
|
5
|
+
/** Replays a recorded action sequence against a page. Best-effort: a selector
|
|
6
|
+
* that no longer resolves is skipped, not fatal. */
|
|
7
|
+
export async function replayActions(page, actions) {
|
|
8
|
+
for (const a of actions) {
|
|
9
|
+
if (a.type === "navigate") {
|
|
10
|
+
if (a.value) {
|
|
11
|
+
await page.goto(a.value, { waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => { });
|
|
12
|
+
}
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (a.type === "scroll") {
|
|
16
|
+
await page.mouse.wheel(0, a.value === "up" ? -600 : 600).catch(() => { });
|
|
17
|
+
await page.waitForTimeout(30);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (a.type === "keypress") {
|
|
21
|
+
const key = a.value ?? "Enter";
|
|
22
|
+
if (a.selectors[0]) {
|
|
23
|
+
await page.locator(a.selectors[0]).first().focus().catch(() => { });
|
|
24
|
+
}
|
|
25
|
+
await page.keyboard.press(key).catch(() => { });
|
|
26
|
+
await page.waitForTimeout(30);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
for (const sel of a.selectors) {
|
|
30
|
+
const loc = page.locator(sel).first();
|
|
31
|
+
const n = await loc.count().catch(() => 0);
|
|
32
|
+
if (n === 0)
|
|
33
|
+
continue;
|
|
34
|
+
switch (a.type) {
|
|
35
|
+
case "input":
|
|
36
|
+
await loc.fill(a.value ?? "").catch(() => { });
|
|
37
|
+
break;
|
|
38
|
+
case "hover":
|
|
39
|
+
await loc.hover().catch(() => { });
|
|
40
|
+
break;
|
|
41
|
+
case "select":
|
|
42
|
+
await loc.selectOption(a.value ?? "").catch(() => { });
|
|
43
|
+
break;
|
|
44
|
+
default:
|
|
45
|
+
await loc.click({ timeout: 1000 }).catch(() => { });
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
await page.waitForTimeout(50);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reuses one browser across replays (ddmin + validator each run many). Each
|
|
55
|
+
* `run` gets a fresh page; the interceptor collects telemetry fingerprints and
|
|
56
|
+
* reports whether `targetFingerprint` was seen.
|
|
57
|
+
*/
|
|
58
|
+
export class ReplayEngine {
|
|
59
|
+
opts;
|
|
60
|
+
browser = null;
|
|
61
|
+
constructor(opts = {}) {
|
|
62
|
+
this.opts = opts;
|
|
63
|
+
}
|
|
64
|
+
async getBrowser() {
|
|
65
|
+
if (!this.browser)
|
|
66
|
+
this.browser = await chromium.launch({ headless: true });
|
|
67
|
+
return this.browser;
|
|
68
|
+
}
|
|
69
|
+
async run(url, actions, targetFingerprint) {
|
|
70
|
+
// The browser is reused across replays for speed, but after enough page
|
|
71
|
+
// loads a renderer can crash. Relaunch once and retry so a single crash
|
|
72
|
+
// doesn't take down the whole repro pipeline.
|
|
73
|
+
let lastError = null;
|
|
74
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
75
|
+
let context = null;
|
|
76
|
+
let page = null;
|
|
77
|
+
try {
|
|
78
|
+
const browser = await this.getBrowser();
|
|
79
|
+
context = await browser.newContext(this.opts.storageState ? { storageState: this.opts.storageState } : {});
|
|
80
|
+
page = await context.newPage();
|
|
81
|
+
const bus = new EventBus();
|
|
82
|
+
const fingerprints = new Set();
|
|
83
|
+
bus.on("telemetry", (p) => fingerprints.add(fingerprintOf(p)));
|
|
84
|
+
attachInterceptor(page, bus);
|
|
85
|
+
if (this.opts.attachGuard)
|
|
86
|
+
await this.opts.attachGuard(page);
|
|
87
|
+
await page.goto(url, { waitUntil: "load", timeout: 30000 }).catch(() => { });
|
|
88
|
+
// Settle for hydration before replaying โ the detection pass waits on the
|
|
89
|
+
// `load` event plus a settle window, and a replay that clicks before React
|
|
90
|
+
// attaches its handlers won't reproduce the crash (false "unreliable").
|
|
91
|
+
await page.waitForTimeout(2000);
|
|
92
|
+
await replayActions(page, actions);
|
|
93
|
+
await page.waitForTimeout(300);
|
|
94
|
+
return { reproduced: fingerprints.has(targetFingerprint) };
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
lastError = e;
|
|
98
|
+
await this.close(); // drop the (possibly crashed) browser and retry fresh
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await page?.close().catch(() => { });
|
|
102
|
+
await context?.close().catch(() => { });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
throw lastError;
|
|
106
|
+
}
|
|
107
|
+
async close() {
|
|
108
|
+
if (this.browser)
|
|
109
|
+
await this.browser.close().catch(() => { });
|
|
110
|
+
this.browser = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { BASE_CSS, SEVERITY_COLOR, seismograph } from "./ui.js";
|
|
4
|
+
function escapeHtml(s) {
|
|
5
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
6
|
+
}
|
|
7
|
+
const SEV_ORDER = ["crash", "error", "warning", "noise"];
|
|
8
|
+
/**
|
|
9
|
+
* F-report โ standalone offline HTML report. Self-contained (inline CSS, no
|
|
10
|
+
* CDN), rendered in the shared "crash seismograph" identity: one red spike per
|
|
11
|
+
* crash, severity chips, and colored repro verdicts.
|
|
12
|
+
*/
|
|
13
|
+
export function renderReport(targetUrl, findings) {
|
|
14
|
+
const sorted = [...findings].sort((a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity));
|
|
15
|
+
const counts = { crash: 0, error: 0, warning: 0 };
|
|
16
|
+
for (const f of sorted)
|
|
17
|
+
if (counts[f.severity] !== undefined)
|
|
18
|
+
counts[f.severity]++;
|
|
19
|
+
const cards = sorted
|
|
20
|
+
.map((f) => {
|
|
21
|
+
const loc = f.mappedLocation
|
|
22
|
+
? `${escapeHtml(f.mappedLocation.filePath)}:${f.mappedLocation.line}:${f.mappedLocation.column}`
|
|
23
|
+
: "";
|
|
24
|
+
const snippet = f.mappedLocation ? escapeHtml(f.mappedLocation.codeContext) : "";
|
|
25
|
+
const repro = f.repro
|
|
26
|
+
? `<div class="repro ${f.repro.verdict}">${f.repro.verdict} ยท ${f.repro.reproductions}/${f.repro.runs} runs ยท ${f.repro.actions.length} step(s) ยท <code>${escapeHtml(path.basename(f.repro.specPath))}</code></div>`
|
|
27
|
+
: "";
|
|
28
|
+
const steps = f.actionHistory.length
|
|
29
|
+
? `<details><summary>action history (${f.actionHistory.length})</summary><ol>${f.actionHistory
|
|
30
|
+
.map((a) => {
|
|
31
|
+
const detail = a.value ? ` <span>${escapeHtml(a.value)}</span>` : "";
|
|
32
|
+
const sel = a.selectors[0] ? ` ${escapeHtml(a.selectors[0])}` : "";
|
|
33
|
+
return `<li><code>${escapeHtml(a.type)}${detail}</code>${sel}</li>`;
|
|
34
|
+
})
|
|
35
|
+
.join("")}</ol></details>`
|
|
36
|
+
: "";
|
|
37
|
+
return `
|
|
38
|
+
<article class="finding">
|
|
39
|
+
<header>
|
|
40
|
+
<span class="sev" style="--sev:${SEVERITY_COLOR[f.severity]}">${escapeHtml(f.severity)}</span>
|
|
41
|
+
<h2>${escapeHtml(f.rawMessage.split("\n")[0])}</h2>
|
|
42
|
+
</header>
|
|
43
|
+
${loc ? `<div class="loc">${loc}</div>` : ""}
|
|
44
|
+
${snippet ? `<pre class="snippet">${snippet}</pre>` : ""}
|
|
45
|
+
${f.occurrences > 1 ? `<div class="occ">seen ร${f.occurrences}</div>` : ""}
|
|
46
|
+
${repro}
|
|
47
|
+
${steps}
|
|
48
|
+
</article>`;
|
|
49
|
+
})
|
|
50
|
+
.join("\n");
|
|
51
|
+
return `<!doctype html>
|
|
52
|
+
<html lang="en">
|
|
53
|
+
<head>
|
|
54
|
+
<meta charset="utf-8">
|
|
55
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
56
|
+
<title>aztrx report</title>
|
|
57
|
+
<style>${BASE_CSS}</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<main>
|
|
61
|
+
<div class="hero">
|
|
62
|
+
${seismograph(counts.crash ?? 0)}
|
|
63
|
+
<div class="brand-row">
|
|
64
|
+
<h1><span class="brand">aztrx</span> <span class="brand-sub">report</span></h1>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="target">${escapeHtml(targetUrl)}</div>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="bar">
|
|
69
|
+
<span class="count">crash <b class="crash">${counts.crash ?? 0}</b></span>
|
|
70
|
+
<span class="count">error <b class="error">${counts.error ?? 0}</b></span>
|
|
71
|
+
<span class="count">warning <b class="warning">${counts.warning ?? 0}</b></span>
|
|
72
|
+
</div>
|
|
73
|
+
${sorted.length ? cards : `<p class="empty">No findings โ the app survived this pass.</p>`}
|
|
74
|
+
</main>
|
|
75
|
+
</body>
|
|
76
|
+
</html>`;
|
|
77
|
+
}
|
|
78
|
+
export function writeReport(repoRoot, targetUrl, findings) {
|
|
79
|
+
const dir = path.join(repoRoot, ".aztrx");
|
|
80
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
81
|
+
const file = path.join(dir, "report.html");
|
|
82
|
+
fs.writeFileSync(file, renderReport(targetUrl, findings), "utf-8");
|
|
83
|
+
return file;
|
|
84
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { TraceMap, originalPositionFor, } from "@jridgewell/trace-mapping";
|
|
4
|
+
/**
|
|
5
|
+
* Pulls the first `url:line:col` frame out of a stack string. Handles React
|
|
6
|
+
* Error Boundary console text, which embeds the original stack in its body.
|
|
7
|
+
*/
|
|
8
|
+
export function extractFrame(text) {
|
|
9
|
+
const match = text.match(/(https?:\/\/[^\s)"']+?):(\d+):(\d+)/);
|
|
10
|
+
if (!match)
|
|
11
|
+
return null;
|
|
12
|
+
return {
|
|
13
|
+
url: match[1],
|
|
14
|
+
line: parseInt(match[2], 10),
|
|
15
|
+
column: parseInt(match[3], 10),
|
|
16
|
+
message: text.split("\n")[0].trim().slice(0, 200),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function stripQuery(url) {
|
|
20
|
+
return url.split("?")[0];
|
|
21
|
+
}
|
|
22
|
+
/** True only for a real, readable regular file โ directories and unreadable
|
|
23
|
+
* paths return false so readers never hit `EISDIR` / permission errors. */
|
|
24
|
+
function isFile(p) {
|
|
25
|
+
try {
|
|
26
|
+
return fs.existsSync(p) && fs.statSync(p).isFile();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Turns a sourcemap `source` value into candidate absolute paths to probe. */
|
|
33
|
+
function sourceCandidates(source, repoRoot) {
|
|
34
|
+
const cleaned = source
|
|
35
|
+
.replace(/^webpack:\/\/[^/]+\//, "") // webpack://namespace/src/...
|
|
36
|
+
.replace(/^webpack:\/\//, "")
|
|
37
|
+
.replace(/^\/@fs\//, "")
|
|
38
|
+
.replace(/^\//, "")
|
|
39
|
+
.split("?")[0];
|
|
40
|
+
const prefixes = ["", "apps/web/", "src/", "app/"];
|
|
41
|
+
return prefixes.map((p) => path.resolve(repoRoot, p, cleaned));
|
|
42
|
+
}
|
|
43
|
+
function locateFile(candidates) {
|
|
44
|
+
for (const c of candidates) {
|
|
45
|
+
if (isFile(c))
|
|
46
|
+
return c;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
export async function resolveFrame(frame, repoRoot) {
|
|
51
|
+
const viaMap = await trySourceMap(frame, repoRoot);
|
|
52
|
+
if (viaMap)
|
|
53
|
+
return viaMap;
|
|
54
|
+
// Fallback: Vite dev serves real source files at their URL path, so the
|
|
55
|
+
// bundle URL is already the source path โ no sourcemap needed.
|
|
56
|
+
const relative = stripQuery(frame.url)
|
|
57
|
+
.replace(/^https?:\/\/[^/]+\//, "")
|
|
58
|
+
.replace(/^\//, "");
|
|
59
|
+
const directPath = path.resolve(repoRoot, relative);
|
|
60
|
+
return {
|
|
61
|
+
message: frame.message,
|
|
62
|
+
sourceFile: path.relative(repoRoot, directPath),
|
|
63
|
+
line: frame.line,
|
|
64
|
+
column: frame.column,
|
|
65
|
+
codeSnippet: extractSnippet(directPath, frame.line),
|
|
66
|
+
resolvedFrom: isFile(directPath) ? "direct" : "unresolved",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async function trySourceMap(frame, repoRoot) {
|
|
70
|
+
const mapUrl = stripQuery(frame.url) + ".map";
|
|
71
|
+
let rawMap;
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(mapUrl);
|
|
74
|
+
if (!res.ok)
|
|
75
|
+
return null;
|
|
76
|
+
rawMap = (await res.json());
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const map = new TraceMap(rawMap);
|
|
83
|
+
const pos = originalPositionFor(map, { line: frame.line, column: frame.column });
|
|
84
|
+
if (!pos.source || pos.line == null)
|
|
85
|
+
return null;
|
|
86
|
+
const absolute = locateFile(sourceCandidates(pos.source, repoRoot));
|
|
87
|
+
if (!absolute) {
|
|
88
|
+
return {
|
|
89
|
+
message: frame.message,
|
|
90
|
+
sourceFile: pos.source,
|
|
91
|
+
line: pos.line,
|
|
92
|
+
column: pos.column ?? 0,
|
|
93
|
+
codeSnippet: `<file not accessible locally: ${pos.source}>`,
|
|
94
|
+
resolvedFrom: "unresolved",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
message: frame.message,
|
|
99
|
+
sourceFile: path.relative(repoRoot, absolute),
|
|
100
|
+
line: pos.line,
|
|
101
|
+
column: pos.column ?? 0,
|
|
102
|
+
codeSnippet: extractSnippet(absolute, pos.line),
|
|
103
|
+
resolvedFrom: "sourcemap",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function extractSnippet(filePath, targetLine, window = 4) {
|
|
111
|
+
if (!isFile(filePath))
|
|
112
|
+
return `<file not accessible locally: ${filePath}>`;
|
|
113
|
+
const lines = fs.readFileSync(filePath, "utf-8").split("\n");
|
|
114
|
+
const start = Math.max(0, targetLine - window - 1);
|
|
115
|
+
const end = Math.min(lines.length, targetLine + window);
|
|
116
|
+
return lines
|
|
117
|
+
.slice(start, end)
|
|
118
|
+
.map((line, idx) => {
|
|
119
|
+
const n = start + idx + 1;
|
|
120
|
+
const marker = n === targetLine ? "> " : " ";
|
|
121
|
+
return `${marker}${String(n).padStart(4, " ")} โ ${line}`;
|
|
122
|
+
})
|
|
123
|
+
.join("\n");
|
|
124
|
+
}
|
package/dist/core/rng.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Deterministic seeded PRNG (mulberry32) so fuzz runs are reproducible. */
|
|
2
|
+
export function mulberry32(seed) {
|
|
3
|
+
let a = seed >>> 0;
|
|
4
|
+
return () => {
|
|
5
|
+
a |= 0;
|
|
6
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
7
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
8
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
9
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
const js = (s) => JSON.stringify(s);
|
|
4
|
+
/**
|
|
5
|
+
* F8 โ spec compiler. Turns a minimal action sequence into an executable
|
|
6
|
+
* Playwright test that asserts the failure actually happens, so a human can
|
|
7
|
+
* run `npx playwright test` and see the bug with their own eyes โ proof, not
|
|
8
|
+
* a log line.
|
|
9
|
+
*/
|
|
10
|
+
export function compileSpec(finding, actions, url) {
|
|
11
|
+
const title = finding.rawMessage.split("\n")[0].slice(0, 80) || "unknown error";
|
|
12
|
+
const needle = finding.rawMessage.split("\n")[0].slice(0, 120);
|
|
13
|
+
const out = [];
|
|
14
|
+
out.push(`import { test, expect } from "@playwright/test";`);
|
|
15
|
+
out.push(``);
|
|
16
|
+
out.push(`// Aztrx repro โ ${finding.id}`);
|
|
17
|
+
out.push(`// severity: ${finding.severity} type: ${finding.type}`);
|
|
18
|
+
if (finding.mappedLocation) {
|
|
19
|
+
out.push(`// source: ${finding.mappedLocation.filePath}:${finding.mappedLocation.line}:${finding.mappedLocation.column}`);
|
|
20
|
+
}
|
|
21
|
+
out.push(`test(${js(`repro: ${title}`)}, async ({ page }) => {`);
|
|
22
|
+
out.push(` const errors: string[] = [];`);
|
|
23
|
+
out.push(` page.on("pageerror", (e) => errors.push(e.message));`);
|
|
24
|
+
out.push(` page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });`);
|
|
25
|
+
out.push(` await page.goto(${js(url)});`);
|
|
26
|
+
for (const a of actions) {
|
|
27
|
+
if (a.type === "navigate") {
|
|
28
|
+
out.push(` await page.goto(${js(a.value ?? url)});`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (a.type === "scroll") {
|
|
32
|
+
out.push(` await page.mouse.wheel(0, ${a.value === "up" ? -600 : 600});`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const sel = a.selectors[0];
|
|
36
|
+
if (!sel) {
|
|
37
|
+
out.push(` // (skipped โ no reliable selector for this step)`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
switch (a.type) {
|
|
41
|
+
case "input":
|
|
42
|
+
out.push(` await page.locator(${js(sel)}).first().fill(${js(a.value ?? "")});`);
|
|
43
|
+
break;
|
|
44
|
+
case "hover":
|
|
45
|
+
out.push(` await page.locator(${js(sel)}).first().hover();`);
|
|
46
|
+
break;
|
|
47
|
+
case "select":
|
|
48
|
+
out.push(` await page.locator(${js(sel)}).first().selectOption(${js(a.value ?? "")});`);
|
|
49
|
+
break;
|
|
50
|
+
case "keypress":
|
|
51
|
+
out.push(` await page.locator(${js(sel)}).first().focus();`);
|
|
52
|
+
out.push(` await page.keyboard.press(${js(a.value ?? "Enter")});`);
|
|
53
|
+
break;
|
|
54
|
+
default:
|
|
55
|
+
out.push(` await page.locator(${js(sel)}).first().click();`);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
out.push(` await expect.poll(() => errors.join("\\n"), { timeout: 5000 }).toContain(${js(needle)});`);
|
|
60
|
+
out.push(`});`);
|
|
61
|
+
return out.join("\n") + "\n";
|
|
62
|
+
}
|
|
63
|
+
export function writeSpec(repoRoot, finding, actions, url) {
|
|
64
|
+
const dir = path.join(repoRoot, ".aztrx", "repro");
|
|
65
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
66
|
+
const file = path.join(dir, `${finding.id}.spec.ts`);
|
|
67
|
+
fs.writeFileSync(file, compileSpec(finding, actions, url), "utf-8");
|
|
68
|
+
return file;
|
|
69
|
+
}
|