comber 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 +18 -0
- package/README.md +732 -0
- package/action.yml +279 -0
- package/comber.config.example.json +64 -0
- package/dist/api/classify.js +99 -0
- package/dist/api/classify.js.map +1 -0
- package/dist/api/driver.js +235 -0
- package/dist/api/driver.js.map +1 -0
- package/dist/api/graphql-driver.js +150 -0
- package/dist/api/graphql-driver.js.map +1 -0
- package/dist/api/graphql.js +115 -0
- package/dist/api/graphql.js.map +1 -0
- package/dist/api/har-driver.js +167 -0
- package/dist/api/har-driver.js.map +1 -0
- package/dist/api/har.js +100 -0
- package/dist/api/har.js.map +1 -0
- package/dist/api/load.js +83 -0
- package/dist/api/load.js.map +1 -0
- package/dist/api/openapi.js +159 -0
- package/dist/api/openapi.js.map +1 -0
- package/dist/api/request.js +141 -0
- package/dist/api/request.js.map +1 -0
- package/dist/api/schema.js +126 -0
- package/dist/api/schema.js.map +1 -0
- package/dist/cli.js +352 -0
- package/dist/cli.js.map +1 -0
- package/dist/comment.js +164 -0
- package/dist/comment.js.map +1 -0
- package/dist/config.js +664 -0
- package/dist/config.js.map +1 -0
- package/dist/connectors/builtin/discord.js +55 -0
- package/dist/connectors/builtin/github-checks.js +53 -0
- package/dist/connectors/builtin/junit.js +70 -0
- package/dist/connectors/builtin/linear.js +58 -0
- package/dist/connectors/builtin/slack.js +60 -0
- package/dist/connectors/builtin/webhook.js +39 -0
- package/dist/connectors/index.js +3 -0
- package/dist/connectors/loader.js +72 -0
- package/dist/connectors/registry.js +28 -0
- package/dist/connectors/types.js +23 -0
- package/dist/core/action-gate.js +48 -0
- package/dist/core/baseline.js +85 -0
- package/dist/core/baseline.js.map +1 -0
- package/dist/core/blind-spots.js +22 -0
- package/dist/core/blind-spots.js.map +1 -0
- package/dist/core/contract-extras.js +1 -0
- package/dist/core/contract-extras.js.map +1 -0
- package/dist/core/contract.js +1 -0
- package/dist/core/contract.js.map +1 -0
- package/dist/core/coverage.js +70 -0
- package/dist/core/coverage.js.map +1 -0
- package/dist/core/crawl.js +229 -0
- package/dist/core/crawl.js.map +1 -0
- package/dist/core/egress.js +128 -0
- package/dist/core/egress.js.map +1 -0
- package/dist/core/ledger.js +41 -0
- package/dist/core/ledger.js.map +1 -0
- package/dist/core/net.js +39 -0
- package/dist/core/pipeline.js +191 -0
- package/dist/core/pipeline.js.map +1 -0
- package/dist/core/plan.js +121 -0
- package/dist/core/plan.js.map +1 -0
- package/dist/core/redact.js +240 -0
- package/dist/core/redact.js.map +1 -0
- package/dist/core/replay.js +263 -0
- package/dist/core/replay.js.map +1 -0
- package/dist/core/report.js +253 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/safety-judge.js +116 -0
- package/dist/core/safety.js +92 -0
- package/dist/core/safety.js.map +1 -0
- package/dist/core/signal-normalize.js +190 -0
- package/dist/core/signal-normalize.js.map +1 -0
- package/dist/core/signals.js +5 -0
- package/dist/core/signals.js.map +1 -0
- package/dist/core/triage.js +104 -0
- package/dist/core/triage.js.map +1 -0
- package/dist/core/types.js +1 -0
- package/dist/core/types.js.map +1 -0
- package/dist/desktop/desktop-profile.js +6 -0
- package/dist/desktop/desktop-profile.js.map +1 -0
- package/dist/desktop/driver.js +433 -0
- package/dist/desktop/driver.js.map +1 -0
- package/dist/desktop/evidence.js +206 -0
- package/dist/desktop/evidence.js.map +1 -0
- package/dist/desktop/macos/accessibility-runner.js +118 -0
- package/dist/desktop/macos/accessibility-runner.js.map +1 -0
- package/dist/desktop/macos/types.js +1 -0
- package/dist/desktop/macos/types.js.map +1 -0
- package/dist/desktop/preflight.js +107 -0
- package/dist/desktop/preflight.js.map +1 -0
- package/dist/desktop/proxy-proof.js +185 -0
- package/dist/desktop/proxy-proof.js.map +1 -0
- package/dist/desktop/view.js +348 -0
- package/dist/desktop/view.js.map +1 -0
- package/dist/desktop/windows/types.js +1 -0
- package/dist/desktop/windows/types.js.map +1 -0
- package/dist/desktop/windows/uia-runner.js +127 -0
- package/dist/desktop/windows/uia-runner.js.map +1 -0
- package/dist/native/apple.js +14 -0
- package/dist/native/apple.js.map +1 -0
- package/dist/native/device-profile.js +6 -0
- package/dist/native/device-profile.js.map +1 -0
- package/dist/native/driver.js +416 -0
- package/dist/native/driver.js.map +1 -0
- package/dist/native/evidence.js +164 -0
- package/dist/native/evidence.js.map +1 -0
- package/dist/native/load.js +9 -0
- package/dist/native/load.js.map +1 -0
- package/dist/native/maestro.js +153 -0
- package/dist/native/maestro.js.map +1 -0
- package/dist/native/preflight.js +72 -0
- package/dist/native/preflight.js.map +1 -0
- package/dist/native/proxy-proof.js +182 -0
- package/dist/native/proxy-proof.js.map +1 -0
- package/dist/native/samsung.js +34 -0
- package/dist/native/samsung.js.map +1 -0
- package/dist/native/signals.js +50 -0
- package/dist/native/signals.js.map +1 -0
- package/dist/native/view.js +139 -0
- package/dist/native/view.js.map +1 -0
- package/dist/profile.js +237 -0
- package/dist/profile.js.map +1 -0
- package/dist/sink.js +134 -0
- package/dist/sink.js.map +1 -0
- package/dist/sinks/file.js +49 -0
- package/dist/sinks/file.js.map +1 -0
- package/dist/sinks/http.js +38 -0
- package/dist/sinks/http.js.map +1 -0
- package/dist/sinks/index.js +5 -0
- package/dist/sinks/index.js.map +1 -0
- package/dist/sinks/multi.js +40 -0
- package/dist/sinks/multi.js.map +1 -0
- package/dist/sinks/paths.js +32 -0
- package/dist/sinks/paths.js.map +1 -0
- package/dist/sinks/record.js +58 -0
- package/dist/sinks/record.js.map +1 -0
- package/dist/sinks/types.js +1 -0
- package/dist/sinks/types.js.map +1 -0
- package/dist/web/breakage.js +210 -0
- package/dist/web/breakage.js.map +1 -0
- package/dist/web/controls.js +102 -0
- package/dist/web/controls.js.map +1 -0
- package/dist/web/driver.js +803 -0
- package/dist/web/driver.js.map +1 -0
- package/dist/web/elements.js +151 -0
- package/dist/web/elements.js.map +1 -0
- package/dist/web/fingerprint.js +13 -0
- package/dist/web/fingerprint.js.map +1 -0
- package/dist/web/forms.js +74 -0
- package/dist/web/forms.js.map +1 -0
- package/dist/web/links.js +194 -0
- package/dist/web/links.js.map +1 -0
- package/dist/web/types.js +1 -0
- package/dist/web/types.js.map +1 -0
- package/package.json +80 -0
- package/runtime/macos-accessibility-runner.js +275 -0
- package/runtime/windows-uia-runner.ps1 +539 -0
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chromium, } from "playwright";
|
|
3
|
+
import { attachBreakageListeners, attachInitScripts, auditDom, runAxe, sessionLivenessProbe } from "./breakage.js";
|
|
4
|
+
import { classify, orderForExhaustion, synthFor, traversalIntentForKind } from "./elements.js";
|
|
5
|
+
import { exerciseControl, isControl } from "./controls.js";
|
|
6
|
+
import { FormTester } from "./forms.js";
|
|
7
|
+
import { LinkChecker } from "./links.js";
|
|
8
|
+
import { EgressGuard } from "../core/egress.js";
|
|
9
|
+
import { restoreFingerprint } from "./fingerprint.js";
|
|
10
|
+
/**
|
|
11
|
+
* Browser-side DOM extraction, as a STRING expression (tsx/esbuild keep-names injects
|
|
12
|
+
* a `__name` helper into serialized closures that doesn't exist in the browser). Widened
|
|
13
|
+
* selector set + classification fields + a structural domPath (stable across re-snapshots)
|
|
14
|
+
* + recursive OPEN-shadow-DOM piercing. Returns { els, blindSpots } where blindSpots counts
|
|
15
|
+
* likely-closed shadow roots this frame couldn't see into. Runs per-frame (snapshot() merges).
|
|
16
|
+
*/
|
|
17
|
+
const EXTRACT_INTERACTIVE = `(() => {
|
|
18
|
+
const SEL = [
|
|
19
|
+
"a[href]","area[href]","button","[role=button]","input","select","textarea","[onclick]",
|
|
20
|
+
"[role=tab]","[role=menuitem]","[role=menuitemcheckbox]","[role=menuitemradio]",
|
|
21
|
+
"[role=switch]","[role=checkbox]","[role=radio]","[role=option]","[role=link]",
|
|
22
|
+
"[role=combobox]","[role=slider]","[role=treeitem]","[role=gridcell]","summary",
|
|
23
|
+
"[aria-haspopup]","[aria-expanded]","[contenteditable='true']",
|
|
24
|
+
"[tabindex]:not([tabindex='-1'])"
|
|
25
|
+
].join(",");
|
|
26
|
+
const isVisible = (el) => {
|
|
27
|
+
if (!el.getBoundingClientRect) return false;
|
|
28
|
+
const r = el.getBoundingClientRect();
|
|
29
|
+
const s = window.getComputedStyle(el);
|
|
30
|
+
return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none" && s.opacity !== "0";
|
|
31
|
+
};
|
|
32
|
+
const domPath = (el) => {
|
|
33
|
+
const segs = []; let node = el, hops = 0;
|
|
34
|
+
while (node && node.nodeType === 1 && hops < 6) {
|
|
35
|
+
const tag = node.tagName.toLowerCase();
|
|
36
|
+
let n = 1, sib = node;
|
|
37
|
+
while ((sib = sib.previousElementSibling)) { if (sib.tagName === node.tagName) n++; }
|
|
38
|
+
segs.unshift(tag + ":nth-of-type(" + n + ")");
|
|
39
|
+
node = node.parentElement; hops++;
|
|
40
|
+
}
|
|
41
|
+
return segs.join(">");
|
|
42
|
+
};
|
|
43
|
+
const out = []; let i = 0; let blindSpots = 0;
|
|
44
|
+
const ORIGIN = location.origin;
|
|
45
|
+
const linkClassOf = (el) => {
|
|
46
|
+
const raw = el.getAttribute("href"); if (raw == null) return undefined;
|
|
47
|
+
if (raw.startsWith("#")) return "hash";
|
|
48
|
+
if (/^(mailto:|tel:|javascript:)/i.test(raw)) return "nonhttp";
|
|
49
|
+
try { return new URL(el.href).origin === ORIGIN ? "internal" : "external"; } catch { return undefined; }
|
|
50
|
+
};
|
|
51
|
+
const process = (el) => {
|
|
52
|
+
if (!isVisible(el)) return;
|
|
53
|
+
const ref = "c" + (i++);
|
|
54
|
+
try { el.setAttribute("data-comber-ref", ref); } catch (e) {}
|
|
55
|
+
const tag = el.tagName.toLowerCase();
|
|
56
|
+
const aLabel = el.getAttribute("aria-label");
|
|
57
|
+
const txt = (el.innerText || el.textContent || "").trim().replace(/\\s+/g, " ");
|
|
58
|
+
const name = (aLabel || txt || el.getAttribute("placeholder") || el.value ||
|
|
59
|
+
el.getAttribute("title") || el.getAttribute("alt") || el.getAttribute("name") || "").slice(0, 120);
|
|
60
|
+
const aExpanded = el.getAttribute("aria-expanded");
|
|
61
|
+
const aPressed = el.getAttribute("aria-pressed");
|
|
62
|
+
// Action-effect inputs: for a submit-capable form control, capture the effective wire
|
|
63
|
+
// method + resolved action URL (the formaction/formmethod override wins over the form's).
|
|
64
|
+
const etype = el.type ? String(el.type).toLowerCase() : "";
|
|
65
|
+
const submitCapable = !!el.form && (etype === "submit" || etype === "image");
|
|
66
|
+
let formMethod, formAction;
|
|
67
|
+
if (submitCapable) {
|
|
68
|
+
formMethod = (el.getAttribute("formmethod") || el.form.getAttribute("method") || "get").toLowerCase();
|
|
69
|
+
const a = el.getAttribute("formaction") || el.form.getAttribute("action") || "";
|
|
70
|
+
try { formAction = a ? new URL(a, location.href).href : location.href; } catch (e) { formAction = location.href; }
|
|
71
|
+
}
|
|
72
|
+
const onclickAttr = (el.getAttribute("onclick") || "").slice(0, 200);
|
|
73
|
+
out.push({
|
|
74
|
+
ref, role: el.getAttribute("role") || tag, name: String(name), href: el.href || undefined,
|
|
75
|
+
tag, inputType: el.type ? String(el.type).toLowerCase() : undefined, domPath: domPath(el),
|
|
76
|
+
ariaExpanded: aExpanded == null ? undefined : aExpanded === "true",
|
|
77
|
+
ariaControls: el.getAttribute("aria-controls") || undefined,
|
|
78
|
+
ariaPressed: aPressed == null ? undefined : aPressed === "true",
|
|
79
|
+
ariaChecked: el.getAttribute("aria-checked") || undefined,
|
|
80
|
+
checkedState: el.checked != null ? String(el.checked) : el.getAttribute("aria-checked") || undefined,
|
|
81
|
+
ariaSelected: el.getAttribute("aria-selected") || undefined,
|
|
82
|
+
valueNow: el.value != null ? String(el.value) : el.getAttribute("aria-valuenow") || undefined,
|
|
83
|
+
optionCount: el.tagName === "SELECT" ? el.options.length : (el.getAttribute("role") === "listbox" || el.getAttribute("role") === "combobox") ? el.querySelectorAll("[role=option]").length : undefined,
|
|
84
|
+
ariaHasPopup: el.getAttribute("aria-haspopup") || undefined,
|
|
85
|
+
group: el.getAttribute("name") || el.getAttribute("aria-owns") || undefined,
|
|
86
|
+
disabled: !!(el.disabled || el.getAttribute("aria-disabled") === "true"),
|
|
87
|
+
contentEditable: el.isContentEditable === true,
|
|
88
|
+
hasOnClick: el.hasAttribute("onclick") || typeof el.onclick === "function",
|
|
89
|
+
tabbable: el.hasAttribute("tabindex") && el.getAttribute("tabindex") !== "-1",
|
|
90
|
+
linkClass: linkClassOf(el),
|
|
91
|
+
formMethod, formAction, onclickText: onclickAttr || undefined,
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
const collect = (root) => {
|
|
95
|
+
try { root.querySelectorAll(SEL).forEach(process); } catch (e) {}
|
|
96
|
+
try {
|
|
97
|
+
root.querySelectorAll("*").forEach((el) => {
|
|
98
|
+
if (el.shadowRoot) { collect(el.shadowRoot); }
|
|
99
|
+
else if (el.tagName && el.tagName.includes("-") && el.childElementCount === 0) { blindSpots++; }
|
|
100
|
+
});
|
|
101
|
+
} catch (e) {}
|
|
102
|
+
};
|
|
103
|
+
collect(document);
|
|
104
|
+
return { els: out, blindSpots };
|
|
105
|
+
})()`;
|
|
106
|
+
/** Browser-side link + anchor-id collection (string body — see EXTRACT note). */
|
|
107
|
+
const COLLECT_LINKS = `(() => {
|
|
108
|
+
const links = [];
|
|
109
|
+
document.querySelectorAll("a[href], area[href]").forEach((el) => {
|
|
110
|
+
const rawHref = el.getAttribute("href");
|
|
111
|
+
if (rawHref == null) return;
|
|
112
|
+
links.push({
|
|
113
|
+
rawHref,
|
|
114
|
+
resolvedHref: el.href || rawHref,
|
|
115
|
+
text: (el.innerText || el.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 80),
|
|
116
|
+
isDownload: el.hasAttribute("download"),
|
|
117
|
+
foundOnUrl: location.href,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
const anchorIds = [];
|
|
121
|
+
document.querySelectorAll("[id]").forEach((el) => { if (el.id) anchorIds.push(el.id); });
|
|
122
|
+
document.querySelectorAll("[name]").forEach((el) => { const n = el.getAttribute("name"); if (n) anchorIds.push(n); });
|
|
123
|
+
return { links, anchorIds };
|
|
124
|
+
})()`;
|
|
125
|
+
/** Browser-side form discovery (string body — tags forms with data-comber-form-ref). */
|
|
126
|
+
const DISCOVER_FORMS = `(() => {
|
|
127
|
+
const isVis = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.display !== "none" && s.visibility !== "hidden"; };
|
|
128
|
+
const forms = []; let fi = 0;
|
|
129
|
+
document.querySelectorAll("form, [role=form]").forEach((f) => {
|
|
130
|
+
if (!isVis(f)) return;
|
|
131
|
+
const fref = "f" + (fi++);
|
|
132
|
+
try { f.setAttribute("data-comber-form-ref", fref); } catch (e) {}
|
|
133
|
+
const fields = [];
|
|
134
|
+
f.querySelectorAll("input,select,textarea").forEach((el) => {
|
|
135
|
+
const type = (el.type || el.tagName).toLowerCase();
|
|
136
|
+
if (["hidden","submit","button","reset","image"].includes(type)) return;
|
|
137
|
+
let label = "";
|
|
138
|
+
if (el.id) { const lab = document.querySelector("label[for='" + (window.CSS && CSS.escape ? CSS.escape(el.id) : el.id) + "']"); if (lab) label = lab.innerText || ""; }
|
|
139
|
+
label = label || el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.getAttribute("name") || "";
|
|
140
|
+
fields.push({ name: el.getAttribute("name") || el.id || "", type, label: String(label).trim().slice(0, 60), required: !!(el.required || el.getAttribute("aria-required") === "true"), pattern: el.getAttribute("pattern") || undefined });
|
|
141
|
+
});
|
|
142
|
+
const submitEl = f.querySelector("[type=submit], button:not([type=button])");
|
|
143
|
+
forms.push({
|
|
144
|
+
formRef: fref, name: f.getAttribute("name") || f.getAttribute("aria-label") || "",
|
|
145
|
+
action: f.getAttribute("action") || undefined, method: (f.getAttribute("method") || "get").toLowerCase(),
|
|
146
|
+
submitText: ((submitEl && (submitEl.innerText || submitEl.value)) || "").trim().slice(0, 40),
|
|
147
|
+
fieldCount: fields.length, requiredCount: fields.filter((x) => x.required).length, fields,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
return forms;
|
|
151
|
+
})()`;
|
|
152
|
+
const WEBACTION_PREFIX = "webaction:";
|
|
153
|
+
const WEBACTION_BASE_SEP = "::";
|
|
154
|
+
const WEBACTION_STEP_SEP = ">>";
|
|
155
|
+
const NOOP_COVERAGE = { skip() { }, bumpExtra() { } };
|
|
156
|
+
function normalizeUrl(url) {
|
|
157
|
+
try {
|
|
158
|
+
const u = new URL(url);
|
|
159
|
+
u.hash = "";
|
|
160
|
+
return u.toString();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return url;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function pathOf(url) {
|
|
167
|
+
try {
|
|
168
|
+
return new URL(url).pathname;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return url;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function egressSignalSignature(method, url) {
|
|
175
|
+
const key = (() => {
|
|
176
|
+
try {
|
|
177
|
+
const parsed = new URL(url);
|
|
178
|
+
parsed.hash = "";
|
|
179
|
+
parsed.search = "";
|
|
180
|
+
parsed.hostname = parsed.hostname.toLowerCase();
|
|
181
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return url;
|
|
185
|
+
}
|
|
186
|
+
})();
|
|
187
|
+
return `egress-${method}-${createHash("sha1").update(key).digest("hex").slice(0, 12)}`;
|
|
188
|
+
}
|
|
189
|
+
function sameOrigin(a, b) {
|
|
190
|
+
try {
|
|
191
|
+
return new URL(a).origin === new URL(b).origin;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** scheme+host+port of a URL, or undefined if unparseable. (No shared util existed.) */
|
|
198
|
+
function originOf(url) {
|
|
199
|
+
try {
|
|
200
|
+
return new URL(url).origin;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function encodeWebActionPath(baseUrl, domPaths) {
|
|
207
|
+
return `${WEBACTION_PREFIX}${encodeURIComponent(baseUrl)}${WEBACTION_BASE_SEP}${domPaths
|
|
208
|
+
.map((domPath) => encodeURIComponent(domPath))
|
|
209
|
+
.join(WEBACTION_STEP_SEP)}`;
|
|
210
|
+
}
|
|
211
|
+
function parseWebActionPath(id) {
|
|
212
|
+
const body = id.slice(WEBACTION_PREFIX.length);
|
|
213
|
+
const sep = body.indexOf(WEBACTION_BASE_SEP);
|
|
214
|
+
const rawBase = sep < 0 ? body : body.slice(0, sep);
|
|
215
|
+
const rawSteps = sep < 0 ? "" : body.slice(sep + WEBACTION_BASE_SEP.length);
|
|
216
|
+
const domPaths = rawSteps
|
|
217
|
+
? rawSteps.split(WEBACTION_STEP_SEP).filter(Boolean).map((step) => decodeURIComponent(step))
|
|
218
|
+
: [];
|
|
219
|
+
return { baseUrl: decodeURIComponent(rawBase), domPaths };
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The first Driver implementation — Playwright over Chromium. It keeps ALL web/DOM/ARIA
|
|
223
|
+
* semantics (the ElKind taxonomy, link/form subsystems, axe, the egress gates) behind the
|
|
224
|
+
* core contract: the core only ever sees an `Address`, a `Surface` of `SurfaceNode`s, and
|
|
225
|
+
* `BreakageSignal`s. Web-internal control/form/link drivers run via `exercise`/`probe`.
|
|
226
|
+
*/
|
|
227
|
+
export class WebDriver {
|
|
228
|
+
opts;
|
|
229
|
+
name = "web";
|
|
230
|
+
// Probes registered with their lifecycle slot. The order reproduces the pre-v2 hard-coded
|
|
231
|
+
// crawl ordering EXACTLY: after-goto → session; after-snapshot → axe, dom, links (in that
|
|
232
|
+
// order); after-exercise → forms. No `finalize`-slot probe (link validation is the
|
|
233
|
+
// separate DriverExtras.finalize). Byte-identical web findings + plan ordering.
|
|
234
|
+
capabilities = {
|
|
235
|
+
screenshot: true,
|
|
236
|
+
probes: [
|
|
237
|
+
{ name: "session", slot: "after-goto" },
|
|
238
|
+
{ name: "axe", slot: "after-snapshot" },
|
|
239
|
+
{ name: "dom", slot: "after-snapshot" },
|
|
240
|
+
{ name: "links", slot: "after-snapshot" },
|
|
241
|
+
{ name: "forms", slot: "after-exercise" },
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
browser;
|
|
245
|
+
ctx;
|
|
246
|
+
page;
|
|
247
|
+
signals = [];
|
|
248
|
+
egressGuard = new EgressGuard();
|
|
249
|
+
cov = NOOP_COVERAGE;
|
|
250
|
+
linkChecker;
|
|
251
|
+
formTester;
|
|
252
|
+
crawlReached = new Set();
|
|
253
|
+
lastSnapshot;
|
|
254
|
+
linkAudit = [];
|
|
255
|
+
axeRuns = 0;
|
|
256
|
+
actionBaseUrl = "";
|
|
257
|
+
actionPath = [];
|
|
258
|
+
constructor(opts) {
|
|
259
|
+
this.opts = opts;
|
|
260
|
+
}
|
|
261
|
+
// -- DriverExtras: core injects its coverage sink so web sub-metrics + form skips land
|
|
262
|
+
// in the same honest ledger the orchestrator owns. --
|
|
263
|
+
attachCoverage(sink) {
|
|
264
|
+
this.cov = sink;
|
|
265
|
+
}
|
|
266
|
+
async start() {
|
|
267
|
+
try {
|
|
268
|
+
await this.startInner();
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
// A failure AFTER launch (newContext/route/newPage can all throw) would otherwise
|
|
272
|
+
// orphan a headless Chromium process — one leaked browser per failed start, compounding
|
|
273
|
+
// in CI. Tear down what we opened before propagating.
|
|
274
|
+
await this.close();
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async startInner() {
|
|
279
|
+
this.browser = await chromium.launch({ headless: this.opts.headless });
|
|
280
|
+
this.ctx = await this.browser.newContext({
|
|
281
|
+
...(this.opts.storageStatePath ? { storageState: this.opts.storageStatePath } : {}),
|
|
282
|
+
// Lets a synthetic-aware app reject the bot server-side too.
|
|
283
|
+
extraHTTPHeaders: { "X-Comber-Synthetic": "1" },
|
|
284
|
+
});
|
|
285
|
+
// EGRESS GATE — registered before any navigation. Aborts destructive-endpoint URLs
|
|
286
|
+
// always; aborts all mutating methods when --block-writes. Every block is observable.
|
|
287
|
+
const egress = this.opts.egressUrlDenylist;
|
|
288
|
+
const blockWrites = this.opts.blockWrites === true;
|
|
289
|
+
await this.ctx.route("**/*", async (route) => {
|
|
290
|
+
const req = route.request();
|
|
291
|
+
const method = req.method().toUpperCase();
|
|
292
|
+
const url = req.url();
|
|
293
|
+
const mutating = method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
294
|
+
// DELETE is an inherently destructive verb — there is no read-DELETE — so it is blocked
|
|
295
|
+
// ALWAYS, independent of --allow-writes, closing the RESTful `DELETE /api/items/5` bypass
|
|
296
|
+
// the accessible-name denylist can't see (an icon-only delete has no name to match).
|
|
297
|
+
const destructiveMethod = method === "DELETE";
|
|
298
|
+
const urlDenied = egress ? egress.test(url) : false;
|
|
299
|
+
const localFixtureAllowed = this.opts.allowPrivateNetworkForTesting === true && sameOrigin(url, this.opts.startUrl);
|
|
300
|
+
const networkDecision = localFixtureAllowed ? { allowed: true } : await this.egressGuard.check(url);
|
|
301
|
+
if (!networkDecision.allowed || urlDenied || destructiveMethod || (blockWrites && mutating)) {
|
|
302
|
+
const reason = !networkDecision.allowed
|
|
303
|
+
? networkDecision.reason ?? "blocked by network egress policy"
|
|
304
|
+
: urlDenied
|
|
305
|
+
? "blocked by destructive URL policy"
|
|
306
|
+
: destructiveMethod
|
|
307
|
+
? "blocked destructive DELETE method"
|
|
308
|
+
: "blocked by write policy";
|
|
309
|
+
this.signals.push({
|
|
310
|
+
kind: "egress-blocked",
|
|
311
|
+
severity: "warn",
|
|
312
|
+
message: `blocked ${method} ${url.slice(0, 140)} (${reason})`,
|
|
313
|
+
url: this.page?.url() ?? url,
|
|
314
|
+
signature: egressSignalSignature(method, url),
|
|
315
|
+
});
|
|
316
|
+
return route.abort("blockedbyclient");
|
|
317
|
+
}
|
|
318
|
+
return route.continue();
|
|
319
|
+
});
|
|
320
|
+
// WebSocket egress gate — ws:// / wss:// connections bypass the HTTP route
|
|
321
|
+
// above, so gate them separately with the same EgressGuard (private/metadata
|
|
322
|
+
// targets blocked). Held until we decide: connect-through if allowed, close if not.
|
|
323
|
+
await this.ctx.routeWebSocket("**/*", async (ws) => {
|
|
324
|
+
const wsUrl = ws.url();
|
|
325
|
+
const localFixtureAllowed = this.opts.allowPrivateNetworkForTesting === true && sameOrigin(wsUrl, this.opts.startUrl);
|
|
326
|
+
const decision = localFixtureAllowed ? { allowed: true } : await this.egressGuard.check(wsUrl);
|
|
327
|
+
if (!decision.allowed) {
|
|
328
|
+
this.signals.push({
|
|
329
|
+
kind: "egress-blocked",
|
|
330
|
+
severity: "warn",
|
|
331
|
+
message: `blocked WS ${wsUrl.slice(0, 140)} (${decision.reason ?? "blocked by network egress policy"})`,
|
|
332
|
+
url: this.page?.url() ?? wsUrl,
|
|
333
|
+
signature: egressSignalSignature("WS", wsUrl),
|
|
334
|
+
});
|
|
335
|
+
ws.close();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
ws.connectToServer();
|
|
339
|
+
});
|
|
340
|
+
this.page = await this.ctx.newPage();
|
|
341
|
+
attachBreakageListeners(this.page, (s) => this.signals.push(s));
|
|
342
|
+
await attachInitScripts(this.page);
|
|
343
|
+
this.linkChecker = new LinkChecker({
|
|
344
|
+
startOrigin: (() => {
|
|
345
|
+
try {
|
|
346
|
+
return new URL(this.opts.startUrl).origin;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return this.opts.startUrl;
|
|
350
|
+
}
|
|
351
|
+
})(),
|
|
352
|
+
globalConcurrency: this.opts.linkConcurrency,
|
|
353
|
+
timeoutMs: this.opts.linkTimeoutMs,
|
|
354
|
+
externalEnabled: this.opts.checkExternalLinks,
|
|
355
|
+
maxExternalLinks: this.opts.maxExternalLinks,
|
|
356
|
+
crawlReachedUrls: this.crawlReached,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
get p() {
|
|
360
|
+
if (!this.page)
|
|
361
|
+
throw new Error("WebDriver not started");
|
|
362
|
+
return this.page;
|
|
363
|
+
}
|
|
364
|
+
// -- addressing --------------------------------------------------------------
|
|
365
|
+
addressOf(url) {
|
|
366
|
+
return { id: normalizeUrl(url), scope: pathOf(url), label: url };
|
|
367
|
+
}
|
|
368
|
+
entryAddress() {
|
|
369
|
+
return this.addressOf(this.opts.startUrl);
|
|
370
|
+
}
|
|
371
|
+
currentAddress() {
|
|
372
|
+
if (this.actionPath.length > 0 && this.lastSnapshot) {
|
|
373
|
+
return this.actionAddressForSnapshot(this.lastSnapshot, this.actionPath);
|
|
374
|
+
}
|
|
375
|
+
return this.addressOf(this.currentUrl());
|
|
376
|
+
}
|
|
377
|
+
inScope(addr) {
|
|
378
|
+
if (addr.id.startsWith(WEBACTION_PREFIX)) {
|
|
379
|
+
return this.isAllowedOrigin(parseWebActionPath(addr.id).baseUrl);
|
|
380
|
+
}
|
|
381
|
+
return this.isAllowedOrigin(addr.id);
|
|
382
|
+
}
|
|
383
|
+
isAllowedOrigin(url) {
|
|
384
|
+
if (!this.opts.sameOriginOnly)
|
|
385
|
+
return true;
|
|
386
|
+
try {
|
|
387
|
+
return this.opts.allowedOrigins.includes(new URL(url).origin);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return sameOrigin(url, this.opts.startUrl);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Derive the core `ActionEffect` from a submit-capable control's form metadata, so the core
|
|
395
|
+
* action-floor gates on what the action DOES (wire method / destination), not its
|
|
396
|
+
* author/attacker-controlled label. Links are the follow-frontier (never exercised) → no
|
|
397
|
+
* effect. A non-form JS button carries no method → no effect here; its writes are still caught
|
|
398
|
+
* at the wire by the egress denylist / `--block-writes`. Returns undefined when nothing is
|
|
399
|
+
* known (so unknown-effect nodes stay allow, keeping this additive).
|
|
400
|
+
*/
|
|
401
|
+
effectOf(el) {
|
|
402
|
+
if (el.kind === "link")
|
|
403
|
+
return undefined;
|
|
404
|
+
const effect = {};
|
|
405
|
+
const method = el.formMethod ? el.formMethod.toUpperCase() : undefined;
|
|
406
|
+
if (method) {
|
|
407
|
+
effect.method = method;
|
|
408
|
+
effect.mutates = /^(POST|PUT|PATCH|DELETE)$/.test(method);
|
|
409
|
+
if (method === "DELETE")
|
|
410
|
+
effect.irreversible = "delete";
|
|
411
|
+
}
|
|
412
|
+
if (el.formAction) {
|
|
413
|
+
const origin = originOf(el.formAction);
|
|
414
|
+
if (origin) {
|
|
415
|
+
effect.destinationOrigin = origin;
|
|
416
|
+
// offOrigin = outside the operator's allowed set (suppressed when --cross-origin is on).
|
|
417
|
+
if (!this.isAllowedOrigin(el.formAction))
|
|
418
|
+
effect.offOrigin = true;
|
|
419
|
+
// crossOrigin = destination ≠ the CURRENT page origin, INDEPENDENT of the allowed set —
|
|
420
|
+
// so a cross-origin WRITE is hard-denied (action-gate rule 1) even when cross-origin
|
|
421
|
+
// crawling is enabled, closing the exfil-POST bypass.
|
|
422
|
+
if (origin !== originOf(this.currentUrl()))
|
|
423
|
+
effect.crossOrigin = true;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (el.onclickText && /\bconfirm\s*\(/i.test(el.onclickText))
|
|
427
|
+
effect.requiresConfirm = true;
|
|
428
|
+
return Object.keys(effect).length > 0 ? effect : undefined;
|
|
429
|
+
}
|
|
430
|
+
async goto(addr) {
|
|
431
|
+
if (addr.id.startsWith(WEBACTION_PREFIX)) {
|
|
432
|
+
const parsed = parseWebActionPath(addr.id);
|
|
433
|
+
await this.gotoUrl(parsed.baseUrl);
|
|
434
|
+
this.actionBaseUrl = parsed.baseUrl;
|
|
435
|
+
this.actionPath = [];
|
|
436
|
+
for (const domPath of parsed.domPaths) {
|
|
437
|
+
await this.clickDomPath(domPath);
|
|
438
|
+
this.actionPath.push(domPath);
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
this.actionBaseUrl = addr.id;
|
|
443
|
+
this.actionPath = [];
|
|
444
|
+
await this.gotoUrl(addr.id);
|
|
445
|
+
}
|
|
446
|
+
async gotoUrl(url) {
|
|
447
|
+
await this.p.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 }).catch((e) => {
|
|
448
|
+
this.signals.push({
|
|
449
|
+
kind: "navigation-failed",
|
|
450
|
+
severity: "fail",
|
|
451
|
+
message: `goto ${url} failed: ${String(e).slice(0, 200)}`,
|
|
452
|
+
url,
|
|
453
|
+
signature: `nav-${url}`,
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
await this.settle();
|
|
457
|
+
}
|
|
458
|
+
/** Best-effort "page is done reacting": network-idle with a short ceiling. */
|
|
459
|
+
async settle() {
|
|
460
|
+
await this.p.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => { });
|
|
461
|
+
}
|
|
462
|
+
/** Web-internal DOM-first snapshot (full InteractiveEl). The core sees only the Surface
|
|
463
|
+
* produced by `snapshot()`; control/form handlers use this richer view. */
|
|
464
|
+
async webSnapshot() {
|
|
465
|
+
const interactive = [];
|
|
466
|
+
let frameBlindSpots = 0;
|
|
467
|
+
const blindSpotDetails = [];
|
|
468
|
+
for (const frame of this.p.frames()) {
|
|
469
|
+
const isMain = frame === this.p.mainFrame();
|
|
470
|
+
try {
|
|
471
|
+
const res = (await frame.evaluate(EXTRACT_INTERACTIVE));
|
|
472
|
+
frameBlindSpots += res.blindSpots;
|
|
473
|
+
const scope = pathOf(frame.url() || this.p.url());
|
|
474
|
+
for (let i = 0; i < res.blindSpots; i++) {
|
|
475
|
+
blindSpotDetails.push({
|
|
476
|
+
kind: "closed-shadow-root",
|
|
477
|
+
severity: "blocker-for-exhaustive",
|
|
478
|
+
scope,
|
|
479
|
+
message: "closed shadow root or opaque custom element could not be inspected",
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
for (const raw of res.els) {
|
|
483
|
+
interactive.push({
|
|
484
|
+
...raw,
|
|
485
|
+
kind: classify(raw),
|
|
486
|
+
selector: `[data-comber-ref="${raw.ref}"]`,
|
|
487
|
+
framePath: isMain ? undefined : [frame.url()],
|
|
488
|
+
inShadow: undefined,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
// cross-origin frame — can't evaluate into it. Honest blind spot.
|
|
494
|
+
if (!isMain) {
|
|
495
|
+
frameBlindSpots += 1;
|
|
496
|
+
blindSpotDetails.push({
|
|
497
|
+
kind: "cross-origin-frame",
|
|
498
|
+
severity: "blocker-for-exhaustive",
|
|
499
|
+
scope: pathOf(frame.url() || this.p.url()),
|
|
500
|
+
message: "cross-origin frame could not be inspected",
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
url: this.p.url(),
|
|
507
|
+
title: await this.p.title().catch(() => ""),
|
|
508
|
+
interactive,
|
|
509
|
+
frameBlindSpots,
|
|
510
|
+
blindSpotDetails,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
/** DISCOVER — the contract surface. Wraps webSnapshot into target-agnostic nodes:
|
|
514
|
+
* the full InteractiveEl rides in `handle`; file-inputs are surfaced as disabled
|
|
515
|
+
* (never drive a native file picker); links are `navigates` (followed, not clicked). */
|
|
516
|
+
async snapshot() {
|
|
517
|
+
const snap = await this.webSnapshot();
|
|
518
|
+
this.lastSnapshot = snap;
|
|
519
|
+
const nodes = orderForExhaustion(snap.interactive).map((el) => ({
|
|
520
|
+
id: el.domPath,
|
|
521
|
+
kind: el.kind,
|
|
522
|
+
label: el.name,
|
|
523
|
+
handle: el,
|
|
524
|
+
group: el.group,
|
|
525
|
+
disabled: Boolean(el.disabled) || el.kind === "file-input",
|
|
526
|
+
intent: traversalIntentForKind(el.kind),
|
|
527
|
+
navigates: el.kind === "link",
|
|
528
|
+
effect: this.effectOf(el),
|
|
529
|
+
}));
|
|
530
|
+
return {
|
|
531
|
+
address: this.surfaceAddress(snap),
|
|
532
|
+
title: snap.title,
|
|
533
|
+
nodes,
|
|
534
|
+
blindSpots: snap.frameBlindSpots,
|
|
535
|
+
blindSpotDetails: snap.blindSpotDetails,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/** EXERCISE — type-correct dispatch over the ElKind in the node's handle. Stateful
|
|
539
|
+
* controls go through the assert-changed handlers; control/dead-* signals ride the
|
|
540
|
+
* ExerciseResult, listener signals drain via drainSignals(). */
|
|
541
|
+
async exercise(node) {
|
|
542
|
+
const el = node.handle;
|
|
543
|
+
const signals = [];
|
|
544
|
+
if (node.intent === "branch") {
|
|
545
|
+
return this.exerciseBranch(el);
|
|
546
|
+
}
|
|
547
|
+
if (isControl(el.kind)) {
|
|
548
|
+
const r = await exerciseControl(this, el);
|
|
549
|
+
signals.push(...r.signals);
|
|
550
|
+
this.cov.bumpExtra("controlsExercised");
|
|
551
|
+
if (r.modalOpened)
|
|
552
|
+
this.cov.bumpExtra("modalsOpened");
|
|
553
|
+
return { signals };
|
|
554
|
+
}
|
|
555
|
+
switch (el.kind) {
|
|
556
|
+
case "text-input":
|
|
557
|
+
await this.fill(el, synthFor(el));
|
|
558
|
+
break;
|
|
559
|
+
case "contenteditable":
|
|
560
|
+
await this.fill(el, "comber-probe");
|
|
561
|
+
break;
|
|
562
|
+
default:
|
|
563
|
+
// button / generic-clickable / other
|
|
564
|
+
await this.act(el);
|
|
565
|
+
}
|
|
566
|
+
return { signals };
|
|
567
|
+
}
|
|
568
|
+
async exerciseBranch(el) {
|
|
569
|
+
const before = this.lastSnapshot ?? (await this.webSnapshot());
|
|
570
|
+
const beforeUrl = before.url;
|
|
571
|
+
const beforeFp = restoreFingerprint(before);
|
|
572
|
+
await this.act(el);
|
|
573
|
+
const after = await this.webSnapshot();
|
|
574
|
+
this.lastSnapshot = after;
|
|
575
|
+
if (after.url !== beforeUrl) {
|
|
576
|
+
return { discovered: [this.addressOf(after.url)] };
|
|
577
|
+
}
|
|
578
|
+
if (restoreFingerprint(after) === beforeFp) {
|
|
579
|
+
return {};
|
|
580
|
+
}
|
|
581
|
+
const nextPath = [...this.actionPath, el.domPath];
|
|
582
|
+
return { discovered: [this.actionAddressForSnapshot(after, nextPath)] };
|
|
583
|
+
}
|
|
584
|
+
// -- web-internal action primitives (ControlActions) -------------------------
|
|
585
|
+
/** Resolve a Locator for an element, honoring iframe context, with a domPath fallback. */
|
|
586
|
+
async resolveLocator(el) {
|
|
587
|
+
let root = this.p;
|
|
588
|
+
if (el.framePath && el.framePath.length) {
|
|
589
|
+
const f = this.p.frames().find((fr) => fr.url() === el.framePath[0]);
|
|
590
|
+
if (f)
|
|
591
|
+
root = f;
|
|
592
|
+
}
|
|
593
|
+
const primary = root.locator(el.selector).first();
|
|
594
|
+
if ((await primary.count().catch(() => 0)) > 0)
|
|
595
|
+
return primary;
|
|
596
|
+
// Stale ref after a re-snapshot/restore: fall back to the structural domPath.
|
|
597
|
+
if (el.domPath) {
|
|
598
|
+
const byPath = root.locator(el.domPath).first();
|
|
599
|
+
if ((await byPath.count().catch(() => 0)) > 0)
|
|
600
|
+
return byPath;
|
|
601
|
+
}
|
|
602
|
+
return primary; // let the action surface the real failure
|
|
603
|
+
}
|
|
604
|
+
async clickDomPath(domPath) {
|
|
605
|
+
await this.p.locator(domPath).first().click({ timeout: 5_000 }).catch((e) => {
|
|
606
|
+
this.signals.push({
|
|
607
|
+
kind: "dead-end-click",
|
|
608
|
+
severity: "warn",
|
|
609
|
+
message: `replay click "${domPath}" had no effect or errored: ${String(e).slice(0, 120)}`,
|
|
610
|
+
url: this.p.url(),
|
|
611
|
+
signature: `dead-replay-${domPath}`.slice(0, 80),
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
await this.settle();
|
|
615
|
+
}
|
|
616
|
+
async act(el) {
|
|
617
|
+
const target = await this.resolveLocator(el);
|
|
618
|
+
await target.click({ timeout: 5_000 }).catch((e) => {
|
|
619
|
+
this.signals.push({
|
|
620
|
+
kind: "dead-end-click",
|
|
621
|
+
severity: "warn",
|
|
622
|
+
message: `click "${el.name}" (${el.kind}) had no effect or errored: ${String(e).slice(0, 120)}`,
|
|
623
|
+
url: this.p.url(),
|
|
624
|
+
signature: `dead-${el.kind}-${el.domPath}`.slice(0, 80),
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
await this.settle();
|
|
628
|
+
}
|
|
629
|
+
async fill(el, value) {
|
|
630
|
+
const target = await this.resolveLocator(el);
|
|
631
|
+
await target.fill(value, { timeout: 5_000 }).catch(() => { });
|
|
632
|
+
await this.settle();
|
|
633
|
+
}
|
|
634
|
+
async selectOption(el, value) {
|
|
635
|
+
const target = await this.resolveLocator(el);
|
|
636
|
+
await target.selectOption(value !== undefined ? value : { index: 1 }, { timeout: 5_000 }).catch(() => { });
|
|
637
|
+
await this.settle();
|
|
638
|
+
}
|
|
639
|
+
async pressKey(key) {
|
|
640
|
+
await this.p.keyboard.press(key).catch(() => { });
|
|
641
|
+
await this.settle();
|
|
642
|
+
}
|
|
643
|
+
async nudge(el) {
|
|
644
|
+
const target = await this.resolveLocator(el);
|
|
645
|
+
await target.press("ArrowRight", { timeout: 5_000 }).catch(() => { });
|
|
646
|
+
await this.settle();
|
|
647
|
+
}
|
|
648
|
+
/** DriverExtras.softReset — the cheap middle restore tier (dismiss a leftover popover). */
|
|
649
|
+
async softReset() {
|
|
650
|
+
await this.pressKey("Escape");
|
|
651
|
+
}
|
|
652
|
+
// -- web-internal link + form primitives -------------------------------------
|
|
653
|
+
async collectLinks() {
|
|
654
|
+
return (await this.p.evaluate(COLLECT_LINKS).catch(() => ({ links: [], anchorIds: [] })));
|
|
655
|
+
}
|
|
656
|
+
async discoverForms() {
|
|
657
|
+
return (await this.p.evaluate(DISCOVER_FORMS).catch(() => []));
|
|
658
|
+
}
|
|
659
|
+
// Refs are controlled "fN" tokens, so building eval strings via template literal is
|
|
660
|
+
// safe AND sidesteps the esbuild __name trap (string contents aren't transpiled).
|
|
661
|
+
async formEmptyProbe(ref) {
|
|
662
|
+
const js = `(() => {
|
|
663
|
+
const f = document.querySelector('[data-comber-form-ref="${ref}"]'); if (!f) return null;
|
|
664
|
+
const els = f.querySelectorAll("input,select,textarea");
|
|
665
|
+
const saved = []; els.forEach((el, i) => saved.push([i, el.value == null ? "" : el.value]));
|
|
666
|
+
els.forEach((el) => {
|
|
667
|
+
const t = (el.type || "").toLowerCase();
|
|
668
|
+
if (el.tagName === "TEXTAREA" || ["text","email","password","search","tel","url","number","date","","time","month"].includes(t)) {
|
|
669
|
+
el.value = ""; el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
return { savedJson: JSON.stringify(saved), checkValidity: f.checkValidity ? f.checkValidity() : true, requiredCount: f.querySelectorAll("[required],[aria-required='true']").length };
|
|
673
|
+
})()`;
|
|
674
|
+
return (await this.p.evaluate(js).catch(() => null));
|
|
675
|
+
}
|
|
676
|
+
async submitForm(ref) {
|
|
677
|
+
const js = `(() => { const f = document.querySelector('[data-comber-form-ref="${ref}"]'); if (f) { if (f.requestSubmit) f.requestSubmit(); else if (f.submit) f.submit(); } })()`;
|
|
678
|
+
await this.p.evaluate(js).catch(() => { });
|
|
679
|
+
await this.settle();
|
|
680
|
+
}
|
|
681
|
+
async formFeedback() {
|
|
682
|
+
const js = `(() => !!document.querySelector('[aria-invalid="true"], [role=alert]:not(:empty), input:invalid, textarea:invalid, select:invalid'))()`;
|
|
683
|
+
return (await this.p.evaluate(js).catch(() => false));
|
|
684
|
+
}
|
|
685
|
+
async formExists(ref) {
|
|
686
|
+
const js = `(() => !!document.querySelector('[data-comber-form-ref="${ref}"]'))()`;
|
|
687
|
+
return (await this.p.evaluate(js).catch(() => false));
|
|
688
|
+
}
|
|
689
|
+
async restoreForm(ref, savedJson) {
|
|
690
|
+
const js = `(() => {
|
|
691
|
+
const f = document.querySelector('[data-comber-form-ref="${ref}"]'); if (!f) return;
|
|
692
|
+
const saved = ${savedJson}; const els = f.querySelectorAll("input,select,textarea");
|
|
693
|
+
saved.forEach((pair) => { const el = els[pair[0]]; if (el) { el.value = pair[1]; el.dispatchEvent(new Event("input", { bubbles: true })); } });
|
|
694
|
+
})()`;
|
|
695
|
+
await this.p.evaluate(js).catch(() => { });
|
|
696
|
+
}
|
|
697
|
+
/** DriverExtras.outboundAddresses — in-scope, non-denylisted link destinations from the
|
|
698
|
+
* most recent surface, so the frontier expands by FOLLOWING links (never clicking them).
|
|
699
|
+
* The web destructive-href + name denylists are enforced here (where the href lives). */
|
|
700
|
+
outboundAddresses() {
|
|
701
|
+
const snap = this.lastSnapshot;
|
|
702
|
+
if (!snap)
|
|
703
|
+
return [];
|
|
704
|
+
// domPath order matches the pre-split frontier (links sort last in orderForExhaustion,
|
|
705
|
+
// then by domPath), so traversal order under a tight --max-states cap is preserved.
|
|
706
|
+
const links = snap.interactive
|
|
707
|
+
.filter((el) => el.kind === "link")
|
|
708
|
+
.sort((a, b) => (a.domPath < b.domPath ? -1 : a.domPath > b.domPath ? 1 : 0));
|
|
709
|
+
const out = [];
|
|
710
|
+
for (const el of links) {
|
|
711
|
+
if (!el.href)
|
|
712
|
+
continue;
|
|
713
|
+
if (this.opts.denylist.test(el.name || ""))
|
|
714
|
+
continue;
|
|
715
|
+
if (this.opts.destructiveHrefDenylist.test(el.href))
|
|
716
|
+
continue;
|
|
717
|
+
if (el.linkClass === "internal" || (!el.linkClass && /^https?:/i.test(el.href))) {
|
|
718
|
+
const addr = this.addressOf(el.href);
|
|
719
|
+
if (this.isAllowedOrigin(addr.id))
|
|
720
|
+
out.push(addr);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return out;
|
|
724
|
+
}
|
|
725
|
+
/** A named, capability-gated discovery pass. Collect-during for links; the validate-after
|
|
726
|
+
* post-pass is DriverExtras.finalize. axe self-limits to the first 8 states (bounded cost). */
|
|
727
|
+
async probe(phase) {
|
|
728
|
+
switch (phase) {
|
|
729
|
+
case "session": {
|
|
730
|
+
const s = await sessionLivenessProbe(this.p);
|
|
731
|
+
return s ? [s] : [];
|
|
732
|
+
}
|
|
733
|
+
case "dom":
|
|
734
|
+
return auditDom(this.p);
|
|
735
|
+
case "axe": {
|
|
736
|
+
if (this.axeRuns >= 8)
|
|
737
|
+
return [];
|
|
738
|
+
this.axeRuns++;
|
|
739
|
+
return runAxe(this.p);
|
|
740
|
+
}
|
|
741
|
+
case "links": {
|
|
742
|
+
// collect-during (every new state); validation happens in finalize().
|
|
743
|
+
this.crawlReached.add(normalizeUrl(this.currentUrl()));
|
|
744
|
+
if (this.linkChecker)
|
|
745
|
+
this.linkChecker.ingest(await this.collectLinks(), this.currentUrl());
|
|
746
|
+
return [];
|
|
747
|
+
}
|
|
748
|
+
case "forms": {
|
|
749
|
+
this.formTester ??= new FormTester(this, this.cov);
|
|
750
|
+
const out = [];
|
|
751
|
+
await this.formTester.testState(this.currentUrl(), (s) => out.push(s));
|
|
752
|
+
return out;
|
|
753
|
+
}
|
|
754
|
+
default:
|
|
755
|
+
return [];
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/** DriverExtras.finalize — validate EVERY collected link over the cookie-inheriting
|
|
759
|
+
* request context (server-side, so CORS is a non-issue). Must run before close(). */
|
|
760
|
+
async finalize() {
|
|
761
|
+
if (!this.linkChecker || !this.ctx)
|
|
762
|
+
return [];
|
|
763
|
+
this.linkAudit = await this.linkChecker.validateAll(this.ctx.request).catch(() => []);
|
|
764
|
+
this.cov.bumpExtra("linksChecked", this.linkAudit.filter((r) => r.method !== "skip").length);
|
|
765
|
+
this.cov.bumpExtra("linksBroken", this.linkAudit.filter((r) => !r.ok).length);
|
|
766
|
+
return this.linkChecker.toFindings(this.linkAudit);
|
|
767
|
+
}
|
|
768
|
+
/** DriverExtras.auditExtra — structured per-driver data for the report/result.json. */
|
|
769
|
+
auditExtra() {
|
|
770
|
+
return { linkAudit: this.linkAudit };
|
|
771
|
+
}
|
|
772
|
+
async screenshot() {
|
|
773
|
+
return this.p.screenshot({ fullPage: false }).catch(() => Buffer.alloc(0));
|
|
774
|
+
}
|
|
775
|
+
currentUrl() {
|
|
776
|
+
return this.page?.url() ?? "";
|
|
777
|
+
}
|
|
778
|
+
surfaceAddress(snap) {
|
|
779
|
+
if (this.actionPath.length === 0)
|
|
780
|
+
return this.addressOf(snap.url);
|
|
781
|
+
return this.actionAddressForSnapshot(snap, this.actionPath);
|
|
782
|
+
}
|
|
783
|
+
actionAddressForSnapshot(snap, domPaths) {
|
|
784
|
+
const baseUrl = this.actionBaseUrl || snap.url;
|
|
785
|
+
const fingerprint = restoreFingerprint(snap);
|
|
786
|
+
const scope = `${pathOf(baseUrl)}#branch-${fingerprint.slice(0, 8)}`;
|
|
787
|
+
const label = `${snap.url} :: ${snap.title || "branch"}`;
|
|
788
|
+
return {
|
|
789
|
+
id: encodeWebActionPath(baseUrl, domPaths),
|
|
790
|
+
scope,
|
|
791
|
+
dedupeKey: `webbranch:${fingerprint}`,
|
|
792
|
+
label,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
drainSignals() {
|
|
796
|
+
const out = this.signals;
|
|
797
|
+
this.signals = [];
|
|
798
|
+
return out;
|
|
799
|
+
}
|
|
800
|
+
async close() {
|
|
801
|
+
await this.browser?.close().catch(() => { });
|
|
802
|
+
}
|
|
803
|
+
}
|