barebrowse 0.13.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 CHANGED
@@ -1,6 +1,57 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
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
+
50
+ ## [0.14.0] - 2026-06-15
51
+
52
+ ### Documentation
53
+
54
+ - **README — "The bare ecosystem" section recast from a 4-column table to a Core / Optional-reach list.** Now covers all six modules — core `bareagent` · `bareguard` · `litectx`, optional reach `barebrowse` · `baremobile` · `beeperbox` — in a scannable row form that also renders cleanly on npm. README only; no package change.
4
55
 
5
56
  ## [0.13.0] - 2026-06-12
6
57
 
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,22 +238,25 @@ 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
230
245
 
231
- Four vanilla JS modules. Zero deps where possible (bareguard has one). Same API patterns.
246
+ Local-first, composable agent infrastructure. Same API patterns throughout —
247
+ mix and match, each module works standalone.
248
+
249
+ **Core** — the brain, the gate, the memory.
250
+
251
+ - **[bareagent](https://npmjs.com/package/bare-agent)** — the think→act→observe loop. *Goal in → coordinated actions out.* Replaces LangChain, CrewAI, AutoGen.
252
+ - **[bareguard](https://npmjs.com/package/bareguard)** — the single gate every action passes through. *Action in → allow / deny / ask-a-human out.* Replaces hand-rolled allowlists and scattered policy code.
253
+ - **[litectx](https://npmjs.com/package/litectx)** — tree-sitter code + memory graph with activation decay, plus lightweight context engineering (write · select · compress · isolate). *Query in → ranked context out.*
232
254
 
233
- | | [**bareagent**](https://npmjs.com/package/bare-agent) | [**barebrowse**](https://npmjs.com/package/barebrowse) | [**baremobile**](https://npmjs.com/package/baremobile) | [**bareguard**](https://npmjs.com/package/bareguard) |
234
- |---|---|---|---|---|
235
- | **Does** | Gives agents a think→act loop | Gives agents a real browser | Gives agents an Android device | Gates everything an agent does |
236
- | **How** | Goal in → coordinated actions out | URL in → pruned snapshot out | Screen in → pruned snapshot out | Action in → allow / deny / human-asked out |
237
- | **Replaces** | LangChain, CrewAI, AutoGen | Playwright, Selenium, Puppeteer | Appium, Espresso, UIAutomator2 | Hand-rolled allowlists, scattered policy code |
238
- | **Interfaces** | Library · CLI · subprocess | Library · CLI · MCP | Library · CLI · MCP | Library |
239
- | **Solo or together** | Orchestrates the others as tools | Works standalone | Works standalone | Embedded in bareagent's loop; usable by any runner |
255
+ **Optional reach** give the agent hands.
240
256
 
241
- > **Reach 50+ messengers with one Docker container via [beeperbox](https://github.com/hamr0/beeperbox)** — a headless Beeper Desktop that exposes WhatsApp, iMessage, Signal, Telegram, Slack, Discord, RCS, SMS and more as a single MCP server. Wire it through bareagent's MCP bridge; bareguard policies the invocations like any other tool (per-chat allowlists, ask patterns on destructive sends, all the usual layered defense).
257
+ - **[barebrowse](https://npmjs.com/package/barebrowse)** — a real browser for agents. *URL in pruned snapshot out.* Replaces Playwright, Selenium, Puppeteer.
258
+ - **[baremobile](https://npmjs.com/package/baremobile)** — Android + iOS device control. *Screen in → pruned snapshot out.* Replaces Appium, Espresso, XCUITest.
259
+ - **[beeperbox](https://github.com/hamr0/beeperbox)** — 50+ messaging networks via one MCP server (headless Beeper Desktop in Docker). *Chat in → unified message stream out.* Replaces Twilio, per-platform bot APIs.
242
260
 
243
261
  **What you can build:**
244
262
 
@@ -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
- --mode=headless|headed|hybrid Browser mode (default: headless)
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
- _pageConnecting = connect({ mode: 'hybrid' });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "barebrowse",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Authenticated web browsing for autonomous agents via CDP. URL in, pruned ARIA snapshot out.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/auth.js CHANGED
@@ -288,25 +288,39 @@ export function cookieDomainMatch(host, cookieDomain) {
288
288
  }
289
289
 
290
290
  /**
291
- * Extract cookies for a URL and inject them into a CDP session.
292
- * Convenience function combining extractCookies + injectCookies.
293
- * @param {object} session - CDP session handle
294
- * @param {string} url - URL to extract cookies for
295
- * @param {object} [opts] - Options passed to extractCookies
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 async function authenticate(session, url, opts = {}) {
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
- const cookies = candidates.filter((c) => cookieDomainMatch(fullHost, c.domain));
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
+ }