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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
// Unambiguous framework files. NOTE: `/_next/static/chunks/` is NOT here —
|
|
5
|
+
// in Next.js dev, user code is served from there too, so URL path alone can't
|
|
6
|
+
// distinguish it. This is an approximation; the sourcemap resolver does the
|
|
7
|
+
// precise own-code mapping separately.
|
|
8
|
+
const DEP_FRAGMENTS = [
|
|
9
|
+
"node_modules",
|
|
10
|
+
"react-dom",
|
|
11
|
+
"react.development",
|
|
12
|
+
"react.production",
|
|
13
|
+
"scheduler.development",
|
|
14
|
+
"scheduler.production",
|
|
15
|
+
"next/dist",
|
|
16
|
+
"webpack-internal",
|
|
17
|
+
];
|
|
18
|
+
const NOISE_FRAGMENTS = [
|
|
19
|
+
"Download the React DevTools",
|
|
20
|
+
"react_devtools_backend",
|
|
21
|
+
];
|
|
22
|
+
// Dev-tooling artifacts that are not the app under test — the Next.js dev
|
|
23
|
+
// overlay's "launch editor" source-resolver request, etc. Suppressed regardless
|
|
24
|
+
// of signal type so they neither count as findings nor enter the repro pipeline.
|
|
25
|
+
const DEV_TOOLING_NOISE = ["__nextjs_launch-editor"];
|
|
26
|
+
// React 18/19 hydration mismatches (Next.js 15 App Router included). These are
|
|
27
|
+
// boundary-level divergences between server and client render that stress runs
|
|
28
|
+
// surface constantly but that aren't deterministic bugs in the app's logic —
|
|
29
|
+
// letting them through would poison the repro pipeline. Suppressed as noise.
|
|
30
|
+
const HYDRATION_NOISE = [
|
|
31
|
+
"Hydration failed",
|
|
32
|
+
"There was an error while hydrating",
|
|
33
|
+
"Text content does not match server-rendered HTML",
|
|
34
|
+
"A tree hydrated but some attributes of the server rendered HTML",
|
|
35
|
+
"An error occurred during hydration",
|
|
36
|
+
"Hydration completed but contained mismatches",
|
|
37
|
+
];
|
|
38
|
+
function normalize(message) {
|
|
39
|
+
return message
|
|
40
|
+
.replace(/\b\d+\b/g, "<N>")
|
|
41
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<UUID>")
|
|
42
|
+
.replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, "<TS>")
|
|
43
|
+
.replace(/0x[0-9a-f]+/gi, "<HEX>")
|
|
44
|
+
.replace(/\s+/g, " ");
|
|
45
|
+
}
|
|
46
|
+
function extractFrameUrls(stack) {
|
|
47
|
+
const urls = [];
|
|
48
|
+
const re = /https?:\/\/[^\s)"']+?:\d+:\d+/g;
|
|
49
|
+
let m;
|
|
50
|
+
while ((m = re.exec(stack)) !== null)
|
|
51
|
+
urls.push(m[0]);
|
|
52
|
+
return urls;
|
|
53
|
+
}
|
|
54
|
+
function isDepFrame(frame) {
|
|
55
|
+
return DEP_FRAGMENTS.some((d) => frame.includes(d));
|
|
56
|
+
}
|
|
57
|
+
function stripPos(frame) {
|
|
58
|
+
return frame.replace(/:\d+:\d+$/, "");
|
|
59
|
+
}
|
|
60
|
+
function hasOwnFrame(stack) {
|
|
61
|
+
const frames = extractFrameUrls(stack);
|
|
62
|
+
if (frames.length === 0)
|
|
63
|
+
return false;
|
|
64
|
+
return frames.some((f) => !isDepFrame(f));
|
|
65
|
+
}
|
|
66
|
+
export function fingerprintOf(payload) {
|
|
67
|
+
const frames = extractFrameUrls(payload.rawStack);
|
|
68
|
+
const own = frames.filter((f) => !isDepFrame(f)).slice(0, 3).map(stripPos);
|
|
69
|
+
const key = `${payload.type}|${normalize(payload.rawMessage)}|${own.join("|")}`;
|
|
70
|
+
return createHash("sha1").update(key).digest("hex").slice(0, 12);
|
|
71
|
+
}
|
|
72
|
+
function classifySeverity(payload, isOwnCode) {
|
|
73
|
+
if (DEV_TOOLING_NOISE.some((n) => payload.rawMessage.includes(n)))
|
|
74
|
+
return "noise";
|
|
75
|
+
if (HYDRATION_NOISE.some((n) => payload.rawMessage.includes(n)))
|
|
76
|
+
return "noise";
|
|
77
|
+
switch (payload.type) {
|
|
78
|
+
case "uncaught_exception":
|
|
79
|
+
return isOwnCode ? "crash" : "error";
|
|
80
|
+
case "unhandled_rejection":
|
|
81
|
+
case "network_5xx":
|
|
82
|
+
case "network_timeout":
|
|
83
|
+
return "error";
|
|
84
|
+
case "console_error":
|
|
85
|
+
if (NOISE_FRAGMENTS.some((n) => payload.rawMessage.includes(n)))
|
|
86
|
+
return "noise";
|
|
87
|
+
return "warning";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* F3 — Signal classifier. Fingerprints, dedups, assigns severity, and
|
|
92
|
+
* suppresses a baseline of already-known fingerprints. Returns a Finding on
|
|
93
|
+
* first sight, `null` on duplicate or suppressed.
|
|
94
|
+
*/
|
|
95
|
+
export class SignalClassifier {
|
|
96
|
+
seen = new Map();
|
|
97
|
+
baseline = new Set();
|
|
98
|
+
constructor(baselineFingerprints = []) {
|
|
99
|
+
for (const f of baselineFingerprints)
|
|
100
|
+
this.baseline.add(f);
|
|
101
|
+
}
|
|
102
|
+
classify(payload) {
|
|
103
|
+
const fingerprint = fingerprintOf(payload);
|
|
104
|
+
if (this.baseline.has(fingerprint))
|
|
105
|
+
return null;
|
|
106
|
+
const existing = this.seen.get(fingerprint);
|
|
107
|
+
if (existing) {
|
|
108
|
+
existing.occurrences += 1;
|
|
109
|
+
return null; // dedup — count, don't re-emit
|
|
110
|
+
}
|
|
111
|
+
const isOwnCode = hasOwnFrame(payload.rawStack);
|
|
112
|
+
const finding = {
|
|
113
|
+
id: fingerprint,
|
|
114
|
+
fingerprint,
|
|
115
|
+
occurrences: 1,
|
|
116
|
+
severity: classifySeverity(payload, isOwnCode),
|
|
117
|
+
type: payload.type,
|
|
118
|
+
rawMessage: payload.rawMessage,
|
|
119
|
+
rawStack: payload.rawStack,
|
|
120
|
+
actionHistory: [],
|
|
121
|
+
};
|
|
122
|
+
this.seen.set(fingerprint, finding);
|
|
123
|
+
return finding;
|
|
124
|
+
}
|
|
125
|
+
/** Unique findings, noise suppressed. */
|
|
126
|
+
findings() {
|
|
127
|
+
return [...this.seen.values()].filter((f) => f.severity !== "noise");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export async function loadBaseline(repoRoot) {
|
|
131
|
+
const p = path.join(repoRoot, ".aztrx", "baseline.json");
|
|
132
|
+
try {
|
|
133
|
+
const arr = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
134
|
+
return Array.isArray(arr) ? arr.filter((x) => typeof x === "string") : [];
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F12 — opt-in cloud sync. Streams a completed run's findings to the Aztrx
|
|
3
|
+
* ingest API (`POST /api/runs`) for the team dashboard and server-side
|
|
4
|
+
* deduplication by crash fingerprint. Mirrors the telemetry module's contract:
|
|
5
|
+
* fire-and-forget, bounded by a short abort, and never affects the exit code.
|
|
6
|
+
* Everything is sanitized before packaging (secrets, URLs, repo paths).
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import { detectFrameworkMeta } from "../init.js";
|
|
10
|
+
import { createSanitizer } from "../telemetry/sanitize.js";
|
|
11
|
+
const DEFAULT_CLOUD_URL = process.env.AZTRX_CLOUD_URL || "https://api.aztrx.app";
|
|
12
|
+
const UPLOAD_TIMEOUT_MS = 2000;
|
|
13
|
+
/** In-flight uploads, drained by `flushCloud()` before the CLI exits. */
|
|
14
|
+
const pendingUploads = [];
|
|
15
|
+
function readFileIfExists(p) {
|
|
16
|
+
try {
|
|
17
|
+
return fs.readFileSync(p, "utf-8");
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function buildPayload(findings, opts) {
|
|
24
|
+
const sanitize = createSanitizer(opts.repoRoot);
|
|
25
|
+
const meta = detectFrameworkMeta(opts.repoRoot);
|
|
26
|
+
const cloudFindings = [];
|
|
27
|
+
for (const f of findings) {
|
|
28
|
+
if (f.severity !== "crash" && f.severity !== "error")
|
|
29
|
+
continue;
|
|
30
|
+
const specRaw = f.repro?.specPath ? readFileIfExists(f.repro.specPath) : null;
|
|
31
|
+
const patchRaw = f.heal?.status === "healed" && f.heal.patchPath
|
|
32
|
+
? readFileIfExists(f.heal.patchPath)
|
|
33
|
+
: null;
|
|
34
|
+
cloudFindings.push({
|
|
35
|
+
fingerprint: f.fingerprint,
|
|
36
|
+
severity: f.severity,
|
|
37
|
+
type: f.type,
|
|
38
|
+
message: sanitize.text(f.rawMessage),
|
|
39
|
+
location: f.mappedLocation
|
|
40
|
+
? {
|
|
41
|
+
file: sanitize.text(f.mappedLocation.filePath),
|
|
42
|
+
line: f.mappedLocation.line,
|
|
43
|
+
column: f.mappedLocation.column,
|
|
44
|
+
}
|
|
45
|
+
: undefined,
|
|
46
|
+
repro: f.repro
|
|
47
|
+
? {
|
|
48
|
+
verdict: f.repro.verdict,
|
|
49
|
+
rate: f.repro.rate,
|
|
50
|
+
runs: f.repro.runs,
|
|
51
|
+
reproductions: f.repro.reproductions,
|
|
52
|
+
spec: specRaw ? sanitize.text(specRaw) : null,
|
|
53
|
+
}
|
|
54
|
+
: undefined,
|
|
55
|
+
patch: patchRaw ? sanitize.text(patchRaw) : null,
|
|
56
|
+
model_tier: f.heal?.model ?? null,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
schema: "aztrx.run/1",
|
|
61
|
+
sentAt: new Date().toISOString(),
|
|
62
|
+
framework: meta.framework,
|
|
63
|
+
framework_version: meta.version,
|
|
64
|
+
target: sanitize.url(opts.url),
|
|
65
|
+
mode: opts.mode,
|
|
66
|
+
counts: opts.counts,
|
|
67
|
+
findings: cloudFindings,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Fire-and-forget upload. Never rejects; bounded by a short abort. */
|
|
71
|
+
export function dispatchUpload(payload, endpoint, apiKey) {
|
|
72
|
+
const ctrl = new AbortController();
|
|
73
|
+
const timer = setTimeout(() => ctrl.abort(), UPLOAD_TIMEOUT_MS);
|
|
74
|
+
const headers = { "content-type": "application/json" };
|
|
75
|
+
if (apiKey)
|
|
76
|
+
headers["x-api-key"] = apiKey;
|
|
77
|
+
return fetch(`${endpoint}/api/runs`, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers,
|
|
80
|
+
body: JSON.stringify(payload),
|
|
81
|
+
signal: ctrl.signal,
|
|
82
|
+
})
|
|
83
|
+
.then(() => { })
|
|
84
|
+
.catch(() => { })
|
|
85
|
+
.finally(() => clearTimeout(timer));
|
|
86
|
+
}
|
|
87
|
+
/** Build the sanitized payload and detach an upload. A clean run (zero
|
|
88
|
+
* findings) still uploads — the dashboard tracks green runs too. */
|
|
89
|
+
export function submitRun(findings, opts) {
|
|
90
|
+
const endpoint = opts.endpoint ?? DEFAULT_CLOUD_URL;
|
|
91
|
+
const apiKey = opts.apiKey ?? process.env.AZTRX_API_KEY;
|
|
92
|
+
const payload = buildPayload(findings, opts);
|
|
93
|
+
pendingUploads.push(dispatchUpload(payload, endpoint, apiKey));
|
|
94
|
+
}
|
|
95
|
+
/** Await all in-flight uploads (each already bounded). Called right before the
|
|
96
|
+
* CLI exits so a pending `--upload` isn't killed mid-flight; never affects exit
|
|
97
|
+
* code. */
|
|
98
|
+
export async function flushCloud() {
|
|
99
|
+
while (pendingUploads.length) {
|
|
100
|
+
const batch = pendingUploads.splice(0);
|
|
101
|
+
await Promise.allSettled(batch);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { selectorCascade } from "./recorder.js";
|
|
2
|
+
// F6 guard-rail (part 1): never click anything that looks destructive. The
|
|
3
|
+
// full deny-list (config regexps, data-aztrx-skip) is a later pass.
|
|
4
|
+
export const DESTRUCTIVE = /(delete|remove|logout|sign\s?out|log\s?out|pay|checkout|submit\s?order|purchase|buy|удалить|оплатить|выйти|выход)/i;
|
|
5
|
+
export const TEXT_INPUT_TYPES = new Set(["text", "search", "email", "tel", "url", "number", ""]);
|
|
6
|
+
export const SELECTOR = 'a, button, input, select, textarea, [role="button"], [onclick]';
|
|
7
|
+
/**
|
|
8
|
+
* F5-lite — discover interactive elements and act on them, tripping runtime
|
|
9
|
+
* errors for the interceptor to catch. This is the deterministic "walk every
|
|
10
|
+
* button" seed of the Chaos Fuzzer; rage-clicks, form fuzzing, and network
|
|
11
|
+
* jitter come next.
|
|
12
|
+
*/
|
|
13
|
+
export async function walkDom(page, bus, opts = {}) {
|
|
14
|
+
const max = opts.maxActions ?? 100;
|
|
15
|
+
const startUrl = page.url();
|
|
16
|
+
let actions = 0;
|
|
17
|
+
const handles = await page.$$(SELECTOR);
|
|
18
|
+
for (const handle of handles) {
|
|
19
|
+
if (actions >= max)
|
|
20
|
+
break;
|
|
21
|
+
if (page.url() !== startUrl)
|
|
22
|
+
break; // navigated — bail this pass
|
|
23
|
+
const visible = await handle.isVisible().catch(() => false);
|
|
24
|
+
const enabled = await handle.isEnabled().catch(() => false);
|
|
25
|
+
if (!visible || !enabled)
|
|
26
|
+
continue;
|
|
27
|
+
let tag;
|
|
28
|
+
try {
|
|
29
|
+
tag = await handle.evaluate((el) => el.tagName.toLowerCase());
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
continue; // element unreadable mid-query — skip
|
|
33
|
+
}
|
|
34
|
+
let label = "";
|
|
35
|
+
try {
|
|
36
|
+
label = await handle.evaluate((el) => {
|
|
37
|
+
const t = el.innerText ||
|
|
38
|
+
el.getAttribute("aria-label") ||
|
|
39
|
+
el.getAttribute("value") ||
|
|
40
|
+
el.getAttribute("placeholder") ||
|
|
41
|
+
"";
|
|
42
|
+
return t.trim();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
label = ""; // degraded — no label to filter on
|
|
47
|
+
}
|
|
48
|
+
if (DESTRUCTIVE.test(label))
|
|
49
|
+
continue;
|
|
50
|
+
if (tag === "a") {
|
|
51
|
+
const href = (await handle.getAttribute("href")) ?? "";
|
|
52
|
+
if (/^https?:\/\//.test(href) && !href.startsWith(originOf(startUrl)))
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (tag === "input") {
|
|
56
|
+
const type = (await handle.getAttribute("type")) ?? "";
|
|
57
|
+
if (!TEXT_INPUT_TYPES.has(type))
|
|
58
|
+
continue; // skip password/hidden/submit/checkbox/etc.
|
|
59
|
+
}
|
|
60
|
+
const selectors = await selectorCascade(page, handle);
|
|
61
|
+
if (tag === "input" || tag === "textarea") {
|
|
62
|
+
const action = { type: "input", selectors, value: "test", timestamp: Date.now() };
|
|
63
|
+
bus.emit("action", action);
|
|
64
|
+
if (!opts.dryRun)
|
|
65
|
+
await handle.fill("test").catch(() => { });
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
const action = { type: "click", selectors, timestamp: Date.now() };
|
|
69
|
+
bus.emit("action", action);
|
|
70
|
+
if (!opts.dryRun)
|
|
71
|
+
await handle.click({ timeout: 1500 }).catch(() => { });
|
|
72
|
+
}
|
|
73
|
+
actions++;
|
|
74
|
+
await page.waitForTimeout(120);
|
|
75
|
+
}
|
|
76
|
+
return actions;
|
|
77
|
+
}
|
|
78
|
+
export function originOf(url) {
|
|
79
|
+
return url.match(/^https?:\/\/[^/]+/)?.[0] ?? "";
|
|
80
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal typed event bus — the single backbone the modules communicate over,
|
|
3
|
+
* so the Orchestrator can be swapped for a cloud sink later without touching
|
|
4
|
+
* any module (hexagonal, per PRD §2.2).
|
|
5
|
+
*/
|
|
6
|
+
export class EventBus {
|
|
7
|
+
handlers = new Map();
|
|
8
|
+
on(event, handler) {
|
|
9
|
+
let set = this.handlers.get(event);
|
|
10
|
+
if (!set) {
|
|
11
|
+
set = new Set();
|
|
12
|
+
this.handlers.set(event, set);
|
|
13
|
+
}
|
|
14
|
+
set.add(handler);
|
|
15
|
+
return () => {
|
|
16
|
+
set.delete(handler);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
emit(event, payload) {
|
|
20
|
+
this.handlers.get(event)?.forEach((h) => h(payload));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
/**
|
|
4
|
+
* Append-only JSON-lines run log. Every run writes its lifecycle + findings
|
|
5
|
+
* here so a live surface (Local Studio, later the cloud sink) can tail it
|
|
6
|
+
* without coupling to the orchestrator's console output.
|
|
7
|
+
*/
|
|
8
|
+
export class RunLog {
|
|
9
|
+
file;
|
|
10
|
+
constructor(repoRoot) {
|
|
11
|
+
const dir = path.join(repoRoot, ".aztrx");
|
|
12
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
13
|
+
this.file = path.join(dir, "events.jsonl");
|
|
14
|
+
}
|
|
15
|
+
append(event) {
|
|
16
|
+
fs.appendFileSync(this.file, JSON.stringify(event) + "\n", "utf-8");
|
|
17
|
+
}
|
|
18
|
+
/** Truncate so the log represents the latest run only (live-tail semantics). */
|
|
19
|
+
reset() {
|
|
20
|
+
fs.writeFileSync(this.file, "", "utf-8");
|
|
21
|
+
}
|
|
22
|
+
get path() {
|
|
23
|
+
return this.file;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { selectorCascade } from "./recorder.js";
|
|
2
|
+
import { SELECTOR, DESTRUCTIVE, TEXT_INPUT_TYPES, originOf } from "./domWalker.js";
|
|
3
|
+
import { mulberry32 } from "./rng.js";
|
|
4
|
+
const GARBAGE = [
|
|
5
|
+
"<script>alert(1)</script>",
|
|
6
|
+
"'; DROP TABLE users;--",
|
|
7
|
+
"a".repeat(4096),
|
|
8
|
+
"😀".repeat(64),
|
|
9
|
+
String.fromCharCode(0) + "null-byte",
|
|
10
|
+
"NaN",
|
|
11
|
+
"-1",
|
|
12
|
+
"0",
|
|
13
|
+
"99999999999999999999",
|
|
14
|
+
"\n\t\r whitespace",
|
|
15
|
+
"\"'`${}[]()",
|
|
16
|
+
"undefined",
|
|
17
|
+
];
|
|
18
|
+
// Keys most likely to trip state-dependent bugs: Enter submits, Escape closes
|
|
19
|
+
// modals, Backspace/Tab mutate focused fields, arrows scroll/select.
|
|
20
|
+
const KEYS = ["Enter", "Escape", "Backspace", "Tab", "ArrowDown", "ArrowUp", " ", "Delete"];
|
|
21
|
+
function pick(rnd, arr) {
|
|
22
|
+
return arr[Math.floor(rnd() * arr.length)];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* F5 — chaos fuzzer. Seeded random walk with a richer vocabulary than the
|
|
26
|
+
* deterministic walk: clicks (occasionally doubled), hover, keypresses, select
|
|
27
|
+
* option changes, scrolls, and garbage-filled text inputs — tripping runtime
|
|
28
|
+
* errors for the interceptor. Skips anything the destructive deny-list flags,
|
|
29
|
+
* and never follows off-origin links.
|
|
30
|
+
*/
|
|
31
|
+
export async function fuzz(page, bus, opts = {}) {
|
|
32
|
+
const max = opts.maxActions ?? 100;
|
|
33
|
+
const rnd = mulberry32(opts.seed ?? 42);
|
|
34
|
+
const startUrl = page.url();
|
|
35
|
+
let acted = 0;
|
|
36
|
+
for (let i = 0; i < max; i++) {
|
|
37
|
+
if (page.url() !== startUrl) {
|
|
38
|
+
await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
39
|
+
}
|
|
40
|
+
// Occasional page-level scroll — reaches lazy-loaded / intersection-gated UI.
|
|
41
|
+
if (rnd() < 0.08) {
|
|
42
|
+
const dir = rnd() < 0.5 ? "up" : "down";
|
|
43
|
+
const action = { type: "scroll", selectors: [], value: dir, timestamp: Date.now() };
|
|
44
|
+
bus.emit("action", action);
|
|
45
|
+
if (!opts.dryRun)
|
|
46
|
+
await page.mouse.wheel(0, dir === "up" ? -600 : 600).catch(() => { });
|
|
47
|
+
acted++;
|
|
48
|
+
await page.waitForTimeout(40);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const handles = await page.$$(SELECTOR);
|
|
52
|
+
const visible = [];
|
|
53
|
+
for (const h of handles) {
|
|
54
|
+
const v = await h.isVisible().catch(() => false);
|
|
55
|
+
const e = await h.isEnabled().catch(() => false);
|
|
56
|
+
if (v && e)
|
|
57
|
+
visible.push(h);
|
|
58
|
+
}
|
|
59
|
+
if (visible.length === 0)
|
|
60
|
+
break;
|
|
61
|
+
const handle = pick(rnd, visible);
|
|
62
|
+
let tag;
|
|
63
|
+
try {
|
|
64
|
+
tag = await handle.evaluate((el) => el.tagName.toLowerCase());
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue; // element detached/unreadable mid-query — skip
|
|
68
|
+
}
|
|
69
|
+
let label = "";
|
|
70
|
+
try {
|
|
71
|
+
label = await handle.evaluate((el) => {
|
|
72
|
+
const t = el.innerText ||
|
|
73
|
+
el.getAttribute("aria-label") ||
|
|
74
|
+
el.getAttribute("value") ||
|
|
75
|
+
el.getAttribute("placeholder") ||
|
|
76
|
+
"";
|
|
77
|
+
return t.trim();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
label = ""; // degraded — no label to filter on
|
|
82
|
+
}
|
|
83
|
+
if (DESTRUCTIVE.test(label))
|
|
84
|
+
continue;
|
|
85
|
+
if (tag === "a") {
|
|
86
|
+
const href = (await handle.getAttribute("href")) ?? "";
|
|
87
|
+
if (/^https?:\/\//.test(href) && !href.startsWith(originOf(startUrl)))
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// Text inputs — mostly pour garbage in, sometimes hit keys or hover.
|
|
91
|
+
if (tag === "input" || tag === "textarea") {
|
|
92
|
+
const type = (await handle.getAttribute("type")) ?? "";
|
|
93
|
+
if (!TEXT_INPUT_TYPES.has(type))
|
|
94
|
+
continue;
|
|
95
|
+
const selectors = await selectorCascade(page, handle);
|
|
96
|
+
const roll = rnd();
|
|
97
|
+
if (roll < 0.6) {
|
|
98
|
+
const value = pick(rnd, GARBAGE);
|
|
99
|
+
const action = { type: "input", selectors, value, timestamp: Date.now() };
|
|
100
|
+
bus.emit("action", action);
|
|
101
|
+
if (!opts.dryRun)
|
|
102
|
+
await handle.fill(value).catch(() => { });
|
|
103
|
+
}
|
|
104
|
+
else if (roll < 0.85) {
|
|
105
|
+
const key = pick(rnd, KEYS);
|
|
106
|
+
const action = { type: "keypress", selectors, value: key, timestamp: Date.now() };
|
|
107
|
+
bus.emit("action", action);
|
|
108
|
+
if (!opts.dryRun) {
|
|
109
|
+
await handle.focus().catch(() => { });
|
|
110
|
+
await page.keyboard.press(key).catch(() => { });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const action = { type: "hover", selectors, timestamp: Date.now() };
|
|
115
|
+
bus.emit("action", action);
|
|
116
|
+
if (!opts.dryRun)
|
|
117
|
+
await handle.hover().catch(() => { });
|
|
118
|
+
}
|
|
119
|
+
acted++;
|
|
120
|
+
await page.waitForTimeout(60);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Select — actually change the option, a common source of state bugs.
|
|
124
|
+
if (tag === "select") {
|
|
125
|
+
let options = [];
|
|
126
|
+
try {
|
|
127
|
+
options = await handle.evaluate((el) => Array.from(el.options).map((o) => o.value || o.textContent?.trim() || ""));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
continue; // options unreadable — skip this select
|
|
131
|
+
}
|
|
132
|
+
if (options.length > 0) {
|
|
133
|
+
const value = pick(rnd, options);
|
|
134
|
+
const selectors = await selectorCascade(page, handle);
|
|
135
|
+
const action = { type: "select", selectors, value, timestamp: Date.now() };
|
|
136
|
+
bus.emit("action", action);
|
|
137
|
+
if (!opts.dryRun)
|
|
138
|
+
await handle.selectOption(value).catch(() => { });
|
|
139
|
+
acted++;
|
|
140
|
+
await page.waitForTimeout(60);
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// Clickable — mostly click, otherwise hover or keyboard-navigate.
|
|
145
|
+
const selectors = await selectorCascade(page, handle);
|
|
146
|
+
const roll = rnd();
|
|
147
|
+
if (roll < 0.55) {
|
|
148
|
+
const action = { type: "click", selectors, timestamp: Date.now() };
|
|
149
|
+
bus.emit("action", action);
|
|
150
|
+
if (!opts.dryRun) {
|
|
151
|
+
await handle.click({ timeout: 1000 }).catch(() => { });
|
|
152
|
+
if (rnd() < 0.15)
|
|
153
|
+
await handle.click({ timeout: 1000 }).catch(() => { }); // double-click
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else if (roll < 0.85) {
|
|
157
|
+
const action = { type: "hover", selectors, timestamp: Date.now() };
|
|
158
|
+
bus.emit("action", action);
|
|
159
|
+
if (!opts.dryRun)
|
|
160
|
+
await handle.hover().catch(() => { });
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const key = pick(rnd, KEYS);
|
|
164
|
+
const action = { type: "keypress", selectors, value: key, timestamp: Date.now() };
|
|
165
|
+
bus.emit("action", action);
|
|
166
|
+
if (!opts.dryRun) {
|
|
167
|
+
await handle.focus().catch(() => { });
|
|
168
|
+
await page.keyboard.press(key).catch(() => { });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
acted++;
|
|
172
|
+
await page.waitForTimeout(60);
|
|
173
|
+
}
|
|
174
|
+
return acted;
|
|
175
|
+
}
|