barebrowse 0.14.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ // @ts-nocheck — AX_FN below is browser-context code (document, getComputedStyle,
2
+ // CSS, real regex literals) serialized via .toString() and run inside Firefox,
3
+ // never in Node. tsc's Node lib (no DOM) can't meaningfully check it, and the
4
+ // readable.js string convention would corrupt its regex backslashes, so the
5
+ // file opts out of type-checking. The Node-side exports are trivial.
6
+ /**
7
+ * ax-snapshot.js — Reconstruct a CDP-shaped accessibility tree in-page.
8
+ *
9
+ * CDP hands you Accessibility.getFullAXTree; BiDi has no equivalent, so on
10
+ * Firefox we rebuild an equivalent tree inside the page via script.evaluate.
11
+ * The output tree uses the SAME node shape and role vocabulary as
12
+ * buildTree()'s CDP output, so prune.js and aria.js consume it unchanged:
13
+ *
14
+ * { nodeId, role, name, properties, children, ignored }
15
+ *
16
+ * where `role` is CDP/ARIA vocabulary ('RootWebArea', 'StaticText', 'heading',
17
+ * 'link', 'button', 'img', 'paragraph', 'list', 'navigation', …), NOT tag
18
+ * names. The three things getFullAXTree gave us for free and we reimplement:
19
+ * 1. implicit ARIA role mapping (HTML element → role)
20
+ * 2. accessible-name computation (aria-labelledby → aria-label → native
21
+ * label/alt/legend/title → text content) — the POC proved textContent
22
+ * alone is NOT enough (img alt, <label>, aria-labelledby all missed).
23
+ * 3. hidden-subtree filtering (aria-hidden, display:none, visibility:hidden,
24
+ * the hidden attribute).
25
+ *
26
+ * Each kept element is tagged with a data-bb-ref attribute carrying its ref,
27
+ * so firefox-page.js (resolveRef) can resolve a ref back to its element via
28
+ * querySelector.
29
+ * Refs are assigned from a caller-supplied `base` so they stay globally unique
30
+ * across browsing contexts (iframes), matching CDP's flat integer refs.
31
+ */
32
+
33
+ /** Attribute used to tag elements for ref → element resolution. */
34
+ export const REF_ATTR = 'data-bb-ref';
35
+
36
+ /**
37
+ * The in-page reconstruction function, as source text. Evaluated in a browsing
38
+ * context with a `base` ref offset; returns JSON { tree, count }. Written as a
39
+ * single self-contained function (no closures over module scope) because it
40
+ * runs in the page, not in Node.
41
+ */
42
+ const AX_FN = function reconstructAX(base, REF_ATTR) {
43
+ let ref = base;
44
+
45
+ // Roles whose accessible name comes from descendant text (so we must NOT
46
+ // also emit child StaticText nodes — CDP folds the text into the name).
47
+ const NAME_FROM_CONTENT = new Set([
48
+ 'button', 'link', 'heading', 'cell', 'columnheader', 'rowheader',
49
+ 'tab', 'menuitem', 'option', 'treeitem', 'switch', 'checkbox', 'radio',
50
+ ]);
51
+
52
+ // HTML tag → implicit ARIA role (the common, high-value subset).
53
+ const TAG_ROLE = {
54
+ A: (el) => (el.hasAttribute('href') ? 'link' : 'generic'),
55
+ BUTTON: () => 'button',
56
+ NAV: () => 'navigation', MAIN: () => 'main', ASIDE: () => 'complementary',
57
+ HEADER: (el) => (isTopLevelLandmark(el) ? 'banner' : 'generic'),
58
+ FOOTER: (el) => (isTopLevelLandmark(el) ? 'contentinfo' : 'generic'),
59
+ FORM: () => 'form', SEARCH: () => 'search',
60
+ SECTION: (el) => (accName(el) ? 'region' : 'generic'),
61
+ ARTICLE: () => 'article',
62
+ H1: () => 'heading', H2: () => 'heading', H3: () => 'heading',
63
+ H4: () => 'heading', H5: () => 'heading', H6: () => 'heading',
64
+ P: () => 'paragraph',
65
+ UL: () => 'list', OL: () => 'list', LI: () => 'listitem',
66
+ DL: () => 'list', DT: () => 'term', DD: () => 'definition',
67
+ IMG: (el) => (el.getAttribute('alt') === '' ? 'none' : 'image'),
68
+ FIGURE: () => 'figure', FIGCAPTION: () => 'Figcaption',
69
+ TABLE: () => 'table', TR: () => 'row', TD: () => 'cell',
70
+ TH: (el) => (el.getAttribute('scope') === 'row' ? 'rowheader' : 'columnheader'),
71
+ THEAD: () => 'rowgroup', TBODY: () => 'rowgroup', TFOOT: () => 'rowgroup',
72
+ SELECT: (el) => (el.multiple ? 'listbox' : 'combobox'),
73
+ TEXTAREA: () => 'textbox',
74
+ OPTION: () => 'option',
75
+ LABEL: () => 'LabelText', SPAN: () => 'generic', DIV: () => 'generic',
76
+ STRONG: () => 'strong', EM: () => 'emphasis', B: () => 'strong', I: () => 'emphasis',
77
+ BLOCKQUOTE: () => 'blockquote', CODE: () => 'code', PRE: () => 'generic',
78
+ HR: () => 'separator',
79
+ IFRAME: () => 'Iframe', FRAME: () => 'Iframe',
80
+ INPUT: (el) => {
81
+ const t = (el.getAttribute('type') || 'text').toLowerCase();
82
+ return ({
83
+ button: 'button', submit: 'button', reset: 'button', image: 'button',
84
+ checkbox: 'checkbox', radio: 'radio', range: 'slider',
85
+ search: 'searchbox', email: 'textbox', tel: 'textbox', url: 'textbox',
86
+ text: 'textbox', password: 'textbox', number: 'spinbutton',
87
+ hidden: 'none',
88
+ })[t] || 'textbox';
89
+ },
90
+ };
91
+
92
+ function isTopLevelLandmark(el) {
93
+ // <header>/<footer> are landmarks only when not scoped inside article/section/main/aside.
94
+ for (let p = el.parentElement; p; p = p.parentElement) {
95
+ if (/^(ARTICLE|SECTION|MAIN|ASIDE|NAV)$/.test(p.tagName)) return false;
96
+ }
97
+ return true;
98
+ }
99
+
100
+ function roleOf(el) {
101
+ const explicit = el.getAttribute('role');
102
+ if (explicit) return explicit.trim().split(/\s+/)[0];
103
+ const fn = TAG_ROLE[el.tagName];
104
+ return fn ? fn(el) : 'generic';
105
+ }
106
+
107
+ function isHidden(el) {
108
+ if (el.getAttribute('aria-hidden') === 'true') return true;
109
+ if (el.hasAttribute('hidden')) return true;
110
+ const s = getComputedStyle(el);
111
+ if (s.display === 'none' || s.visibility === 'hidden' || s.visibility === 'collapse') return true;
112
+ return false;
113
+ }
114
+
115
+ // Accessible-name computation (compact subset of the accname spec, covering
116
+ // the cases the POC proved textContent misses).
117
+ function accName(el, depth) {
118
+ depth = depth || 0;
119
+ // 1. aria-labelledby (resolve id refs → their text)
120
+ const lb = el.getAttribute && el.getAttribute('aria-labelledby');
121
+ if (lb && depth < 2) {
122
+ const txt = lb.split(/\s+/).map((id) => {
123
+ const t = document.getElementById(id);
124
+ return t ? accName(t, depth + 1) || t.textContent.trim() : '';
125
+ }).filter(Boolean).join(' ').trim();
126
+ if (txt) return txt;
127
+ }
128
+ // 2. aria-label
129
+ const al = el.getAttribute && el.getAttribute('aria-label');
130
+ if (al && al.trim()) return al.trim();
131
+ const tag = el.tagName;
132
+ // 3. native naming
133
+ if (tag === 'IMG' || tag === 'AREA') {
134
+ const alt = el.getAttribute('alt');
135
+ if (alt) return alt.trim();
136
+ }
137
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
138
+ // associated <label> (for= or wrapping ancestor)
139
+ if (el.id) {
140
+ const lab = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
141
+ if (lab) return lab.textContent.trim();
142
+ }
143
+ const wrap = el.closest && el.closest('label');
144
+ if (wrap) return wrap.textContent.trim();
145
+ const ph = el.getAttribute('placeholder');
146
+ if (ph && ph.trim()) return ph.trim();
147
+ }
148
+ if (tag === 'FIELDSET') {
149
+ const lg = el.querySelector && el.querySelector('legend');
150
+ if (lg) return lg.textContent.trim();
151
+ }
152
+ if (tag === 'TABLE') {
153
+ const cap = el.querySelector && el.querySelector('caption');
154
+ if (cap) return cap.textContent.trim();
155
+ }
156
+ // 4. title attribute
157
+ const title = el.getAttribute && el.getAttribute('title');
158
+ if (title && title.trim()) return title.trim();
159
+ return '';
160
+ }
161
+
162
+ function props(el, role) {
163
+ const p = {};
164
+ if (role === 'checkbox' || role === 'radio' || role === 'switch') p.checked = !!el.checked;
165
+ if (el.disabled || el.getAttribute('aria-disabled') === 'true') p.disabled = true;
166
+ const exp = el.getAttribute('aria-expanded');
167
+ if (exp !== null) p.expanded = exp === 'true';
168
+ if (role === 'heading') p.level = Number(el.getAttribute('aria-level')) || Number(el.tagName[1]) || 2;
169
+ if (el.getAttribute('aria-selected') === 'true') p.selected = true;
170
+ if (el.required || el.getAttribute('aria-required') === 'true') p.required = true;
171
+ if ((role === 'textbox' || role === 'searchbox' || role === 'combobox' || role === 'spinbutton') && el.value) {
172
+ p.value = el.value;
173
+ }
174
+ return p;
175
+ }
176
+
177
+ function makeNode(role, name, properties) {
178
+ return { nodeId: String(++ref), role, name: name || '', properties: properties || {}, children: [], ignored: false };
179
+ }
180
+
181
+ // Walk a DOM element → AX node (or null if hidden/irrelevant). Text runs
182
+ // become StaticText children unless the parent names from content.
183
+ function walk(el) {
184
+ if (isHidden(el)) return null;
185
+ let role = roleOf(el);
186
+ if (role === 'none') return collapseChildren(el); // presentational: keep kids
187
+
188
+ // Accessible name: explicit sources first, then fall back to descendant
189
+ // text for roles that name from content (button/link/heading/…), matching
190
+ // CDP — otherwise those nodes come back nameless.
191
+ let name = accName(el);
192
+ if (!name && NAME_FROM_CONTENT.has(role)) {
193
+ name = el.textContent.replace(/\s+/g, ' ').trim();
194
+ }
195
+ const node = makeNode(role, name, props(el, role));
196
+ el.setAttribute(REF_ATTR, node.nodeId);
197
+
198
+ // A collapsed native <select> exposes its current value via props/name;
199
+ // don't expand its <option> list into the tree (a 200-item country select
200
+ // would otherwise flood every snapshot). A multi/size listbox keeps them.
201
+ if (el.tagName === 'SELECT' && !el.multiple) return node;
202
+
203
+ if (!NAME_FROM_CONTENT.has(role)) {
204
+ for (const child of kidsOf(el)) {
205
+ if (child.nodeType === 3) {
206
+ const t = child.textContent.replace(/\s+/g, ' ').trim();
207
+ if (t) node.children.push(makeNode('StaticText', t, {}));
208
+ } else if (child.nodeType === 1) {
209
+ const c = walk(child);
210
+ if (c) Array.isArray(c) ? node.children.push(...c) : node.children.push(c);
211
+ }
212
+ }
213
+ } else {
214
+ // name-from-content: still recurse into element children for nested
215
+ // interactives (e.g. a link inside a heading), but drop bare text.
216
+ for (const child of kidsOf(el)) {
217
+ if (child.nodeType !== 1) continue;
218
+ const c = walk(child);
219
+ if (c) Array.isArray(c) ? node.children.push(...c) : node.children.push(c);
220
+ }
221
+ }
222
+ return node;
223
+ }
224
+
225
+ // Effective children to traverse: shadow-root content when the element hosts
226
+ // an open shadow tree (that is what actually renders — CDP's AX tree includes
227
+ // it), assigned light nodes for a <slot>, else plain light-DOM childNodes.
228
+ function kidsOf(el) {
229
+ if (el.tagName === 'SLOT') {
230
+ const assigned = el.assignedNodes ? el.assignedNodes({ flatten: true }) : [];
231
+ return assigned.length ? assigned : [...el.childNodes];
232
+ }
233
+ if (el.shadowRoot) return [...el.shadowRoot.childNodes];
234
+ return [...el.childNodes];
235
+ }
236
+
237
+ // Presentational element (role=none/presentation): emit its children only.
238
+ function collapseChildren(el) {
239
+ const out = [];
240
+ for (const child of kidsOf(el)) {
241
+ if (child.nodeType === 3) {
242
+ const t = child.textContent.replace(/\s+/g, ' ').trim();
243
+ if (t) out.push(makeNode('StaticText', t, {}));
244
+ } else if (child.nodeType === 1 && !isHidden(child)) {
245
+ const c = walk(child);
246
+ if (c) Array.isArray(c) ? out.push(...c) : out.push(c);
247
+ }
248
+ }
249
+ return out;
250
+ }
251
+
252
+ // Clear stale refs from a prior snapshot so resolution never hits a ghost.
253
+ for (const old of document.querySelectorAll('[' + REF_ATTR + ']')) old.removeAttribute(REF_ATTR);
254
+
255
+ // Wrap body content in an ignored 'none' node, mirroring the html/body
256
+ // wrappers CDP's getFullAXTree emits. prune.js's region extraction only
257
+ // inspects RootWebArea's DIRECT children for landmarks, so without this
258
+ // wrapper a top-level <form>/<nav> (no <main>) would be treated as a
259
+ // directly-extractable region and dropped in act mode — a divergence from
260
+ // CDP, where the same landmark is buried under body and flows through.
261
+ const root = makeNode('RootWebArea', document.title || '', {});
262
+ const body = makeNode('none', '', {});
263
+ body.ignored = true;
264
+ // childNodes (not .children) so bare text directly under <body> is kept as
265
+ // StaticText, matching walk()/CDP; .children would silently drop it.
266
+ for (const child of document.body ? document.body.childNodes : []) {
267
+ if (child.nodeType === 3) {
268
+ const t = child.textContent.replace(/\s+/g, ' ').trim();
269
+ if (t) body.children.push(makeNode('StaticText', t, {}));
270
+ } else if (child.nodeType === 1) {
271
+ const c = walk(child);
272
+ if (c) Array.isArray(c) ? body.children.push(...c) : body.children.push(c);
273
+ }
274
+ }
275
+ root.children.push(body);
276
+ return JSON.stringify({ tree: root, count: ref - base });
277
+ };
278
+
279
+ /**
280
+ * Build the script.evaluate expression that reconstructs the AX tree in a
281
+ * context, assigning refs from `base`.
282
+ * @param {number} base - Starting ref offset (exclusive; first ref is base+1)
283
+ * @returns {string} JS expression returning JSON { tree, count }
284
+ */
285
+ export function axSnapshotExpression(base) {
286
+ return `(${AX_FN.toString()})(${Number(base)}, ${JSON.stringify(REF_ATTR)})`;
287
+ }
package/src/bidi.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * bidi.js — Minimal WebDriver BiDi client over WebSocket (Firefox transport).
3
+ *
4
+ * The W3C-standard successor to CDP. CDP was deprecated in Firefox, so Firefox
5
+ * is driven over BiDi instead. This is the BiDi analogue of cdp.js: same
6
+ * JSON-RPC-over-WebSocket shape (id/method/params → result), same `ws`
7
+ * dependency and 256 MB maxPayload (a full AX snapshot of a large page is
8
+ * returned as one big string from script.evaluate — see ax-snapshot.js — and
9
+ * would blow the built-in WebSocket cap exactly as getFullAXTree did on CDP).
10
+ *
11
+ * Differences from CDP that shape this client:
12
+ * - A session must be created explicitly (`session.new`) before any command.
13
+ * - Events must be subscribed to (`session.subscribe`); we lean on
14
+ * command results (navigate's `wait:'complete'`) instead where possible.
15
+ * - Errors arrive as `{ type:'error', error, message }`, not `{ error:{} }`.
16
+ * - Everything is scoped by `context` (a browsing-context id), not sessionId.
17
+ */
18
+
19
+ import WebSocket from 'ws';
20
+
21
+ /** Lift the message ceiling well past any realistic AX/DOM payload. */
22
+ const MAX_PAYLOAD = 256 * 1024 * 1024; // 256 MB
23
+
24
+ /**
25
+ * Create a BiDi client connected to the given /session WebSocket URL and open
26
+ * a session. Firefox prints its BiDi endpoint to stderr as
27
+ * "WebDriver BiDi listening on ws://HOST:PORT"; the direct-connection socket
28
+ * (no WebDriver-classic handshake) lives at that URL + "/session".
29
+ *
30
+ * @param {string} wsUrl - BiDi session WebSocket URL (ws://HOST:PORT/session)
31
+ * @returns {Promise<object>} BiDi client ({ send, evaluate, on, once, subscribe, sessionId, close })
32
+ */
33
+ export async function createBiDi(wsUrl) {
34
+ const ws = new WebSocket(wsUrl, { maxPayload: MAX_PAYLOAD, perMessageDeflate: false });
35
+ let nextId = 1;
36
+ const pending = new Map(); // id → { resolve, reject }
37
+ const listeners = new Map(); // "method" → Set<callback>
38
+
39
+ /** @type {Promise<void>} */
40
+ const connected = new Promise((resolve, reject) => {
41
+ const timeout = setTimeout(() => reject(new Error('BiDi connection timeout (5s)')), 5000);
42
+ ws.onopen = () => { clearTimeout(timeout); resolve(); };
43
+ ws.onerror = (e) => {
44
+ clearTimeout(timeout);
45
+ reject(new Error(`BiDi WebSocket connection failed: ${e.message || 'unknown error'}`));
46
+ };
47
+ });
48
+ await connected;
49
+
50
+ ws.onmessage = (event) => {
51
+ const msg = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString());
52
+
53
+ // Command response (has id + type 'success' | 'error')
54
+ if (msg.id !== undefined && pending.has(msg.id)) {
55
+ const handler = pending.get(msg.id);
56
+ pending.delete(msg.id);
57
+ if (msg.type === 'error') {
58
+ handler.reject(new Error(`BiDi error: ${msg.error} — ${msg.message || ''}`.trim()));
59
+ } else {
60
+ handler.resolve(msg.result);
61
+ }
62
+ return;
63
+ }
64
+
65
+ // Event (type 'event', has method + params)
66
+ if (msg.type === 'event' && msg.method) {
67
+ const set = listeners.get(msg.method);
68
+ if (set) for (const cb of set) cb(msg.params);
69
+ }
70
+ };
71
+
72
+ ws.onclose = () => {
73
+ for (const [id, handler] of pending) {
74
+ handler.reject(new Error('BiDi WebSocket closed'));
75
+ pending.delete(id);
76
+ }
77
+ };
78
+
79
+ const client = {
80
+ /**
81
+ * Send a BiDi command and wait for its result.
82
+ * @param {string} method - e.g. 'browsingContext.navigate'
83
+ * @param {object} [params]
84
+ * @returns {Promise<object>} result
85
+ */
86
+ send(method, params = {}) {
87
+ const id = nextId++;
88
+ return new Promise((resolve, reject) => {
89
+ pending.set(id, { resolve, reject });
90
+ ws.send(JSON.stringify({ id, method, params }));
91
+ });
92
+ },
93
+
94
+ /**
95
+ * Evaluate an expression in a browsing context and return its value.
96
+ * Throws on an in-page exception (BiDi reports these as a `success`
97
+ * envelope with `type:'exception'`, unlike a protocol error).
98
+ * @param {string} context - browsing-context id
99
+ * @param {string} expression - JS source to evaluate
100
+ * @param {boolean} [awaitPromise=true]
101
+ * @returns {Promise<*>} the deserialized primitive value (result.value)
102
+ */
103
+ async evaluate(context, expression, awaitPromise = true) {
104
+ const res = await client.send('script.evaluate', {
105
+ expression, target: { context }, awaitPromise,
106
+ });
107
+ if (res.type === 'exception') {
108
+ const d = res.exceptionDetails;
109
+ throw new Error(`BiDi script exception: ${d?.text || d?.exception?.value || 'unknown'}`);
110
+ }
111
+ return res.result?.value;
112
+ },
113
+
114
+ /** Subscribe to BiDi events by method name (required before events fire). */
115
+ async subscribe(events) {
116
+ await client.send('session.subscribe', { events });
117
+ },
118
+
119
+ /** Register an event listener. Returns an unsubscribe function. */
120
+ on(method, callback) {
121
+ if (!listeners.has(method)) listeners.set(method, new Set());
122
+ listeners.get(method).add(callback);
123
+ return () => listeners.get(method)?.delete(callback);
124
+ },
125
+
126
+ /** Resolve once a named event fires (or reject on timeout). */
127
+ once(method, timeout = 30000) {
128
+ return new Promise((resolve, reject) => {
129
+ const timer = setTimeout(() => { unsub(); reject(new Error(`Timeout waiting for BiDi event: ${method}`)); }, timeout);
130
+ const unsub = client.on(method, (params) => { clearTimeout(timer); unsub(); resolve(params); });
131
+ });
132
+ },
133
+
134
+ sessionId: null,
135
+ close() { ws.close(); },
136
+ };
137
+
138
+ // Open the session. capabilities:{} accepts Firefox's defaults.
139
+ const session = await client.send('session.new', { capabilities: {} });
140
+ client.sessionId = session.sessionId;
141
+ client.capabilities = session.capabilities;
142
+ return client;
143
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * consent-firefox.js — Auto-dismiss cookie consent dialogs on the Firefox/BiDi
3
+ * engine, by walking the reconstructed AX tree (ax-snapshot.js).
4
+ *
5
+ * The CDP walker (consent.js) can't be reused verbatim: it consumes CDP's flat
6
+ * getFullAXTree (nodes[] with parentId / role.value / backendDOMNodeId) and
7
+ * clicks via DOM.resolveNode + Input.dispatchMouseEvent. The Firefox tree is a
8
+ * DIFFERENT shape — nested `children`, string `role`/`name`, and each node's
9
+ * `nodeId` IS its ref (stamped as data-bb-ref) — and clicks go through
10
+ * input.performActions. So this is a parallel walker sharing only the language
11
+ * patterns (consent-patterns.js).
12
+ *
13
+ * It's written as a PURE function over (root tree, click(ref)) so it can be
14
+ * unit-tested against fixture trees with no browser: the caller (firefox-page)
15
+ * injects a real click that routes ref → pointerClick. The nested tree also
16
+ * makes "descendant of dialog" a plain subtree walk — no parentMap needed.
17
+ */
18
+
19
+ import { ACCEPT_PATTERNS, DIALOG_ROLES, CONSENT_DIALOG_HINTS } from './consent-patterns.js';
20
+
21
+ /** Roles whose text confirms a container is a consent dialog. */
22
+ const CONSENT_TEXT_ROLES = new Set(['heading', 'StaticText', 'generic']);
23
+
24
+ /** Depth-first walk yielding every node in the subtree rooted at `node`. */
25
+ function* walk(node) {
26
+ if (!node) return;
27
+ yield node;
28
+ for (const child of node.children || []) yield* walk(child);
29
+ }
30
+
31
+ /** True if any node in this subtree carries consent-hint text. */
32
+ function hasConsentContent(dialog) {
33
+ for (const node of walk(dialog)) {
34
+ if (node === dialog) continue;
35
+ if (CONSENT_TEXT_ROLES.has(node.role) && CONSENT_DIALOG_HINTS.some((p) => p.test(node.name || ''))) {
36
+ return true;
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+
42
+ /**
43
+ * Find the best "accept" button inside a dialog subtree, honouring
44
+ * ACCEPT_PATTERNS priority (most specific first).
45
+ */
46
+ function findAcceptButton(dialog) {
47
+ for (const pattern of ACCEPT_PATTERNS) {
48
+ for (const node of walk(dialog)) {
49
+ if (node.role === 'button' && node.name && pattern.test(node.name)) return node;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Fallback when no consent dialog container is found: scan the whole tree for a
57
+ * button matching a STRONG (multi-word) accept pattern. Excludes the bare
58
+ * ^accept$/^agree$/^ok$ fallbacks (last 3) so we don't false-match unrelated
59
+ * page buttons — mirrors consent.js's tryGlobalConsentButton.
60
+ */
61
+ function findGlobalAcceptButton(root) {
62
+ const safePatterns = ACCEPT_PATTERNS.slice(0, -3);
63
+ for (const pattern of safePatterns) {
64
+ for (const node of walk(root)) {
65
+ if (node.role === 'button' && node.name && pattern.test(node.name)) return node;
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Try to dismiss a cookie consent dialog in a reconstructed Firefox AX tree.
73
+ *
74
+ * @param {object} root - Spliced AX tree root (from firefox-page's buildTree).
75
+ * @param {(ref: string) => Promise<void>} click - Clicks the element for a ref
76
+ * (firefox-page injects one that routes to pointerClick / performActions).
77
+ * @returns {Promise<boolean>} true if an accept button was found and clicked.
78
+ */
79
+ export async function dismissConsentFirefox(root, click) {
80
+ if (!root) return false;
81
+
82
+ // Find dialog containers that look like consent dialogs.
83
+ const consentDialogs = [];
84
+ for (const node of walk(root)) {
85
+ if (!DIALOG_ROLES.has(node.role)) continue;
86
+ const name = node.name || '';
87
+ if (CONSENT_DIALOG_HINTS.some((p) => p.test(name)) || hasConsentContent(node)) {
88
+ consentDialogs.push(node);
89
+ }
90
+ }
91
+
92
+ // Accept button inside a consent dialog (preferred — scoped, low false-positive).
93
+ for (const dialog of consentDialogs) {
94
+ const button = findAcceptButton(dialog);
95
+ if (button?.nodeId) {
96
+ try {
97
+ await click(button.nodeId);
98
+ return true;
99
+ } catch {
100
+ // Click failed (stale ref / navigated) — try the next dialog.
101
+ }
102
+ }
103
+ }
104
+
105
+ // Banner-style consent (no dialog container at all): scan the page for a
106
+ // strong accept button. We deliberately DO NOT run this page-wide scan when a
107
+ // consent dialog WAS detected but had no in-scope accept button — a page-wide
108
+ // match there can click an UNRELATED "Accept all …" control elsewhere (e.g. a
109
+ // ToS/signup button), an automatic wrong mutation on goto(). The trade-off is
110
+ // losing the rare "accept button rendered outside its own dialog" pattern; we
111
+ // accept that miss rather than risk a wrong click. See the PRD "known
112
+ // limitations" note for the validated false-positive this guards against.
113
+ if (consentDialogs.length === 0) {
114
+ const global = findGlobalAcceptButton(root);
115
+ if (global?.nodeId) {
116
+ try {
117
+ await click(global.nodeId);
118
+ return true;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+ }
124
+ return false;
125
+ }