barebrowse 0.14.0 → 0.15.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/CHANGELOG.md +47 -0
- package/README.md +16 -1
- package/barebrowse.context.md +3 -0
- package/cli.js +10 -2
- package/mcp-server.js +14 -2
- package/package.json +1 -1
- package/src/auth.js +27 -13
- package/src/ax-snapshot.js +287 -0
- package/src/bidi.js +143 -0
- package/src/daemon.js +92 -51
- package/src/firefox-page.js +416 -0
- package/src/firefox.js +203 -0
- package/src/index.js +75 -6
- package/src/readable.js +32 -20
- package/types/auth.d.ts +18 -1
- package/types/ax-snapshot.d.ts +35 -0
- package/types/bidi.d.ts +10 -0
- package/types/daemon.d.ts +12 -0
- package/types/firefox-page.d.ts +19 -0
- package/types/firefox.d.ts +41 -0
- package/types/index.d.ts +13 -2
- package/types/readable.d.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.15.0] - 2026-07-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Firefox support via WebDriver BiDi (`connect({ engine: 'firefox' })`).** CDP
|
|
8
|
+
is deprecated in Firefox, so Firefox is driven over the W3C-standard BiDi
|
|
9
|
+
protocol — a second transport (`src/bidi.js`) over the *same* `ws` dependency,
|
|
10
|
+
with no geckodriver and no new package. Selectable from the CLI
|
|
11
|
+
(`barebrowse open <url> --engine firefox`) and MCP (`BAREBROWSE_ENGINE=firefox`).
|
|
12
|
+
- New modules: `bidi.js` (BiDi JSON-RPC transport), `firefox.js` (launch/find/
|
|
13
|
+
reap), `ax-snapshot.js` (reconstructs a CDP-vocabulary ARIA tree in-page —
|
|
14
|
+
BiDi has no `getFullAXTree` — with implicit roles, accessible-name
|
|
15
|
+
computation, `aria-hidden`/visibility filtering, and shadow-DOM/`<slot>`
|
|
16
|
+
traversal), `firefox-page.js` (the BiDi-backed page object).
|
|
17
|
+
- `prune.js`, `aria.js`, and `readable.js` are reused unchanged across engines
|
|
18
|
+
(readable.js refactored to share `EXTRACT_EXPRESSION` + `finalizeReadable`).
|
|
19
|
+
- Covers `goto`, `snapshot`, `click`, `type`, `press`, `scroll`, `hover`,
|
|
20
|
+
`select`, `drag`, `upload`, `goBack`/`goForward`, `reload`, `screenshot`,
|
|
21
|
+
`pdf`, `tabs`/`switchTab`, `waitFor`, `readable`, `injectCookies`, `close`.
|
|
22
|
+
The navigation guard and upload sandbox are enforced on the Firefox path for
|
|
23
|
+
parity with CDP.
|
|
24
|
+
- Fidelity validated against real CDP snapshots; iframes (incl. nested +
|
|
25
|
+
multi-tab), shadow DOM, CSP, and SPA timing covered in
|
|
26
|
+
`test/integration/firefox.test.js` (15 tests).
|
|
27
|
+
|
|
28
|
+
- **Incognito mode — a clean, unauthenticated session (`incognito: true`).**
|
|
29
|
+
Skips ALL auth injection: no cookie extraction/injection and no
|
|
30
|
+
`storageState` loading, so the agent browses logged-out even though the
|
|
31
|
+
default throwaway profile is unchanged. The gate lives at the page-object
|
|
32
|
+
level, so it holds even when a caller (MCP `goto`, the daemon) injects
|
|
33
|
+
unconditionally. Available on `browse()`, `connect()`, both engines, MCP
|
|
34
|
+
(`browse` tool arg + `BAREBROWSE_INCOGNITO=1`), and CLI (`--incognito`).
|
|
35
|
+
Cookie injection is scoped to the target host on **both** engines via a
|
|
36
|
+
shared `scopedCookiesForUrl` (the Firefox path no longer loads the whole
|
|
37
|
+
cookie jar).
|
|
38
|
+
|
|
39
|
+
### Known gaps (Firefox engine)
|
|
40
|
+
|
|
41
|
+
- Consent auto-dismiss, stealth patches, and `hybrid` mode remain Chromium-only.
|
|
42
|
+
- `reload()` cannot honour `ignoreCache` (Firefox BiDi does not support it yet).
|
|
43
|
+
- The CLI daemon's console/network log capture is CDP-only, so those logs are
|
|
44
|
+
empty for a Firefox session. `saveState`, `waitForNavigation`, and
|
|
45
|
+
`waitForNetworkIdle` are CDP-only too — on Firefox they throw a clear
|
|
46
|
+
"not supported on the Firefox/BiDi engine" error, and download/dialog logs
|
|
47
|
+
read empty. Accessible-name computation is a high-value subset of the full
|
|
48
|
+
W3C accname spec.
|
|
49
|
+
|
|
3
50
|
## [0.14.0] - 2026-06-15
|
|
4
51
|
|
|
5
52
|
### Documentation
|
package/README.md
CHANGED
|
@@ -132,6 +132,21 @@ const snap = await page.snapshot();
|
|
|
132
132
|
await page.close(); // closes only the tab barebrowse opened — your browser keeps running
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
+
### Firefox (WebDriver BiDi)
|
|
136
|
+
|
|
137
|
+
CDP is deprecated in Firefox, so barebrowse drives it over the W3C WebDriver
|
|
138
|
+
BiDi protocol instead — a second transport over the same `ws` dependency, no
|
|
139
|
+
extra download. Same `page.*` API (the ARIA snapshot is reconstructed in-page
|
|
140
|
+
since BiDi has no `getFullAXTree`):
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
const page = await connect({ engine: 'firefox' }); // headless by default
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
From the CLI: `barebrowse open <url> --engine firefox`. From MCP: set
|
|
147
|
+
`BAREBROWSE_ENGINE=firefox`. Firefox cookies (plaintext) reuse into the same
|
|
148
|
+
engine. Chromium (CDP) remains the default; `hybrid` mode is Chromium-only.
|
|
149
|
+
|
|
135
150
|
No clone profile, no fresh cookies — the agent sees what you see.
|
|
136
151
|
|
|
137
152
|
## What it handles automatically
|
|
@@ -223,7 +238,7 @@ URL -> find/launch browser (chromium.js)
|
|
|
223
238
|
## Requirements
|
|
224
239
|
|
|
225
240
|
- Node.js >= 22 (built-in WebSocket, built-in SQLite)
|
|
226
|
-
- Any Chromium-based browser installed (Chrome, Chromium, Brave, Edge, Vivaldi)
|
|
241
|
+
- Any Chromium-based browser installed (Chrome, Chromium, Brave, Edge, Vivaldi) — or Firefox (>= 121) for the `engine: 'firefox'` BiDi path
|
|
227
242
|
- Linux tested (Fedora/KDE). macOS/Windows cookie paths exist but untested.
|
|
228
243
|
|
|
229
244
|
## The bare ecosystem
|
package/barebrowse.context.md
CHANGED
|
@@ -46,6 +46,7 @@ const snapshot = await browse('https://example.com');
|
|
|
46
46
|
const snapshot = await browse('https://example.com', {
|
|
47
47
|
mode: 'headless', // 'headless' | 'headed' | 'hybrid'
|
|
48
48
|
cookies: true, // inject user's browser cookies
|
|
49
|
+
incognito: false, // clean, unauthenticated session: skip ALL auth injection
|
|
49
50
|
browser: 'firefox', // cookie source: 'firefox' | 'chromium' (auto-detected)
|
|
50
51
|
prune: true, // apply ARIA pruning (47-95% token reduction)
|
|
51
52
|
pruneMode: 'act', // 'act' (interactive elements) | 'read' (all content)
|
|
@@ -94,10 +95,12 @@ const snapshot = await browse('https://example.com', {
|
|
|
94
95
|
| `close()` | -- | void | Close page, disconnect CDP, kill browser (if headless) |
|
|
95
96
|
|
|
96
97
|
**connect() options** (in addition to mode/port/consent):
|
|
98
|
+
- `engine: 'chromium'|'firefox'` — Browser engine. Default `chromium` (CDP). `firefox` drives Firefox over WebDriver BiDi (CDP is deprecated there) — a separate transport with the same `page.*` API; the ARIA snapshot is reconstructed in-page since BiDi has no `getFullAXTree`. Chromium-only: `hybrid` mode, consent auto-dismiss, stealth, and the CLI daemon's console/network capture. `reload()` ignores `ignoreCache` on Firefox (BiDi limitation).
|
|
97
99
|
- `port: 9222` — Attach to a Chromium already running with `--remote-debugging-port=N` instead of spawning one. The browser keeps running on `close()`. Stealth + permission denial + download capture are skipped to avoid mutating the user's running browser.
|
|
98
100
|
- `proxy: 'http://...'` — HTTP/SOCKS proxy for browser
|
|
99
101
|
- `viewport: '1280x720'` — Set viewport dimensions
|
|
100
102
|
- `storageState: 'file.json'` — Load cookies/localStorage from saved state
|
|
103
|
+
- `incognito: true|false` — Default `false`. When `true`, run a clean, unauthenticated session: skips `storageState` loading AND makes `injectCookies()` a no-op, so no auth ever enters the session even though callers (MCP `goto`, daemon) inject unconditionally. The temp profile is already throwaway; this gates the *other* auth source — the user's real browser cookies. Not Chrome's `--incognito` flag. Surfaced everywhere: library, MCP (`browse` tool param + `BAREBROWSE_INCOGNITO=1` env for the session), CLI (`--incognito`), bareagent (via `connect` opts).
|
|
101
104
|
- `downloadPath: '/abs/dir'` — Where downloads land. Default: per-session `mkdtemp` under `/tmp/barebrowse-dl-*` that gets removed on `close()`. Caller-supplied paths are not cleaned up — caller owns the lifecycle.
|
|
102
105
|
- `blockAds: true|false` — CDP-level URL blocking of 128 common ad/tracker patterns (Google ads/analytics, FB/Amazon/MS/Adobe ad+analytics, Segment/Amplitude/Mixpanel/Heap/PostHog, Hotjar/FullStory/LogRocket, Criteo/Taboola/Outbrain, the consumer-pixel cluster, AppNexus/Rubicon/PubMatic supply, marketing automation; v0.10.1 added AppsFlyer/Branch/Adjust, Cloudflare Web Analytics, Matomo Cloud). Default `true` for launched browsers, `false` in attach mode (would affect any tab in the user's running browser). Explicit `true` in attach mode is honored and follows the session across `switchTab()` (regression-tested). Shrinks ARIA snapshots and speeds page loads. On legacy Chromium lacking `Network.setBlockedURLs` a one-time `console.warn` surfaces the fallback.
|
|
103
106
|
- `blockUrls: ['*://foo.com/*', ...]` — Extra glob patterns (CDP `Network.setBlockedURLs` format) to block in addition to the default. Merged with the default unless `blockAds: false`.
|
package/cli.js
CHANGED
|
@@ -108,9 +108,11 @@ async function cmdOpen() {
|
|
|
108
108
|
|
|
109
109
|
const url = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
|
|
110
110
|
const opts = {
|
|
111
|
+
engine: parseFlag('--engine'),
|
|
111
112
|
mode: parseFlag('--mode') || 'headless',
|
|
112
113
|
port: parseFlag('--port'),
|
|
113
114
|
cookies: !hasFlag('--no-cookies'),
|
|
115
|
+
incognito: hasFlag('--incognito') || undefined,
|
|
114
116
|
browser: parseFlag('--browser'),
|
|
115
117
|
timeout: parseFlag('--timeout'),
|
|
116
118
|
pruneMode: parseFlag('--prune-mode') || 'act',
|
|
@@ -201,7 +203,7 @@ async function oneShot() {
|
|
|
201
203
|
const url = args[1];
|
|
202
204
|
const mode = args[2] || 'headless';
|
|
203
205
|
try {
|
|
204
|
-
const snapshot = await browse(url, { mode });
|
|
206
|
+
const snapshot = await browse(url, { mode, incognito: hasFlag('--incognito') || undefined });
|
|
205
207
|
process.stdout.write(snapshot + '\n');
|
|
206
208
|
process.exit(0);
|
|
207
209
|
} catch (err) {
|
|
@@ -213,9 +215,11 @@ async function oneShot() {
|
|
|
213
215
|
async function runDaemonInternal() {
|
|
214
216
|
const { runDaemon } = await import('./src/daemon.js');
|
|
215
217
|
const opts = {
|
|
218
|
+
engine: parseFlag('--engine'),
|
|
216
219
|
mode: parseFlag('--mode') || 'headless',
|
|
217
220
|
port: parseFlag('--port'),
|
|
218
221
|
cookies: !hasFlag('--no-cookies'),
|
|
222
|
+
incognito: hasFlag('--incognito') || undefined,
|
|
219
223
|
browser: parseFlag('--browser'),
|
|
220
224
|
timeout: parseFlag('--timeout'),
|
|
221
225
|
pruneMode: parseFlag('--prune-mode') || 'act',
|
|
@@ -480,9 +484,13 @@ Session:
|
|
|
480
484
|
barebrowse status Check session status
|
|
481
485
|
|
|
482
486
|
Open flags:
|
|
483
|
-
--
|
|
487
|
+
--engine=chromium|firefox Browser engine (default: chromium/CDP;
|
|
488
|
+
firefox drives over WebDriver BiDi)
|
|
489
|
+
--mode=headless|headed|hybrid Browser mode (default: headless;
|
|
490
|
+
hybrid is chromium-only)
|
|
484
491
|
--port=N CDP port for headed mode
|
|
485
492
|
--no-cookies Skip cookie injection
|
|
493
|
+
--incognito Clean, unauthenticated session (skip all auth injection)
|
|
486
494
|
--browser=firefox|chromium Cookie source browser
|
|
487
495
|
--timeout=N Navigation timeout in ms
|
|
488
496
|
--prune-mode=act|read Default pruning mode
|
package/mcp-server.js
CHANGED
|
@@ -121,7 +121,18 @@ let _pageConnecting = null;
|
|
|
121
121
|
async function getPage() {
|
|
122
122
|
if (_page) return _page;
|
|
123
123
|
if (_pageConnecting) return _pageConnecting;
|
|
124
|
-
|
|
124
|
+
// Engine selection is a launch-time setting (the browser persists across
|
|
125
|
+
// tool calls), so it's an env var, not a per-request flag. Firefox is driven
|
|
126
|
+
// over WebDriver BiDi and has no hybrid fallback — default it to headless
|
|
127
|
+
// (override with BAREBROWSE_MODE=headed).
|
|
128
|
+
// Incognito is a launch-time setting like engine/mode: the session persists
|
|
129
|
+
// across tool calls, so it's an env var (BAREBROWSE_INCOGNITO=1), not a
|
|
130
|
+
// per-request flag. Skips all auth injection for a clean, logged-out session.
|
|
131
|
+
const incognito = process.env.BAREBROWSE_INCOGNITO === '1';
|
|
132
|
+
const connectOpts = process.env.BAREBROWSE_ENGINE === 'firefox'
|
|
133
|
+
? { engine: 'firefox', mode: process.env.BAREBROWSE_MODE || 'headless', incognito }
|
|
134
|
+
: { mode: 'hybrid', incognito };
|
|
135
|
+
_pageConnecting = connect(connectOpts);
|
|
125
136
|
try {
|
|
126
137
|
_page = await _pageConnecting;
|
|
127
138
|
return _page;
|
|
@@ -156,6 +167,7 @@ export const TOOLS = [
|
|
|
156
167
|
url: { type: 'string', description: 'URL to browse' },
|
|
157
168
|
mode: { type: 'string', enum: ['headless', 'headed', 'hybrid'], description: 'Browser mode (default: headless)' },
|
|
158
169
|
pruneMode: { type: 'string', enum: ['act', 'read'], description: 'Pruning mode. "act" (default) keeps interactive elements and short labels — best for clicking/filling. "read" keeps paragraphs, headings, and long text — best for articles, docs, and content extraction. If the page is content-heavy and act-mode returns mostly empty, retry with "read".' },
|
|
170
|
+
incognito: { type: 'boolean', description: 'Clean, unauthenticated session: skip cookie injection so the page loads logged-out (default: false).' },
|
|
159
171
|
maxChars: { type: 'number', description: 'Max chars to return inline. Larger snapshots are saved to .barebrowse/ and a file path is returned instead. Default: 30000.' },
|
|
160
172
|
},
|
|
161
173
|
required: ['url'],
|
|
@@ -391,7 +403,7 @@ async function handleToolCall(name, args) {
|
|
|
391
403
|
case 'browse': {
|
|
392
404
|
let timer;
|
|
393
405
|
const text = await Promise.race([
|
|
394
|
-
browse(args.url, { mode: args.mode, pruneMode: args.pruneMode }),
|
|
406
|
+
browse(args.url, { mode: args.mode, pruneMode: args.pruneMode, incognito: args.incognito }),
|
|
395
407
|
new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('browse timed out after 60s')), 60000); }),
|
|
396
408
|
]);
|
|
397
409
|
clearTimeout(timer);
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -288,25 +288,39 @@ export function cookieDomainMatch(host, cookieDomain) {
|
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
291
|
+
* Select the user's cookies that are in-scope for `url`. Shared by BOTH engines
|
|
292
|
+
* (CDP `authenticate` and Firefox/BiDi `injectCookies`) so they can't drift on
|
|
293
|
+
* scoping — a divergence here re-opens loading the entire cookie jar into an
|
|
294
|
+
* agent session. Returns the filtered cookie array; never injects.
|
|
295
|
+
*
|
|
296
|
+
* Coarse SQL pre-filter strips to a registrable-ish domain so the LIKE query
|
|
297
|
+
* returns a superset (incl. parent-domain cookies). slice(-2) is a cheap
|
|
298
|
+
* heuristic — it over-selects for multi-part eTLDs (co.uk) and as a substring
|
|
299
|
+
* match, so the precise RFC-6265 domain-match is what actually decides which
|
|
300
|
+
* cookies pass. Without it, browsing apple.com would match apple.com.evil.org
|
|
301
|
+
* and every *.co.uk site (verified).
|
|
302
|
+
* @param {string} url - URL whose host the cookies must match
|
|
303
|
+
* @param {object} [opts] - passed to extractCookies (e.g. { browser })
|
|
304
|
+
* @returns {Array<object>} cookies whose domain matches the URL host
|
|
296
305
|
*/
|
|
297
|
-
export
|
|
306
|
+
export function scopedCookiesForUrl(url, opts = {}) {
|
|
298
307
|
const fullHost = new URL(url).hostname.toLowerCase();
|
|
299
|
-
// Coarse SQL pre-filter: strip to a registrable-ish domain so the LIKE query
|
|
300
|
-
// returns a superset (incl. parent-domain cookies). slice(-2) is a cheap
|
|
301
|
-
// heuristic — it over-selects for multi-part eTLDs (co.uk) and as a substring
|
|
302
|
-
// match, so the precise RFC-6265 domain-match below is what actually decides
|
|
303
|
-
// which cookies get injected. Without it, browsing apple.com would inject
|
|
304
|
-
// cookies for apple.com.evil.org and every *.co.uk site (verified).
|
|
305
308
|
const noWww = fullHost.replace(/^www\./, '');
|
|
306
309
|
const parts = noWww.split('.');
|
|
307
310
|
const coarseDomain = parts.length > 2 ? parts.slice(-2).join('.') : noWww;
|
|
308
311
|
const candidates = extractCookies({ ...opts, domain: coarseDomain });
|
|
309
|
-
|
|
312
|
+
return candidates.filter((c) => cookieDomainMatch(fullHost, c.domain));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Extract cookies for a URL and inject them into a CDP session.
|
|
317
|
+
* Convenience function combining scopedCookiesForUrl + injectCookies.
|
|
318
|
+
* @param {object} session - CDP session handle
|
|
319
|
+
* @param {string} url - URL to extract cookies for
|
|
320
|
+
* @param {object} [opts] - Options passed to extractCookies
|
|
321
|
+
*/
|
|
322
|
+
export async function authenticate(session, url, opts = {}) {
|
|
323
|
+
const cookies = scopedCookiesForUrl(url, opts);
|
|
310
324
|
if (cookies.length > 0) {
|
|
311
325
|
await injectCookies(session, cookies);
|
|
312
326
|
}
|
|
@@ -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
|
+
}
|