quicke2e 0.2.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/src/redact.mjs ADDED
@@ -0,0 +1,89 @@
1
+ // Declared redaction, shared by the loop (every snapshot) and discover (the map on disk).
2
+ // redact: [".backup-code", "#card-picker", /recovery code \S+/i]
3
+ // Strings are CSS selectors, RegExps are text patterns. Applied AT THE SOURCE, before any label,
4
+ // title, criterion, trace or map entry is built from the page.
5
+ //
6
+ // Selectors: every matching element -- in the document, open shadow roots and same-origin iframes --
7
+ // gives up its text; that text is then cut out of every page-derived string (the label of a button
8
+ // that CONTAINS it, a name resolved through aria-labelledby, option labels), and candidates inside a
9
+ // match are renamed outright. The cut is separator-flexible ("EEEE-5555-6666" also matches
10
+ // "EEEE 5555 6666"); whole-token for ordinary texts, substring for long letter+digit codes; and only
11
+ // for texts of 4+ letters/digits: a 2-character match ("42") cut every "42" on the page, prices
12
+ // included (audit r9). A short match (a 3-digit CVC) is still renamed as an element; its text is
13
+ // just not hunted elsewhere.
14
+ // Regexes: applied to raw labels, titles and URLs. Text outside any element (the page title) can
15
+ // only be declared this way -- a selector cannot know that text is sensitive.
16
+
17
+ const esc = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18
+
19
+ export function textPatterns(texts) {
20
+ const out = [];
21
+ for (const t of new Set(texts)) {
22
+ const chars = [...t].filter((c) => /[A-Za-z0-9]/.test(c));
23
+ if (chars.length < 4) continue;
24
+ const body = chars.map(esc).join("[\\s\\-./:]*");
25
+ // A long text mixing letters and digits ("AB12CD34") cannot collide with ordinary words, so it
26
+ // is cut as a SUBSTRING, even glued to other text ("Code:AB12CD34x", audit r10). Shorter or
27
+ // single-class texts keep word boundaries -- that is what keeps "42" out of "€42" and "4217".
28
+ const strong = chars.length >= 8 && /[A-Za-z]/.test(chars.join("")) && /\d/.test(chars.join(""));
29
+ out.push(new RegExp(strong ? body : `(?<![A-Za-z0-9])${body}(?![A-Za-z0-9])`, "gi"));
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export async function buildRedactor(page, redact = []) {
35
+ const sels = redact.filter((x) => typeof x === "string");
36
+ const res = redact.filter((x) => x instanceof RegExp)
37
+ .map((r) => new RegExp(r.source, r.flags.includes("g") ? r.flags : r.flags + "g"));
38
+ let texts = [], inside = new Set();
39
+ if (sels.length) {
40
+ const r = await page.evaluate((q) => {
41
+ const found = [], nodes = [];
42
+ const walk = (root) => {
43
+ for (const el of root.querySelectorAll("*")) {
44
+ if (el.matches(q)) found.push(el);
45
+ if (el.shadowRoot) walk(el.shadowRoot);
46
+ if (el.tagName === "IFRAME") { let d = null; try { d = el.contentDocument; } catch {} if (d) walk(d); }
47
+ }
48
+ };
49
+ walk(document);
50
+ const text = (el) => (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim();
51
+ for (const el of found) for (const n of [el, ...el.querySelectorAll("[data-jev-node]")])
52
+ if (n.getAttribute("data-jev-node")) nodes.push(n.getAttribute("data-jev-node"));
53
+ // a <select>'s text is all its options joined, so each option is collected on its own too
54
+ const texts = found.flatMap((el) => [text(el), ...[...el.querySelectorAll("option")].map(text)]);
55
+ return { texts, nodes };
56
+ }, sels.join(",")).catch(() => ({ texts: [], nodes: [] }));
57
+ texts = r.texts; inside = new Set(r.nodes);
58
+ }
59
+ const pats = [...textPatterns(texts), ...res];
60
+ const cut = (t) => (t == null ? t : pats.reduce((x, re) => x.replace(re, "<redacted>"), String(t)));
61
+ return {
62
+ active: pats.length > 0 || inside.size > 0,
63
+ cut,
64
+ url: (u) => (u == null ? u : res.reduce((x, re) => x.replace(re, "<redacted>"), String(u))),
65
+ isInside: (node) => inside.has(node),
66
+ };
67
+ }
68
+
69
+ // Apply a redactor to a snapshot in place.
70
+ export function redactSnapshot(snap, r) {
71
+ if (!r.active) return;
72
+ snap.title = r.cut(snap.title);
73
+ snap.text = r.cut(snap.text);
74
+ for (const a of snap.actions) {
75
+ if (r.isInside(a.node) || r.isInside(a.selectNode)) {
76
+ a.label = "<redacted>"; if (a.optionLabel) a.optionLabel = "<redacted>";
77
+ if (a.kind === "state" || a.kind === "fill") a.value = "<redacted>";
78
+ }
79
+ a.label = r.cut(a.label); if (a.optionLabel) a.optionLabel = r.cut(a.optionLabel);
80
+ if (a.selectLabel) a.selectLabel = r.cut(a.selectLabel);
81
+ if (typeof a.value === "string") a.value = r.cut(a.value);
82
+ }
83
+ }
84
+
85
+ // Parse CLI --redact values: "/pattern/flags" is a RegExp, anything else a CSS selector.
86
+ export function parseRedactArg(v) {
87
+ const m = /^\/(.+)\/([a-z]*)$/.exec(v);
88
+ return m ? new RegExp(m[1], m[2]) : v;
89
+ }
package/src/route.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // route: use a discovered MAP to get the loop to the right page before it starts deciding.
2
+ //
3
+ // 1. Jev picks the TARGET page pattern for the goal -- one typed choice over the map's pages.
4
+ // 2. If that pattern has ids (/events/:id), Jev picks the instance by the label of the link
5
+ // that leads there ("Summer Jam") -- a second typed choice.
6
+ // 3. Code finds the route (BFS over the map's edges) and walks it by role + label. No model.
7
+ // The loop then runs as usual from the target page. The model never navigates blind; it only
8
+ // makes the local choices it is good at.
9
+ import { choose, settle, ARIA } from "./loop.mjs";
10
+
11
+ const RULES = "Pick the page this goal needs: the page that holds the form or control the goal " +
12
+ "uses first, or the page the goal asks to reach. Pick STAY if the start page already has it.";
13
+ const MAX = 48; // under the 52-letter ceiling, with room for STAY
14
+
15
+ function describe(p) {
16
+ const bits = [p.pattern, p.headings?.[0] || p.title || ""];
17
+ for (const f of p.forms || []) {
18
+ const fields = f.fields.map((x) => x.label).slice(0, 5).join(", ");
19
+ bits.push(`form ${f.submit?.label || f.name || ""}${fields ? ` (${fields})` : ""}`);
20
+ }
21
+ return bits.filter(Boolean).join(" | ").slice(0, 160);
22
+ }
23
+
24
+ // Cheap lexical prefilter when a map has more pages than the engine can take in one choice.
25
+ function prefilter(pages, goal) {
26
+ if (pages.length <= MAX) return pages;
27
+ const words = new Set(goal.toLowerCase().match(/[a-z]{3,}/g) || []);
28
+ const score = (p) => (describe(p).toLowerCase().match(/[a-z]{3,}/g) || []).filter((w) => words.has(w)).length;
29
+ return [...pages].sort((a, b) => score(b) - score(a)).slice(0, MAX);
30
+ }
31
+
32
+ export async function pickTarget(map, goal, engine, startPattern, inputs, redact = []) {
33
+ const pages = prefilter(map.pages, goal);
34
+ const criteria = { STAY: `stay on the start page ${startPattern}` };
35
+ pages.forEach((p, i) => { criteria[`PAGE:${i}`] = describe(p); });
36
+ const state = { app: map.base.replace(/^https?:\/\//, ""), start: startPattern, pages: pages.length };
37
+ const d = await choose({ state, criteria, goal, rules: RULES, engine, inputs, redact });
38
+ const out = { cost: d.cost || 0, ms: d.ms, decisions: [{ choice: d.choice, confidence: d.confidence }] };
39
+ if (d.choice === "STAY" || !String(d.choice).startsWith("PAGE:")) return { ...out, target: null };
40
+ const page = pages[Number(d.choice.split(":")[1])];
41
+ // One instance, or an id-free pattern: done.
42
+ const into = map.edges.filter((e) => e.to === page.pattern && e.via.label);
43
+ const byUrl = new Map();
44
+ for (const e of into) if (!byUrl.has(e.toUrl)) byUrl.set(e.toUrl, e);
45
+ if (!page.pattern.includes(":id") || byUrl.size <= 1)
46
+ return { ...out, target: page.urls[0], pattern: page.pattern };
47
+ const inst = [...byUrl.values()].slice(0, MAX);
48
+ const c2 = {};
49
+ inst.forEach((e, i) => { c2[`URL:${i}`] = `${e.via.label} -> ${new URL(e.toUrl).pathname}`; });
50
+ const d2 = await choose({ state: { ...state, pattern: page.pattern }, criteria: c2, goal,
51
+ rules: "Pick the item this goal is about.", engine, inputs, redact });
52
+ out.cost += d2.cost || 0; out.ms += d2.ms;
53
+ out.decisions.push({ choice: d2.choice, confidence: d2.confidence });
54
+ const e = inst[Number(String(d2.choice).split(":")[1])] || inst[0];
55
+ return { ...out, target: e.toUrl, pattern: page.pattern };
56
+ }
57
+
58
+ // Shortest walk over concrete URLs. Redirect edges are free hops.
59
+ export function findRoute(map, fromUrl, toUrl) {
60
+ const strip = (u) => u.replace(/#.*$/, "");
61
+ fromUrl = strip(fromUrl); toUrl = strip(toUrl);
62
+ if (fromUrl === toUrl) return [];
63
+ const prev = new Map([[fromUrl, null]]);
64
+ const q = [fromUrl];
65
+ while (q.length) {
66
+ const u = q.shift();
67
+ for (const e of map.edges) {
68
+ if (strip(e.fromUrl) !== u || prev.has(strip(e.toUrl))) continue;
69
+ if (e.via.op === "submit") continue; // routing never submits a form: that is the test's job
70
+ prev.set(strip(e.toUrl), e);
71
+ if (strip(e.toUrl) === toUrl) {
72
+ const path = [];
73
+ for (let x = toUrl; prev.get(x); x = strip(prev.get(x).fromUrl)) path.unshift(prev.get(x));
74
+ return path;
75
+ }
76
+ q.push(strip(e.toUrl));
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ // Walk the route by role + label, like a user. Falls back to a direct goto if a hop fails.
83
+ export async function walk(page, route, target) {
84
+ const hops = [];
85
+ for (const e of route || []) {
86
+ if (e.via.op === "redirect") continue;
87
+ const before = page.url();
88
+ try {
89
+ const role = ARIA[e.via.role] || "link", name = e.via.label;
90
+ try { await page.getByRole(role, { name, exact: true }).first().click({ timeout: 1500 }); }
91
+ catch { await page.getByRole(role, { name }).first().click({ timeout: 1500 }); }
92
+ await page.waitForURL((u) => u.toString() !== before, { timeout: 3000 }).catch(() => {});
93
+ await settle(page);
94
+ hops.push({ role, label: e.via.label, ok: true });
95
+ } catch { hops.push({ role: e.via.role, label: e.via.label, ok: false }); break; }
96
+ }
97
+ return hops;
98
+ }
package/src/secret.mjs ADDED
@@ -0,0 +1,94 @@
1
+ // ONE definition of "looks like a secret", shared by the loop, the in-page snapshot and codegen.
2
+ // A key or a label matching this never has its value sent to an engine or written into a file.
3
+ export const SECRET_SRC = String.raw`pass|pwd|\bpw\b|secret|token|api.?key|access.?key|license.?key|\bpin\b|cvc|cvv|card|iban|otp|2fa|\bssn\b|social.?security|\bauth\b|bearer|credential|private|recovery|mnemonic|seed.?phrase|account.?number|session`;
4
+ export const SECRET = new RegExp(SECRET_SRC, "i");
5
+ export const isSecretKey = (k) => SECRET.test(k || "");
6
+
7
+ // A STRONG secret (10+ chars, 2+ character classes) cannot be confused with page words, so it is
8
+ // scrubbed fuzzily everywhere. A WEAK one ("admin", "password", "test1234" -- typical seed values) is
9
+ // indistinguishable from ordinary labels: scrubbing it from page text turned the field label
10
+ // "Password" and the links "Admin"/"Administrators" into "<password>" (audit FP1/FP2), broke runs and
11
+ // emitted `name: /^/`. Weak secrets are therefore scrubbed only from the SPEC's own text (goal, done).
12
+ export function isStrong(v) {
13
+ const t = String(v ?? "");
14
+ const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((r) => r.test(t)).length;
15
+ const digits = (t.match(/\d/g) || []).length;
16
+ // mixed classes, or a long number (card, account, recovery code) -- neither looks like a page word
17
+ return (t.length >= 10 && classes >= 2) || (/^[\d\s-]+$/.test(t) && digits >= 10);
18
+ }
19
+ export function weakSecretKeys(inputs) {
20
+ return Object.entries(inputs || {}).filter(([k, v]) => isSecretKey(k) && v != null && String(v).length >= 3 && !isStrong(v)).map(([k]) => k);
21
+ }
22
+
23
+ const esc = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
24
+ const classesOf = (t) => [/[a-z]/i, /\d/, /[^A-Za-z0-9\s]/].filter((r) => r.test(t)).length;
25
+ function secretPatterns(inputs, { weak = false, weakKeys = [], keepWeak = [] } = {}) {
26
+ const out = [], partial = []; // partial forms run LAST, after the whole value had its chance
27
+ for (const [k, v] of Object.entries(inputs || {})) {
28
+ if (!isSecretKey(k) || v == null) continue;
29
+ const val = String(v).trim().replace(/\s+/g, " ");
30
+ if (val.length < 3) continue;
31
+ if (!isStrong(val)) {
32
+ // weak: only the exact value as a whole word -- in spec text always; in page text only for
33
+ // `weakKeys`, the keys the loop has seen ECHOED (typed, and not on any page before typing)
34
+ if ((weak && !keepWeak.includes(k)) || weakKeys.includes(k)) out.push({ k, re: new RegExp(`(?<![A-Za-z0-9])${esc(val)}(?![A-Za-z0-9])`, "gi") });
35
+ continue;
36
+ }
37
+ // strong: whole value, plus an 8-char head for a TRUNCATED echo -- but only when the head itself
38
+ // mixes letters with digits/symbols ("Password" alone would collide with the field label)
39
+ const forms = [val];
40
+ const head = val.slice(0, 8);
41
+ if (val.length > 8 && classesOf(head) >= 2) forms.push(head);
42
+ // A card is commonly DISPLAYED masked, by its last 4 digits ("ending 6789", "•••• 6789",
43
+ // "x6789"). That masked form is scrubbed. A bare 4-digit number is NOT: the audit's card ending
44
+ // 2026 turned "2026 report" into "<card number> report" and the flow picked 2025 (r7).
45
+ const digits = val.replace(/\D/g, "");
46
+ if (/^[\d\s-]+$/.test(val) && digits.length >= 12)
47
+ partial.push({ k, re: new RegExp(`((?:ending(?:\\s+in)?|ends\\s+in|last\\s*(?:4|four)(?:\\s+digits)?)\\s*[:#-]?\\s*|[•*·xX]{1,}[\\s-]?)${digits.slice(-4)}(?!\\d)`, "gi"), keepPrefix: true });
48
+ const formEnc = (t) => new URLSearchParams({ x: t }).toString().slice(2);
49
+ for (const f of forms) for (const form of new Set([f, encodeURIComponent(f), formEnc(f)])) {
50
+ // separator-flexible between every character (a grouped card number "4111-2222-...")
51
+ const chars = [...form.replace(/[\s+]+/g, "")];
52
+ out.push({ k, re: new RegExp(chars.map(esc).join("[\\s\\-./+]*") + "[^\\s&?#\"'<>]*", "gi") });
53
+ }
54
+ }
55
+ return out.concat(partial);
56
+ }
57
+ // Placeholders are applied through sentinels, so a later pattern can never match inside an earlier
58
+ // replacement ("<password>").
59
+ function apply(text, pats) {
60
+ let t = String(text);
61
+ pats.forEach(({ re, keepPrefix }, i) => {
62
+ t = t.replace(re, keepPrefix ? (m, pre) => `${pre}\u0001${i}\u0001` : `\u0001${i}\u0001`);
63
+ });
64
+ return t.replace(/\u0001(\d+)\u0001/g, (_, i) => `<${pats[Number(i)].k}>`);
65
+ }
66
+ // Page-derived text: strong secrets only.
67
+ export function scrubText(text, inputs, opts) { return apply(text ?? "", secretPatterns(inputs, opts)); }
68
+ // The spec's own words (goal, done): strong AND weak secrets.
69
+ export function scrubSpecText(text, inputs, { keepWeak = [] } = {}) { return apply(text ?? "", secretPatterns(inputs, { weak: true, keepWeak })); }
70
+ export function containsSecret(text, inputs, opts) { return scrubText(text, inputs, opts) !== String(text ?? ""); }
71
+ export function scrub(payload, inputs, opts) {
72
+ const pats = secretPatterns(inputs, opts);
73
+ if (!pats.length) return payload;
74
+ const walk = (x) => typeof x === "string" ? apply(x, pats)
75
+ : Array.isArray(x) ? x.map(walk)
76
+ : x && typeof x === "object" ? Object.fromEntries(Object.entries(x).map(([a, b]) => [walk(a), walk(b)])) : x;
77
+ return walk(payload);
78
+ }
79
+
80
+ // DECLARED REDACTION. Page content reaches the engine by design -- the engine chooses by what the
81
+ // page says, like any browser agent. Content that must never leave the page is DECLARED in the spec:
82
+ // redact: [/recovery code \S+/i, /\b\d{4}-[A-Z0-9]{4,}\b/] // text patterns
83
+ // redact: ["[data-sensitive]", ".backup-code"] // CSS selectors (see loop.mjs)
84
+ // An automatic "looks like a code" rule was built and measured first (audit r7): it broke four flows
85
+ // that worked (newest order, version picker, SKU picker, a year next to a card) because codes carry
86
+ // ordering and identity, and it still leaked four other code shapes. A declaration has neither problem.
87
+ export function redactPatterns(payload, patterns = []) {
88
+ const res = patterns.filter((p) => p instanceof RegExp).map((p) => new RegExp(p.source, p.flags.includes("g") ? p.flags : p.flags + "g"));
89
+ if (!res.length) return payload;
90
+ const walk = (x) => typeof x === "string" ? res.reduce((t, re) => t.replace(re, "<redacted>"), x)
91
+ : Array.isArray(x) ? x.map(walk)
92
+ : x && typeof x === "object" ? Object.fromEntries(Object.entries(x).map(([a, b]) => [walk(a), walk(b)])) : x;
93
+ return walk(payload);
94
+ }
@@ -0,0 +1,72 @@
1
+ // Injected into the page. The text a USER perceives, which is what `expect` asserts against.
2
+ //
3
+ // document.body.innerText is not that: a closed <select> contributes EVERY option, so
4
+ // expect:["Growth"] is true before anything is chosen (fixtures/select.html), and Radix's
5
+ // visually-hidden native <select> put every client's name on a page nobody had touched. It also
6
+ // cannot see a typed input value, and it does not enter shadow roots or iframes.
7
+ //
8
+ // Rules: text a sighted user sees, in the flat tree (shadow roots through their slots, same-origin
9
+ // iframes); form controls contribute nothing (assert them with expectState); hidden, aria-hidden,
10
+ // transparent, clipped-to-nothing and off-page text is skipped; spaces around block boxes only, so
11
+ // "€1<span>21</span>" stays "€121".
12
+ () => {
13
+ const out = [];
14
+ const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "OPTION", "OPTGROUP", "DATALIST", "HEAD"]);
15
+ // A sighted user's view: not opacity 0, not visibility hidden, not a zero-size clipping box
16
+ // (a collapsed panel, an sr-only span), not scrolled off the page's top/left edge, not font-size 0.
17
+ // (audit r7: all four false-passed "Payment confirmed" while "Payment failed" was on screen.)
18
+ const shown = (el) => {
19
+ if (el.getAttribute && el.getAttribute("aria-hidden") === "true") return false;
20
+ if (el.hasAttribute && el.hasAttribute("hidden")) return false;
21
+ if (el.checkVisibility && !el.checkVisibility({ visibilityProperty: true, opacityProperty: true })) return false;
22
+ const cs = (el.ownerDocument.defaultView || window).getComputedStyle(el);
23
+ if (parseFloat(cs.fontSize) === 0) return false;
24
+ if (cs.display === "contents") return true;
25
+ const r = el.getBoundingClientRect();
26
+ const sx = (el.ownerDocument.defaultView || window).scrollX, sy = (el.ownerDocument.defaultView || window).scrollY;
27
+ if (r.right + sx <= 0 || r.bottom + sy <= 0) return false;
28
+ const clips = cs.overflow !== "visible" || cs.overflowX !== "visible" || cs.overflowY !== "visible";
29
+ if (clips && (r.width <= 1 || r.height <= 1)) return false;
30
+ return true;
31
+ };
32
+ const inline = (el) => {
33
+ const d = (el.ownerDocument.defaultView || window).getComputedStyle(el).display;
34
+ return d.startsWith("inline") || d === "contents";
35
+ };
36
+ const kids = (el) => el.tagName === "SLOT"
37
+ ? (el.assignedNodes({ flatten: true }).length ? el.assignedNodes({ flatten: true }) : [...el.childNodes])
38
+ : el.shadowRoot ? [...el.shadowRoot.childNodes] : [...el.childNodes];
39
+ // CSS text-transform is part of what a user sees ("EVENT"), as innerText also reflects.
40
+ const transform = (text, el) => {
41
+ const tt = el ? (el.ownerDocument.defaultView || window).getComputedStyle(el).textTransform : "none";
42
+ if (tt === "uppercase") return text.toUpperCase();
43
+ if (tt === "lowercase") return text.toLowerCase();
44
+ if (tt === "capitalize") return text.replace(/(^|\s)(\S)/g, (m, a, b) => a + b.toUpperCase());
45
+ return text;
46
+ };
47
+ const walk = (node) => {
48
+ if (node.nodeType === 3) { out.push(transform(node.data, node.parentElement)); return; }
49
+ if (node.nodeType !== 1) return;
50
+ const el = node;
51
+ if (SKIP.has(el.tagName) || !shown(el)) return;
52
+ const tag = el.tagName;
53
+ // Form controls contribute NOTHING. A control's value is not a RESULT of the work -- the loop
54
+ // typed or chose it itself (audit r7: expect "QuarterPush" matched our own typed input while
55
+ // Save did nothing), and a <select>'s option list was the original false pass. Assert a
56
+ // control's state with expectState: [{ role, name, value }].
57
+ if (tag === "SELECT" || tag === "TEXTAREA" || tag === "INPUT") {
58
+ const t = (el.getAttribute("type") || "").toLowerCase();
59
+ if (tag === "INPUT" && ["submit", "button", "reset"].includes(t)) out.push(" " + el.value + " "); // a button's caption is text
60
+ return;
61
+ }
62
+ const block = !inline(el);
63
+ if (block) out.push(" ");
64
+ if (tag === "IFRAME") { let d = null; try { d = el.contentDocument; } catch {} if (d?.body) walk(d.body); }
65
+ else for (const k of kids(el)) walk(k);
66
+ if (block) out.push(" ");
67
+ };
68
+ if (document.body) walk(document.body);
69
+ // Zero-width characters are KEPT: stripping them made the decoy "Access<U+200B> code accepted"
70
+ // byte-equal to the expected "Access code accepted" -- a false pass (fixtures/aria-label.html).
71
+ return out.join("").replace(/\s+/g, " ").trim();
72
+ }
@@ -0,0 +1,246 @@
1
+ // A+ : src/snapshot.js extended. Diff against src/snapshot.js, and nothing else:
2
+ // 1. DEEP WALK. Candidates are collected in document order through OPEN shadow roots and
3
+ // SAME-ORIGIN iframes (contentDocument), instead of document.querySelectorAll(SEL).
4
+ // 2. ROOT-SCOPED NAMES. aria-labelledby / label[for] resolve in el.getRootNode(), not document,
5
+ // so a label inside the same shadow root (Shoelace) or iframe document is found.
6
+ // 3. PER-DOCUMENT STYLE. getComputedStyle from the element's own window (iframes).
7
+ // 4. NATIVE <select>: one `select` action PER OPTION with a distinct node, so compact() cannot
8
+ // dedupe them to the placeholder; the <select> itself is the act target (selectNode).
9
+ // 5. HIDDEN CHECKBOX INPUT: a checkbox/radio <input> that fails visible() (opacity 0, 0x0 --
10
+ // Element Plus, Shoelace, antd) but whose <label> is visible is offered ONCE, stamped on the
11
+ // label (the thing a user clicks), with the input's name and checked state.
12
+ // 8. SLOTTED TEXT. Inside a shadow root, innerText does not follow <slot> into the host's light
13
+ // DOM, so a Shoelace <button part=base><slot> read as "button". Text is read through slots.
14
+ // 7. <input type=submit|button|reset> is named by its value (accname spec; src/ fell through to
15
+ // the name/id attribute, so a legacy "Create project" button was offered as "save"), and a
16
+ // <select> is never named by its own innerText (that is every option, concatenated).
17
+ // 6. Stamps carry a per-snapshot generation, so a stale stamp from an earlier pass never collides.
18
+ () => {
19
+ const ZW = /[​-‍]/g;
20
+ const strip = (v) => (v || "").replace(ZW, "").replace(/\s+/g, " ").trim();
21
+ const cs = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el);
22
+
23
+ const visible = (el) => {
24
+ const r = el.getBoundingClientRect();
25
+ if (r.width < 1 || r.height < 1) return false;
26
+ const s = cs(el);
27
+ if (s.visibility === "hidden" || s.display === "none" || Number(s.opacity) === 0) return false;
28
+ if (el.closest("[hidden],[aria-hidden='true'],[data-state='inactive']")) return false;
29
+ // FIX (v1): an element can have a box and still be invisible (fixtures/negative.html):
30
+ // an ancestor at opacity 0, a zero-size overflow:hidden wrapper, or positioned off-screen.
31
+ // Form controls are exempt from the opacity rule: Element Plus renders its real combobox input
32
+ // inside an opacity-0 wrapper and the user clicks the wrapper (bake-off re-run, vue-ep T2 0/5).
33
+ const control = /^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName) || el.getAttribute("role") === "combobox";
34
+ if (el.checkVisibility && !el.checkVisibility({ opacityProperty: !control, visibilityProperty: true })) return false;
35
+ if (r.right <= 0 || r.bottom <= 0) return false;
36
+ for (let p = el.parentElement; p && p !== el.ownerDocument.body; p = p.parentElement) {
37
+ const ps = cs(p);
38
+ if (ps.overflow === "visible" && ps.overflowX === "visible" && ps.overflowY === "visible") continue;
39
+ const pr = p.getBoundingClientRect();
40
+ if (pr.width < 1 || pr.height < 1) return false;
41
+ }
42
+ return true;
43
+ };
44
+
45
+ // ---- popup detection: unchanged from src/snapshot.js (top document) ----
46
+ const bodyChildren = [...document.body.children].filter((el) => el.tagName !== "SCRIPT" && el.tagName !== "STYLE");
47
+ const hasBox = (el) => { const r = el.getBoundingClientRect(); return r.width >= 1 && r.height >= 1; };
48
+ const shown = (el) => { const s = cs(el); return s.display !== "none" && s.visibility !== "hidden"; };
49
+ const floats = (el) => { const p = cs(el).position; return p === "fixed" || p === "absolute"; };
50
+ const OVERLAY_ROLE = "[role=dialog],[role=alertdialog],[role=listbox],[role=menu],[role=tooltip]";
51
+ const OVERLAY_IN = (el) => [el, ...el.querySelectorAll(OVERLAY_ROLE)].some((n) => n.matches(OVERLAY_ROLE) && shown(n));
52
+ const CONTROLLED = (el) => [...document.querySelectorAll('[aria-expanded="true"][aria-controls]')]
53
+ .some((t) => { const c = document.getElementById(t.getAttribute("aria-controls")); return c && el.contains(c) && !el.contains(t); });
54
+ const EXPANDED_OUTSIDE = (el) => [...document.querySelectorAll('[aria-expanded="true"]')].some((n) => !el.contains(n));
55
+ const popupRoots = bodyChildren.filter((el) => {
56
+ if (!shown(el)) return false;
57
+ if (!(hasBox(el) || [...el.querySelectorAll("*")].some(hasBox))) return false;
58
+ const kid = [...el.children].find((c) => shown(c) && hasBox(c));
59
+ if (floats(el) || (kid && floats(kid))) return OVERLAY_IN(el) || CONTROLLED(el);
60
+ if (!el.matches(OVERLAY_ROLE) || !hasBox(el)) return false;
61
+ return EXPANDED_OUTSIDE(el);
62
+ });
63
+ const inPopup = (el) => popupRoots.some((p) => p.contains(el));
64
+
65
+ // 2. root-scoped lookups
66
+ // 8. text through <slot>s (flat tree), used wherever a shadow-root element's innerText is read
67
+ const flat = (node) => {
68
+ if (node.nodeType === 3) return node.data;
69
+ if (node.nodeType !== 1) return "";
70
+ if (node.tagName === "SLOT") { const as = node.assignedNodes({ flatten: true });
71
+ return (as.length ? as : [...node.childNodes]).map(flat).join(" "); }
72
+ if (node.tagName === "STYLE" || node.tagName === "SCRIPT") return "";
73
+ const kids = node.shadowRoot ? [...node.shadowRoot.childNodes] : [...node.childNodes];
74
+ return kids.map(flat).join(" ");
75
+ };
76
+ const textOf = (el) => strip(el.getRootNode() instanceof ShadowRoot ? flat(el) : el.innerText);
77
+ const byId = (el, id) => { const r = el.getRootNode(); return (r.getElementById ? r.getElementById(id) : null) || el.ownerDocument.getElementById(id); };
78
+ const labelFor = (el) => el.id ? el.getRootNode().querySelector?.(`label[for="${CSS.escape(el.id)}"]`) : null;
79
+ const accName = (el) => {
80
+ const role = el.getAttribute("role");
81
+ let l = strip(el.getAttribute("aria-label"));
82
+ if (!l) {
83
+ const ids = (el.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean);
84
+ l = strip(ids.map((id) => { const t = byId(el, id); return t ? textOf(t) : ""; }).join(" "));
85
+ }
86
+ if (!l) { const lab = labelFor(el); if (lab) l = textOf(lab); }
87
+ if (!l) { const wrap = el.closest("label"); if (wrap) l = textOf(wrap); }
88
+ if (!l && el.tagName === "INPUT" && /^(submit|button|reset)$/i.test(el.type)) l = strip(el.value);
89
+ if (!l) l = strip(el.getAttribute("placeholder"));
90
+ const valueBearing = role === "combobox" || role === "listbox" || role === "spinbutton";
91
+ // SECURITY (audit S1): an editable element's text is its CONTENT, never its name.
92
+ if (!l && !valueBearing && !el.isContentEditable && !["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)) l = textOf(el);
93
+ if (!l) l = strip(el.getAttribute("title"));
94
+ if (!l) {
95
+ const n = el.getAttribute("name") || el.getAttribute("id") || el.getAttribute("type") || "";
96
+ if (!/^[_:]?[rR][_:]|^[_:]r|^_R_/.test(n)) l = strip(n);
97
+ }
98
+ return l.slice(0, 80) || "(unlabeled)";
99
+ };
100
+
101
+ const selectionOf = (el) => {
102
+ if (el.tagName === "SELECT") return strip(el.selectedOptions?.[0]?.label || "");
103
+ if (typeof el.value === "string" && strip(el.value)) return strip(el.value);
104
+ const own = strip(el.innerText);
105
+ if (own) return own;
106
+ const name = accName(el);
107
+ let node = el, r0 = el.getBoundingClientRect();
108
+ for (let i = 0; i < 4 && node.parentElement; i++) {
109
+ node = node.parentElement;
110
+ const r = node.getBoundingClientRect();
111
+ if (r.height > r0.height * 2.5) break;
112
+ if (node.querySelectorAll("input,textarea,select,button,a[href],[role=combobox]").length > 1) break;
113
+ const t = strip(node.innerText);
114
+ if (t && t !== name) return t;
115
+ }
116
+ return "";
117
+ };
118
+
119
+ const roleOf = (el) => {
120
+ const explicit = el.getAttribute("role");
121
+ if (explicit) return explicit;
122
+ const tag = el.tagName.toLowerCase();
123
+ if (tag === "a") return "link";
124
+ if (tag === "button") return "button";
125
+ if (tag === "select") return "combobox";
126
+ if (tag === "textarea") return "textbox";
127
+ if (tag === "input") {
128
+ const t = (el.getAttribute("type") || "text").toLowerCase();
129
+ if (t === "checkbox") return "checkbox";
130
+ if (t === "radio") return "radio";
131
+ if (t === "submit" || t === "button") return "button";
132
+ if (t === "password") return "password";
133
+ return "textbox";
134
+ }
135
+ return tag;
136
+ };
137
+
138
+ const SEL = [
139
+ "input:not([type=hidden])", "textarea", "select", "button", "a[href]",
140
+ "[role=button]", "[role=tab]", "[role=link]", "[role=checkbox]", "[role=switch]",
141
+ "[role=option]", "[role=menuitem]", "[role=menuitemradio]", "[role=menuitemcheckbox]",
142
+ "[role=combobox]", "[role=radio]",
143
+ "[contenteditable='']", "[contenteditable=true]",
144
+ ].join(",");
145
+
146
+ // 1. deep walk in document order: light DOM, then a host's shadow root at the host's position,
147
+ // then a same-origin iframe's document at the iframe's position.
148
+ const found = [];
149
+ const walk = (root) => {
150
+ const tw = (root.ownerDocument || root).createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
151
+ for (let el = tw.nextNode(); el; el = tw.nextNode()) {
152
+ if (el.matches(SEL)) found.push(el);
153
+ if (el.shadowRoot) walk(el.shadowRoot);
154
+ if (el.tagName === "IFRAME") {
155
+ let d = null; try { d = el.contentDocument; } catch {}
156
+ if (d && d.body && visible(el)) walk(d.body);
157
+ }
158
+ }
159
+ };
160
+ walk(document.body);
161
+
162
+ // v1: which form does a control belong to, and does this button SUBMIT it? compact() uses this
163
+ // to hold back a form's submit while a field the spec names in that same form is still unset.
164
+ const formList = [];
165
+ const formOf = (el) => { const f = el.form || el.closest("form"); if (!f) return -1;
166
+ let i = formList.indexOf(f); if (i < 0) { formList.push(f); i = formList.length - 1; } return i; };
167
+ const submits = (el) => {
168
+ const t = (el.getAttribute("type") || "").toLowerCase();
169
+ if (el.tagName === "BUTTON") return formOf(el) >= 0 && (t === "submit" || t === "");
170
+ return el.tagName === "INPUT" && t === "submit";
171
+ };
172
+
173
+ const actions = [];
174
+ const gen = Math.random().toString(36).slice(2, 6);
175
+ let n = 0;
176
+ const stampedNow = new WeakSet();
177
+ const stamp = (el) => { const node = `${gen}n${n++}`; el.setAttribute("data-jev-node", node); stampedNow.add(el); return node; };
178
+ const proxied = new WeakSet();
179
+
180
+ for (const el of found) {
181
+ const tag = el.tagName.toLowerCase();
182
+ const type = (el.type || "").toLowerCase();
183
+ if (!visible(el)) {
184
+ // 5. hidden checkbox/radio input operated through its visible label
185
+ if (tag === "input" && (type === "checkbox" || type === "radio") && !el.disabled) {
186
+ const lab = el.closest("label") || labelFor(el);
187
+ if (lab && visible(lab) && !proxied.has(lab)) {
188
+ proxied.add(lab);
189
+ const node = stamp(lab);
190
+ actions.push({ id: `click:${node}`, kind: "click", node, label: accName(el), role: type, tag: "label",
191
+ popup: inPopup(lab), value: "", checked: el.checked, proxy: true });
192
+ }
193
+ }
194
+ continue;
195
+ }
196
+ if (el.disabled) continue;
197
+ const role = roleOf(el);
198
+ const node = stamp(el);
199
+ const label = accName(el);
200
+ const popup = inPopup(el);
201
+ const form = formOf(el);
202
+
203
+ if (tag === "select") {
204
+ // 4. one action per option, distinct node; act target = the <select>
205
+ [...el.options].forEach((opt, i) => actions.push({ id: `select:${node}:${i}`, kind: "select", node: `${node}#${i}`,
206
+ selectNode: node, selectLabel: label, optionLabel: opt.label || opt.value,
207
+ label: `${label}: ${opt.label || opt.value}`, value: opt.value, current_value: el.value, role: "option", popup, form }));
208
+ actions.push({ id: `state:${node}`, kind: "state", node, label, role, tag, popup,
209
+ value: strip(el.selectedOptions?.[0]?.label || el.value || "") });
210
+ continue;
211
+ }
212
+ const editable = (tag === "input" && !["checkbox", "radio", "submit", "button"].includes(type))
213
+ || tag === "textarea" || el.isContentEditable;
214
+ if (editable && role !== "combobox")
215
+ actions.push({ id: `fill:${node}`, kind: "fill", node, label, role, tag, popup, form, itype: type,
216
+ value: strip(el.value ?? el.innerText ?? ""),
217
+ secret: type === "password" || new RegExp("__SECRET_SRC__", "i").test(`${label} ${el.name || ""} ${el.getAttribute("autocomplete") || ""}`) });
218
+ if (tag === "label") proxied.add(el);
219
+ actions.push({ id: `click:${node}`, kind: "click", node, label, role, tag, popup, form,
220
+ ...(submits(el) ? { submit: true } : {}),
221
+ value: role === "combobox" ? selectionOf(el).slice(0, 40) : strip(typeof el.value === "string" ? el.value : ""),
222
+ ...(el.getAttribute("aria-selected") !== null ? { selected: el.getAttribute("aria-selected") === "true" } : {}),
223
+ ...(el.getAttribute("aria-expanded") !== null ? { expanded: el.getAttribute("aria-expanded") === "true" } : {}),
224
+ ...(el.getAttribute("aria-haspopup") && el.getAttribute("aria-haspopup") !== "false" ? { haspopup: true } : {}),
225
+ ...(el.getAttribute("aria-checked") !== null ? { checked: el.getAttribute("aria-checked") === "true" }
226
+ : typeof el.checked === "boolean" && ["checkbox", "radio"].includes(type) ? { checked: el.checked } : {}) });
227
+ }
228
+
229
+ for (const root of popupRoots) {
230
+ const alreadyCovered = actions.some((a) => a.popup && root.querySelector(`[data-jev-node="${a.node}"]`));
231
+ if (alreadyCovered) continue;
232
+ for (const el of root.querySelectorAll("*")) {
233
+ if (!visible(el)) continue;
234
+ if (stampedNow.has(el)) continue;
235
+ const txt = strip(el.innerText);
236
+ if (!txt || txt.length > 60) continue;
237
+ if ([...el.children].some((c) => visible(c) && strip(c.innerText) === txt)) continue;
238
+ if ([...el.children].filter((c) => visible(c) && strip(c.innerText)).length > 1) continue;
239
+ const node = stamp(el);
240
+ actions.push({ id: `click:${node}`, kind: "click", node, label: txt, role: "option", tag: el.tagName.toLowerCase(), popup: true, value: "" });
241
+ }
242
+ }
243
+
244
+ const text = strip((document.body && document.body.innerText) || "").slice(0, 1200);
245
+ return { url: location.href, title: document.title, text, actions };
246
+ }