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.
@@ -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 `![${label}: ${value}](https://img.shields.io/badge/${shield(label)}-${shield(value)}-${shield(color)})`;
29
+ }
30
+ function escapeHtml(s) {
31
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
+ }
@@ -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
+ }