From 738427152f334ac155e6e6d47db5ba993eda85d0 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 22 Mar 2026 00:10:15 +0800 Subject: [PATCH] feat(browser): advanced DOM snapshot engine with 13-layer pruning pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core Changes: - New dom-snapshot.ts: 13-layer LLM-optimized DOM pruning engine - Tag filtering, SVG collapse, ad/noise detection - CSS visibility, viewport threshold, paint-order occlusion - Shadow DOM traversal, same-origin iframe extraction - BBox parent-child dedup, attribute whitelist + synthetic attrs - Table → markdown serialization - Incremental diff (mark new elements with *) - data-opencli-ref annotation for precise click/type targeting - Hidden interactive element hints (scroll-to-reveal) New APIs: - IPage.scrollTo(ref) — scroll to snapshot-identified elements - IPage.getFormState() — extract all form fields as structured JSON - scrollToRefJs(), getFormStateJs() — standalone JS generators Integration: - Page (daemon) + CDPPage (direct CDP): use new engine as primary - dom-helpers click/type: 4-layer fallback (data-opencli-ref → data-ref → CSS → index) - Exports from browser/index.ts barrel Testing: - 21 new tests for dom-snapshot engine - All 283 tests pass (29 files, 1.07s) - Split test scripts: npm test (unit only), npm run test:all (full) --- package.json | 6 +- src/browser/cdp.ts | 22 +- src/browser/dom-helpers.ts | 45 +- src/browser/dom-snapshot.test.ts | 249 ++++++ src/browser/dom-snapshot.ts | 770 ++++++++++++++++++ src/browser/index.ts | 2 + src/browser/page.ts | 38 +- .../xiaohongshu/creator-note-detail.test.ts | 2 + src/clis/xiaohongshu/creator-notes.test.ts | 2 + src/pipeline/executor.test.ts | 2 + src/types.ts | 11 +- 11 files changed, 1133 insertions(+), 16 deletions(-) create mode 100644 src/browser/dom-snapshot.test.ts create mode 100644 src/browser/dom-snapshot.ts diff --git a/package.json b/package.json index 73213915..da1b9e4f 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,9 @@ "typecheck": "tsc --noEmit", "lint": "tsc --noEmit", "prepublishOnly": "npm run build", - "test": "vitest run", - "test:site": "node scripts/test-site.mjs", - "test:watch": "vitest", + "test": "vitest run --project unit", + "test:all": "vitest run", + "test:e2e": "vitest run --project e2e", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs" diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index e5b52056..080e0f33 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -11,6 +11,7 @@ import { WebSocket, type RawData } from 'ws'; import type { IPage } from '../types.js'; import { wrapForEval } from './utils.js'; +import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js'; import { clickJs, typeTextJs, @@ -193,9 +194,16 @@ class CDPPage implements IPage { : cookies; } - async snapshot(_opts?: any): Promise { - // CDP doesn't have a built-in accessibility tree equivalent without additional setup - return '(snapshot not available in CDP mode)'; + async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean; viewportExpand?: number; maxTextLength?: number } = {}): Promise { + const snapshotJs = generateSnapshotJs({ + viewportExpand: opts.viewportExpand ?? 800, + maxDepth: Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)), + interactiveOnly: opts.interactive ?? false, + maxTextLength: opts.maxTextLength ?? 120, + includeScrollInfo: true, + bboxDedup: true, + }); + return this.evaluate(snapshotJs); } // ── Shared DOM operations (P1 fix #5 — using dom-helpers.ts) ── @@ -212,6 +220,14 @@ class CDPPage implements IPage { await this.evaluate(pressKeyJs(key)); } + async scrollTo(ref: string): Promise { + return this.evaluate(scrollToRefJs(ref)); + } + + async getFormState(): Promise { + return this.evaluate(getFormStateJs()); + } + async wait(options: any): Promise { if (typeof options === 'number') { await new Promise(resolve => setTimeout(resolve, options * 1000)); diff --git a/src/browser/dom-helpers.ts b/src/browser/dom-helpers.ts index 698e6fb6..63c07fa9 100644 --- a/src/browser/dom-helpers.ts +++ b/src/browser/dom-helpers.ts @@ -11,8 +11,21 @@ export function clickJs(ref: string): string { return ` (() => { const ref = ${safeRef}; - const el = document.querySelector('[data-ref="' + ref + '"]') - || document.querySelectorAll('a, button, input, [role="button"], [tabindex]')[parseInt(ref, 10) || 0]; + // 1. data-opencli-ref (set by snapshot engine) + let el = document.querySelector('[data-opencli-ref="' + ref + '"]'); + // 2. data-ref (legacy) + if (!el) el = document.querySelector('[data-ref="' + ref + '"]'); + // 3. CSS selector + if (!el && ref.match(/^[a-zA-Z#.\\[]/)) { + try { el = document.querySelector(ref); } catch {} + } + // 4. Numeric index into interactive elements + if (!el) { + const idx = parseInt(ref, 10); + if (!isNaN(idx)) { + el = document.querySelectorAll('a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])')[idx]; + } + } if (!el) throw new Error('Element not found: ' + ref); el.scrollIntoView({ behavior: 'instant', block: 'center' }); el.click(); @@ -28,13 +41,31 @@ export function typeTextJs(ref: string, text: string): string { return ` (() => { const ref = ${safeRef}; - const el = document.querySelector('[data-ref="' + ref + '"]') - || document.querySelectorAll('input, textarea, [contenteditable]')[parseInt(ref, 10) || 0]; + // 1. data-opencli-ref (set by snapshot engine) + let el = document.querySelector('[data-opencli-ref="' + ref + '"]'); + // 2. data-ref (legacy) + if (!el) el = document.querySelector('[data-ref="' + ref + '"]'); + // 3. CSS selector + if (!el && ref.match(/^[a-zA-Z#.\\[]/)) { + try { el = document.querySelector(ref); } catch {} + } + // 4. Numeric index into typeable elements + if (!el) { + const idx = parseInt(ref, 10); + if (!isNaN(idx)) { + el = document.querySelectorAll('input, textarea, [contenteditable="true"]')[idx]; + } + } if (!el) throw new Error('Element not found: ' + ref); el.focus(); - el.value = ${safeText}; - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); + if (el.isContentEditable) { + el.textContent = ${safeText}; + el.dispatchEvent(new Event('input', { bubbles: true })); + } else { + el.value = ${safeText}; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } return 'typed'; })() `; diff --git a/src/browser/dom-snapshot.test.ts b/src/browser/dom-snapshot.test.ts new file mode 100644 index 00000000..2a1bb99a --- /dev/null +++ b/src/browser/dom-snapshot.test.ts @@ -0,0 +1,249 @@ +/** + * Tests for dom-snapshot.ts: DOM snapshot engine. + * + * Since the engine generates JavaScript strings for in-page evaluation, + * these tests validate: + * 1. The generated code is syntactically valid JS + * 2. Options are correctly embedded + * 3. The output structure matches expected format + * 4. All features are present (Shadow DOM, iframe, table, diff, etc.) + */ + +import { describe, it, expect } from 'vitest'; +import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js'; + +describe('generateSnapshotJs', () => { + it('returns a non-empty string', () => { + const js = generateSnapshotJs(); + expect(typeof js).toBe('string'); + expect(js.length).toBeGreaterThan(100); + }); + + it('generates syntactically valid JS (can be parsed)', () => { + const js = generateSnapshotJs(); + expect(() => new Function(js)).not.toThrow(); + }); + + it('embeds default options correctly', () => { + const js = generateSnapshotJs(); + expect(js).toContain('VIEWPORT_EXPAND = 800'); + expect(js).toContain('MAX_DEPTH = 50'); + expect(js).toContain('INTERACTIVE_ONLY = false'); + expect(js).toContain('MAX_TEXT_LEN = 120'); + expect(js).toContain('INCLUDE_SCROLL_INFO = true'); + expect(js).toContain('BBOX_DEDUP = true'); + expect(js).toContain('INCLUDE_SHADOW_DOM = true'); + expect(js).toContain('INCLUDE_IFRAMES = true'); + expect(js).toContain('PAINT_ORDER_CHECK = true'); + expect(js).toContain('ANNOTATE_REFS = true'); + expect(js).toContain('REPORT_HIDDEN = true'); + expect(js).toContain('FILTER_ADS = true'); + expect(js).toContain('MARKDOWN_TABLES = true'); + expect(js).toContain('PREV_HASHES = null'); + }); + + it('embeds custom options correctly', () => { + const js = generateSnapshotJs({ + viewportExpand: 2000, + maxDepth: 30, + interactiveOnly: true, + maxTextLength: 200, + includeScrollInfo: false, + bboxDedup: false, + includeShadowDom: false, + includeIframes: false, + maxIframes: 3, + paintOrderCheck: false, + annotateRefs: false, + reportHidden: false, + filterAds: false, + markdownTables: false, + }); + expect(js).toContain('VIEWPORT_EXPAND = 2000'); + expect(js).toContain('MAX_DEPTH = 30'); + expect(js).toContain('INTERACTIVE_ONLY = true'); + expect(js).toContain('MAX_TEXT_LEN = 200'); + expect(js).toContain('INCLUDE_SCROLL_INFO = false'); + expect(js).toContain('BBOX_DEDUP = false'); + expect(js).toContain('INCLUDE_SHADOW_DOM = false'); + expect(js).toContain('INCLUDE_IFRAMES = false'); + expect(js).toContain('MAX_IFRAMES = 3'); + expect(js).toContain('PAINT_ORDER_CHECK = false'); + expect(js).toContain('ANNOTATE_REFS = false'); + expect(js).toContain('REPORT_HIDDEN = false'); + expect(js).toContain('FILTER_ADS = false'); + expect(js).toContain('MARKDOWN_TABLES = false'); + }); + + it('clamps maxDepth between 1 and 200', () => { + expect(generateSnapshotJs({ maxDepth: -5 })).toContain('MAX_DEPTH = 1'); + expect(generateSnapshotJs({ maxDepth: 999 })).toContain('MAX_DEPTH = 200'); + expect(generateSnapshotJs({ maxDepth: 75 })).toContain('MAX_DEPTH = 75'); + }); + + it('wraps output as an IIFE', () => { + const js = generateSnapshotJs(); + expect(js.startsWith('(() =>')).toBe(true); + expect(js.trimEnd().endsWith(')()')).toBe(true); + }); + + it('embeds previousHashes for incremental diff', () => { + const hashes = JSON.stringify(['12345', '67890']); + const js = generateSnapshotJs({ previousHashes: hashes }); + expect(js).toContain('new Set(["12345","67890"])'); + }); + + it('includes all core features in generated code', () => { + const js = generateSnapshotJs(); + + // Tag filtering + expect(js).toContain('SKIP_TAGS'); + expect(js).toContain("'script'"); + expect(js).toContain("'style'"); + + // SVG collapsing + expect(js).toContain('SVG_CHILDREN'); + + // Interactive detection + expect(js).toContain('INTERACTIVE_TAGS'); + expect(js).toContain('INTERACTIVE_ROLES'); + expect(js).toContain('isInteractive'); + + // Visibility + expect(js).toContain('isVisibleByCSS'); + expect(js).toContain('isInExpandedViewport'); + + // BBox dedup + expect(js).toContain('isContainedBy'); + expect(js).toContain('PROPAGATING_TAGS'); + + // Shadow DOM + expect(js).toContain('shadowRoot'); + expect(js).toContain('|shadow|'); + + // iframe + expect(js).toContain('walkIframe'); + expect(js).toContain('|iframe|'); + + // Paint order + expect(js).toContain('isOccludedByOverlay'); + expect(js).toContain('elementFromPoint'); + + // Ad filtering + expect(js).toContain('isAdElement'); + expect(js).toContain('AD_PATTERNS'); + + // data-ref annotation + expect(js).toContain('data-opencli-ref'); + + // Hidden elements report + expect(js).toContain('hiddenInteractives'); + expect(js).toContain('hidden_interactive'); + + // Incremental diff + expect(js).toContain('hashElement'); + expect(js).toContain('currentHashes'); + expect(js).toContain('__opencli_prev_hashes'); + + // Table serialization + expect(js).toContain('serializeTable'); + expect(js).toContain('|table|'); + + // Synthetic attributes + expect(js).toContain("'YYYY-MM-DD'"); + expect(js).toContain('value=••••'); + + // Page metadata + expect(js).toContain('location.href'); + expect(js).toContain('document.title'); + }); + + it('contains proper attribute whitelist', () => { + const js = generateSnapshotJs(); + const expectedAttrs = [ + 'aria-label', 'aria-expanded', 'aria-checked', 'aria-selected', + 'placeholder', 'href', 'role', 'data-testid', 'autocomplete', + ]; + for (const attr of expectedAttrs) { + expect(js).toContain(`'${attr}'`); + } + }); + + it('includes scroll info formatting', () => { + const js = generateSnapshotJs(); + expect(js).toContain('scrollHeight'); + expect(js).toContain('scrollTop'); + expect(js).toContain('|scroll|'); + expect(js).toContain('page_scroll'); + }); +}); + +describe('scrollToRefJs', () => { + it('generates valid JS', () => { + const js = scrollToRefJs('42'); + expect(() => new Function(js)).not.toThrow(); + }); + + it('targets data-opencli-ref', () => { + const js = scrollToRefJs('7'); + expect(js).toContain('data-opencli-ref'); + expect(js).toContain('scrollIntoView'); + expect(js).toContain('"7"'); + }); + + it('falls back to data-ref', () => { + const js = scrollToRefJs('3'); + expect(js).toContain('data-ref'); + }); + + it('returns scrolled info', () => { + const js = scrollToRefJs('1'); + expect(js).toContain('scrolled: true'); + expect(js).toContain('tag:'); + }); +}); + +describe('getFormStateJs', () => { + it('generates valid JS', () => { + const js = getFormStateJs(); + expect(() => new Function(js)).not.toThrow(); + }); + + it('collects form elements', () => { + const js = getFormStateJs(); + expect(js).toContain('document.forms'); + expect(js).toContain('form.elements'); + }); + + it('collects orphan fields', () => { + const js = getFormStateJs(); + expect(js).toContain('orphanFields'); + expect(js).toContain('el.form'); + }); + + it('handles different input types', () => { + const js = getFormStateJs(); + expect(js).toContain('checkbox'); + expect(js).toContain('radio'); + expect(js).toContain('password'); + expect(js).toContain('contenteditable'); + }); + + it('extracts labels', () => { + const js = getFormStateJs(); + expect(js).toContain('aria-label'); + expect(js).toContain('label[for='); + expect(js).toContain('closest'); + expect(js).toContain('placeholder'); + }); + + it('masks passwords', () => { + const js = getFormStateJs(); + expect(js).toContain('••••'); + }); + + it('includes data-opencli-ref in output', () => { + const js = getFormStateJs(); + expect(js).toContain('data-opencli-ref'); + }); +}); diff --git a/src/browser/dom-snapshot.ts b/src/browser/dom-snapshot.ts new file mode 100644 index 00000000..a4b77fdd --- /dev/null +++ b/src/browser/dom-snapshot.ts @@ -0,0 +1,770 @@ +/** + * DOM Snapshot Engine — Advanced DOM pruning for LLM consumption. + * + * Inspired by browser-use's multi-layer pruning pipeline, adapted for opencli's + * Chrome Extension + CDP architecture. Runs entirely in-page via Runtime.evaluate. + * + * Pipeline: + * 1. Walk DOM tree, collect visibility + layout + interactivity signals + * 2. Prune invisible, zero-area, non-content elements + * 3. SVG & decoration collapse + * 4. Shadow DOM traversal + * 5. Same-origin iframe content extraction + * 6. Bounding-box parent-child dedup (link/button wrapping children) + * 7. Paint-order occlusion detection (overlay/modal coverage) + * 8. Attribute whitelist filtering + * 9. Table-aware serialization (markdown tables) + * 10. Token-efficient serialization with interactive indices + * 11. data-ref annotation for click/type targeting + * 12. Hidden interactive element hints (scroll-to-reveal) + * 13. Incremental diff (mark new elements with *) + * + * Additional tools: + * - scrollToRefJs(ref) — scroll to a data-opencli-ref element + * - getFormStateJs() — extract all form fields as structured JSON + */ + +// ─── Types ─────────────────────────────────────────────────────────── + +export interface SnapshotOptions { + /** Extra pixels beyond viewport to include (default 800) */ + viewportExpand?: number; + /** Maximum DOM depth to traverse (default 50) */ + maxDepth?: number; + /** Only emit interactive elements and their landmark ancestors */ + interactiveOnly?: boolean; + /** Maximum text content length per node (default 120) */ + maxTextLength?: number; + /** Include scroll position info on scrollable containers (default true) */ + includeScrollInfo?: boolean; + /** Enable bounding-box parent-child dedup (default true) */ + bboxDedup?: boolean; + /** Traverse Shadow DOM roots (default true) */ + includeShadowDom?: boolean; + /** Extract same-origin iframe content (default true) */ + includeIframes?: boolean; + /** Maximum number of iframes to process (default 5) */ + maxIframes?: number; + /** Enable paint-order occlusion detection (default true) */ + paintOrderCheck?: boolean; + /** Annotate interactive elements with data-opencli-ref (default true) */ + annotateRefs?: boolean; + /** Report hidden interactive elements outside viewport (default true) */ + reportHidden?: boolean; + /** Filter ad/noise elements (default true) */ + filterAds?: boolean; + /** Serialize tables as markdown (default true) */ + markdownTables?: boolean; + /** Previous snapshot hash set (JSON array of hashes) for diff marking (default null) */ + previousHashes?: string | null; +} + +// ─── Utility JS Generators ─────────────────────────────────────────── + +/** + * Generate JS to scroll to an element identified by data-opencli-ref. + * Completes the snapshot→action loop: snapshot identifies `[3] + * |scroll|
(0.5↑ 3.2↓) + * *[58]Result 1 + * [59]Result 2 + * + * - `[id]` — interactive element with backend index for targeting + * - `*` prefix — newly appeared element (incremental diff) + * - `|scroll|` — scrollable container with page counts + * - `|shadow|` — Shadow DOM boundary + * - `|iframe|` — iframe content + * - `|table|` — markdown table rendering + */ +export function generateSnapshotJs(opts: SnapshotOptions = {}): string { + const viewportExpand = opts.viewportExpand ?? 800; + const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? 50, 200)); + const interactiveOnly = opts.interactiveOnly ?? false; + const maxTextLength = opts.maxTextLength ?? 120; + const includeScrollInfo = opts.includeScrollInfo ?? true; + const bboxDedup = opts.bboxDedup ?? true; + const includeShadowDom = opts.includeShadowDom ?? true; + const includeIframes = opts.includeIframes ?? true; + const maxIframes = opts.maxIframes ?? 5; + const paintOrderCheck = opts.paintOrderCheck ?? true; + const annotateRefs = opts.annotateRefs ?? true; + const reportHidden = opts.reportHidden ?? true; + const filterAds = opts.filterAds ?? true; + const markdownTables = opts.markdownTables ?? true; + const previousHashes = opts.previousHashes ?? null; + + return ` +(() => { + 'use strict'; + + // ── Config ───────────────────────────────────────────────────────── + const VIEWPORT_EXPAND = ${viewportExpand}; + const MAX_DEPTH = ${maxDepth}; + const INTERACTIVE_ONLY = ${interactiveOnly}; + const MAX_TEXT_LEN = ${maxTextLength}; + const INCLUDE_SCROLL_INFO = ${includeScrollInfo}; + const BBOX_DEDUP = ${bboxDedup}; + const INCLUDE_SHADOW_DOM = ${includeShadowDom}; + const INCLUDE_IFRAMES = ${includeIframes}; + const MAX_IFRAMES = ${maxIframes}; + const PAINT_ORDER_CHECK = ${paintOrderCheck}; + const ANNOTATE_REFS = ${annotateRefs}; + const REPORT_HIDDEN = ${reportHidden}; + const FILTER_ADS = ${filterAds}; + const MARKDOWN_TABLES = ${markdownTables}; + const PREV_HASHES = ${previousHashes ? `new Set(${previousHashes})` : 'null'}; + + // ── Constants ────────────────────────────────────────────────────── + + const SKIP_TAGS = new Set([ + 'script', 'style', 'noscript', 'link', 'meta', 'head', + 'template', 'br', 'wbr', 'col', 'colgroup', + ]); + + const SVG_CHILDREN = new Set([ + 'path', 'rect', 'g', 'circle', 'ellipse', 'line', 'polyline', + 'polygon', 'use', 'defs', 'clippath', 'mask', 'pattern', + 'text', 'tspan', 'lineargradient', 'radialgradient', 'stop', + 'filter', 'fegaussianblur', 'fecolormatrix', 'feblend', + 'symbol', 'marker', 'foreignobject', 'desc', 'title', + ]); + + const INTERACTIVE_TAGS = new Set([ + 'a', 'button', 'input', 'select', 'textarea', 'details', + 'summary', 'option', 'optgroup', + ]); + + const INTERACTIVE_ROLES = new Set([ + 'button', 'link', 'menuitem', 'option', 'radio', 'checkbox', + 'tab', 'textbox', 'combobox', 'slider', 'spinbutton', + 'searchbox', 'switch', 'menuitemcheckbox', 'menuitemradio', + 'treeitem', 'gridcell', 'row', + ]); + + const LANDMARK_ROLES = new Set([ + 'main', 'navigation', 'banner', 'search', 'region', + 'complementary', 'contentinfo', 'form', 'dialog', + ]); + + const LANDMARK_TAGS = new Set([ + 'nav', 'main', 'header', 'footer', 'aside', 'form', + 'search', 'dialog', 'section', 'article', + ]); + + const ATTR_WHITELIST = new Set([ + 'id', 'name', 'type', 'value', 'placeholder', 'title', 'alt', + 'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-selected', + 'aria-disabled', 'aria-valuemin', 'aria-valuemax', 'aria-valuenow', + 'aria-haspopup', 'aria-live', 'aria-required', + 'href', 'src', 'action', 'method', 'for', 'checked', 'selected', + 'disabled', 'required', 'multiple', 'accept', 'min', 'max', + 'pattern', 'maxlength', 'minlength', 'data-testid', 'data-test', + 'contenteditable', 'tabindex', 'autocomplete', + ]); + + const PROPAGATING_TAGS = new Set(['a', 'button']); + + const AD_PATTERNS = [ + 'googleadservices.com', 'doubleclick.net', 'googlesyndication.com', + 'facebook.com/tr', 'analytics.google.com', 'connect.facebook.net', + 'ad.doubleclick', 'pagead', 'adsense', + ]; + + const AD_SELECTOR_RE = /\\b(ad[_-]?(?:banner|container|wrapper|slot|unit|block|frame|leaderboard|sidebar)|google[_-]?ad|sponsored|adsbygoogle|banner[_-]?ad)\\b/i; + + // ── Viewport & Layout Helpers ────────────────────────────────────── + + const vw = window.innerWidth; + const vh = window.innerHeight; + + function isInExpandedViewport(rect) { + if (!rect || (rect.width === 0 && rect.height === 0)) return false; + return rect.bottom > -VIEWPORT_EXPAND && rect.top < vh + VIEWPORT_EXPAND && + rect.right > -VIEWPORT_EXPAND && rect.left < vw + VIEWPORT_EXPAND; + } + + function isVisibleByCSS(el) { + const style = el.style; + if (style.display === 'none') return false; + if (style.visibility === 'hidden' || style.visibility === 'collapse') return false; + if (style.opacity === '0') return false; + try { + const cs = window.getComputedStyle(el); + if (cs.display === 'none') return false; + if (cs.visibility === 'hidden') return false; + if (parseFloat(cs.opacity) <= 0) return false; + if (cs.clip === 'rect(0px, 0px, 0px, 0px)' && cs.position === 'absolute') return false; + if (cs.overflow === 'hidden' && el.offsetWidth === 0 && el.offsetHeight === 0) return false; + } catch {} + return true; + } + + // ── Paint Order Occlusion ────────────────────────────────────────── + + function isOccludedByOverlay(el) { + if (!PAINT_ORDER_CHECK) return false; + try { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return false; + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + if (cx < 0 || cy < 0 || cx > vw || cy > vh) return false; + const topEl = document.elementFromPoint(cx, cy); + if (!topEl || topEl === el || el.contains(topEl) || topEl.contains(el)) return false; + const cs = window.getComputedStyle(topEl); + if (parseFloat(cs.opacity) < 0.5) return false; + const bg = cs.backgroundColor; + if (bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') return false; + return true; + } catch { return false; } + } + + // ── Ad/Noise Detection ───────────────────────────────────────────── + + function isAdElement(el) { + if (!FILTER_ADS) return false; + try { + const id = el.id || ''; + const cls = el.className || ''; + const testStr = id + ' ' + (typeof cls === 'string' ? cls : ''); + if (AD_SELECTOR_RE.test(testStr)) return true; + if (el.tagName === 'IFRAME') { + const src = el.src || ''; + for (const p of AD_PATTERNS) { if (src.includes(p)) return true; } + } + if (el.hasAttribute('data-ad') || el.hasAttribute('data-ad-slot') || + el.hasAttribute('data-adunit') || el.hasAttribute('data-google-query-id')) return true; + } catch {} + return false; + } + + // ── Interactivity Detection ──────────────────────────────────────── + + function isInteractive(el) { + const tag = el.tagName.toLowerCase(); + if (INTERACTIVE_TAGS.has(tag)) { + if (tag === 'label' && el.hasAttribute('for')) return false; + if (el.disabled && (tag === 'button' || tag === 'input')) return false; + return true; + } + const role = el.getAttribute('role'); + if (role && INTERACTIVE_ROLES.has(role)) return true; + if (el.hasAttribute('onclick') || el.hasAttribute('onmousedown') || el.hasAttribute('ontouchstart')) return true; + if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') !== '-1') return true; + try { if (window.getComputedStyle(el).cursor === 'pointer') return true; } catch {} + if (el.isContentEditable && el.getAttribute('contenteditable') !== 'false') return true; + return false; + } + + function isLandmark(el) { + const role = el.getAttribute('role'); + if (role && LANDMARK_ROLES.has(role)) return true; + return LANDMARK_TAGS.has(el.tagName.toLowerCase()); + } + + // ── Scrollability Detection ──────────────────────────────────────── + + function getScrollInfo(el) { + if (!INCLUDE_SCROLL_INFO) return null; + const sh = el.scrollHeight, ch = el.clientHeight; + const sw = el.scrollWidth, cw = el.clientWidth; + const isV = sh > ch + 5, isH = sw > cw + 5; + if (!isV && !isH) return null; + try { + const cs = window.getComputedStyle(el); + const scrollable = ['auto', 'scroll', 'overlay']; + const tag = el.tagName.toLowerCase(); + const isBody = tag === 'body' || tag === 'html'; + if (isV && !isBody && !scrollable.includes(cs.overflowY)) return null; + const info = {}; + if (isV) { + const above = ch > 0 ? +(el.scrollTop / ch).toFixed(1) : 0; + const below = ch > 0 ? +((sh - ch - el.scrollTop) / ch).toFixed(1) : 0; + if (above > 0 || below > 0) info.v = { above, below }; + } + if (isH && scrollable.includes(cs.overflowX)) { + info.h = { pct: cw > 0 ? Math.round(el.scrollLeft / (sw - cw) * 100) : 0 }; + } + return Object.keys(info).length > 0 ? info : null; + } catch { return null; } + } + + // ── BBox Containment Check ───────────────────────────────────────── + + function isContainedBy(childRect, parentRect, threshold) { + if (!childRect || !parentRect) return false; + const cArea = childRect.width * childRect.height; + if (cArea === 0) return false; + const xO = Math.max(0, Math.min(childRect.right, parentRect.right) - Math.max(childRect.left, parentRect.left)); + const yO = Math.max(0, Math.min(childRect.bottom, parentRect.bottom) - Math.max(childRect.top, parentRect.top)); + return (xO * yO) / cArea >= threshold; + } + + // ── Text Helpers ─────────────────────────────────────────────────── + + function getDirectText(el) { + let text = ''; + for (const child of el.childNodes) { + if (child.nodeType === 3) { + const t = child.textContent.trim(); + if (t) text += (text ? ' ' : '') + t; + } + } + return text; + } + + function capText(s) { + if (!s) return ''; + const t = s.replace(/\\s+/g, ' ').trim(); + return t.length > MAX_TEXT_LEN ? t.slice(0, MAX_TEXT_LEN) + '…' : t; + } + + // ── Element Hashing (for incremental diff) ───────────────────────── + + function hashElement(el) { + // Simple hash: tag + id + className + textContent prefix + const tag = el.tagName || ''; + const id = el.id || ''; + const cls = (typeof el.className === 'string' ? el.className : '').slice(0, 50); + const text = (el.textContent || '').trim().slice(0, 40); + const s = tag + '|' + id + '|' + cls + '|' + text; + let h = 0; + for (let i = 0; i < s.length; i++) { + h = ((h << 5) - h + s.charCodeAt(i)) | 0; + } + return '' + (h >>> 0); // unsigned + } + + // ── Attribute Serialization ──────────────────────────────────────── + + function serializeAttrs(el) { + const parts = []; + for (const attr of el.attributes) { + if (!ATTR_WHITELIST.has(attr.name)) continue; + let val = attr.value.trim(); + if (!val) continue; + if (val.length > 120) val = val.slice(0, 100) + '…'; + if (attr.name === 'type' && val.toLowerCase() === el.tagName.toLowerCase()) continue; + if (attr.name === 'value' && el.getAttribute('type') === 'password') { parts.push('value=••••'); continue; } + if (attr.name === 'href') { + if (val.startsWith('javascript:')) continue; + try { + const u = new URL(val, location.origin); + if (u.origin === location.origin) val = u.pathname + u.search + u.hash; + } catch {} + } + parts.push(attr.name + '=' + val); + } + // Synthetic attributes + const tag = el.tagName; + if (tag === 'INPUT') { + const type = (el.getAttribute('type') || 'text').toLowerCase(); + const fmts = { 'date':'YYYY-MM-DD', 'time':'HH:MM', 'datetime-local':'YYYY-MM-DDTHH:MM', 'month':'YYYY-MM', 'week':'YYYY-W##' }; + if (fmts[type]) parts.push('format=' + fmts[type]); + if (['text','email','tel','url','search','number','date','time','datetime-local','month','week'].includes(type)) { + if (el.value && !parts.some(p => p.startsWith('value='))) parts.push('value=' + capText(el.value)); + } + if (type === 'password' && el.value && !parts.some(p => p.startsWith('value='))) parts.push('value=••••'); + if ((type === 'checkbox' || type === 'radio') && el.checked && !parts.some(p => p.startsWith('checked'))) parts.push('checked'); + if (type === 'file' && el.files && el.files.length > 0) parts.push('files=' + Array.from(el.files).map(f => f.name).join(',')); + } + if (tag === 'TEXTAREA' && el.value && !parts.some(p => p.startsWith('value='))) parts.push('value=' + capText(el.value)); + if (tag === 'SELECT') { + const sel = el.options?.[el.selectedIndex]; + if (sel && !parts.some(p => p.startsWith('value='))) parts.push('value=' + capText(sel.textContent)); + const optEls = Array.from(el.options || []).slice(0, 6); + if (optEls.length > 0) { + const ot = optEls.map(o => capText(o.textContent).slice(0, 30)); + if (el.options.length > 6) ot.push('…' + (el.options.length - 6) + ' more'); + parts.push('options=[' + ot.join('|') + ']'); + } + } + return parts.join(' '); + } + + // ── Table → Markdown Serialization ───────────────────────────────── + + function serializeTable(table, depth) { + if (!MARKDOWN_TABLES) return false; + try { + const rows = table.querySelectorAll('tr'); + if (rows.length === 0 || rows.length > 50) return false; // skip huge tables + const grid = []; + let maxCols = 0; + for (const row of rows) { + const cells = []; + for (const cell of row.querySelectorAll('th, td')) { + let text = capText(cell.textContent || ''); + // Include interactive elements in cells + const links = cell.querySelectorAll('a[href]'); + if (links.length === 1 && text) { + const href = links[0].getAttribute('href'); + if (href && !href.startsWith('javascript:')) { + try { + const u = new URL(href, location.origin); + text = '[' + text + '](' + (u.origin === location.origin ? u.pathname + u.search : href) + ')'; + } catch { text = '[' + text + '](' + href + ')'; } + } + } + cells.push(text || ''); + } + if (cells.length > 0) { + grid.push(cells); + if (cells.length > maxCols) maxCols = cells.length; + } + } + if (grid.length < 2 || maxCols === 0) return false; // need at least header + 1 row + // Pad rows to maxCols + for (const row of grid) { while (row.length < maxCols) row.push(''); } + // Compute column widths + const widths = []; + for (let c = 0; c < maxCols; c++) { + let w = 3; + for (const row of grid) { if (row[c].length > w) w = Math.min(row[c].length, 40); } + widths.push(w); + } + const indent = ' '.repeat(depth); + const tableLines = []; + // Header + tableLines.push(indent + '| ' + grid[0].map((c, i) => c.padEnd(widths[i])).join(' | ') + ' |'); + tableLines.push(indent + '| ' + widths.map(w => '-'.repeat(w)).join(' | ') + ' |'); + // Body + for (let r = 1; r < grid.length; r++) { + tableLines.push(indent + '| ' + grid[r].map((c, i) => c.padEnd(widths[i])).join(' | ') + ' |'); + } + return tableLines; + } catch { return false; } + } + + // ── Main Tree Walk ───────────────────────────────────────────────── + + let interactiveIndex = 0; + const lines = []; + const hiddenInteractives = []; + const currentHashes = []; + let iframeCount = 0; + + function walk(el, depth, parentPropagatingRect) { + if (depth > MAX_DEPTH) return false; + if (el.nodeType !== 1) return false; + + const tag = el.tagName.toLowerCase(); + if (SKIP_TAGS.has(tag)) return false; + if (isAdElement(el)) return false; + + // SVG: emit tag, collapse children + if (tag === 'svg') { + const attrs = serializeAttrs(el); + const interactive = isInteractive(el); + let prefix = ''; + if (interactive) { + interactiveIndex++; + if (ANNOTATE_REFS) el.setAttribute('data-opencli-ref', '' + interactiveIndex); + prefix = '[' + interactiveIndex + ']'; + } + lines.push(' '.repeat(depth) + prefix + ''); + return interactive; + } + if (SVG_CHILDREN.has(tag)) return false; + + // Table: try markdown serialization before generic walk + if (tag === 'table' && MARKDOWN_TABLES) { + const tableLines = serializeTable(el, depth); + if (tableLines) { + const indent = ' '.repeat(depth); + lines.push(indent + '|table|'); + for (const tl of tableLines) lines.push(tl); + return false; // tables usually non-interactive + } + // Fall through to generic walk if markdown failed + } + + // iframe handling + if (tag === 'iframe' && INCLUDE_IFRAMES && iframeCount < MAX_IFRAMES) { + return walkIframe(el, depth); + } + + // Visibility check + let rect; + try { rect = el.getBoundingClientRect(); } catch { return false; } + const hasArea = rect.width > 0 && rect.height > 0; + if (hasArea && !isVisibleByCSS(el)) { + if (!(tag === 'input' && el.type === 'file')) return false; + } + + const interactive = isInteractive(el); + + // Viewport threshold pruning + if (hasArea && !isInExpandedViewport(rect)) { + if (interactive && REPORT_HIDDEN) { + const scrollDist = rect.top > vh ? rect.top - vh : -rect.bottom; + const pagesAway = Math.abs(scrollDist / vh).toFixed(1); + const direction = rect.top > vh ? 'below' : 'above'; + const text = capText(getDirectText(el) || el.getAttribute('aria-label') || el.getAttribute('title') || ''); + hiddenInteractives.push({ tag, text, direction, pagesAway }); + } + return false; + } + + // Paint order occlusion + if (interactive && hasArea && isOccludedByOverlay(el)) return false; + + const landmark = isLandmark(el); + const scrollInfo = getScrollInfo(el); + const isScrollable = scrollInfo !== null; + + // BBox dedup + let excludedByParent = false; + if (BBOX_DEDUP && parentPropagatingRect && !interactive) { + if (hasArea && isContainedBy(rect, parentPropagatingRect, 0.95)) { + const hasSemantic = el.hasAttribute('aria-label') || + (el.getAttribute('role') && INTERACTIVE_ROLES.has(el.getAttribute('role'))); + if (!hasSemantic && !['input','select','textarea','label'].includes(tag)) { + excludedByParent = true; + } + } + } + + let propagateRect = parentPropagatingRect; + if (BBOX_DEDUP && PROPAGATING_TAGS.has(tag) && hasArea) propagateRect = rect; + + // Process children + const origLen = lines.length; + let hasInteractiveDescendant = false; + + for (const child of el.children) { + const r = walk(child, depth + 1, propagateRect); + if (r) hasInteractiveDescendant = true; + } + + // Shadow DOM + if (INCLUDE_SHADOW_DOM && el.shadowRoot) { + const shadowOrigLen = lines.length; + for (const child of el.shadowRoot.children) { + const r = walk(child, depth + 1, propagateRect); + if (r) hasInteractiveDescendant = true; + } + if (lines.length > shadowOrigLen) { + lines.splice(shadowOrigLen, 0, ' '.repeat(depth + 1) + '|shadow|'); + } + } + + const childLinesCount = lines.length - origLen; + const text = capText(getDirectText(el)); + + // Decide whether to emit + if (INTERACTIVE_ONLY && !interactive && !landmark && !hasInteractiveDescendant && !text) { + lines.length = origLen; + return false; + } + if (excludedByParent && !interactive && !isScrollable) return hasInteractiveDescendant; + if (!interactive && !isScrollable && !text && childLinesCount === 0 && !landmark) return false; + + // ── Emit node ──────────────────────────────────────────────────── + const indent = ' '.repeat(depth); + let line = indent; + + // Incremental diff: mark new elements with * + if (PREV_HASHES) { + const h = hashElement(el); + currentHashes.push(h); + if (!PREV_HASHES.has(h)) line += '*'; + } else { + currentHashes.push(hashElement(el)); + } + + // Scroll marker + if (isScrollable && !interactive) line += '|scroll|'; + + // Interactive index + data-ref + if (interactive) { + interactiveIndex++; + if (ANNOTATE_REFS) el.setAttribute('data-opencli-ref', '' + interactiveIndex); + line += isScrollable ? '|scroll[' + interactiveIndex + ']|' : '[' + interactiveIndex + ']'; + } + + // Tag + attributes + const attrs = serializeAttrs(el); + line += '<' + tag; + if (attrs) line += ' ' + attrs; + + // Scroll info suffix, inline text, or self-close + if (isScrollable && scrollInfo) { + const parts = []; + if (scrollInfo.v) parts.push(scrollInfo.v.above + '↑ ' + scrollInfo.v.below + '↓'); + if (scrollInfo.h) parts.push('h:' + scrollInfo.h.pct + '%'); + line += ' /> (' + parts.join(', ') + ')'; + } else if (text && childLinesCount === 0) { + line += '>' + text + ''; + } else { + line += ' />'; + } + + lines.splice(origLen, 0, line); + if (text && childLinesCount > 0) lines.splice(origLen + 1, 0, indent + ' ' + text); + + return interactive || hasInteractiveDescendant; + } + + // ── iframe Processing ────────────────────────────────────────────── + + function walkIframe(el, depth) { + const indent = ' '.repeat(depth); + try { + const doc = el.contentDocument; + if (!doc || !doc.body) { + const attrs = serializeAttrs(el); + lines.push(indent + '|iframe| (cross-origin)'); + return false; + } + iframeCount++; + const attrs = serializeAttrs(el); + lines.push(indent + '|iframe|'); + let has = false; + for (const child of doc.body.children) { + if (walk(child, depth + 1, null)) has = true; + } + return has; + } catch { + const attrs = serializeAttrs(el); + lines.push(indent + '|iframe| (blocked)'); + return false; + } + } + + // ── Entry Point ──────────────────────────────────────────────────── + + lines.push('url: ' + location.href); + lines.push('title: ' + document.title); + lines.push('viewport: ' + vw + 'x' + vh); + const pageScrollInfo = getScrollInfo(document.documentElement) || getScrollInfo(document.body); + if (pageScrollInfo && pageScrollInfo.v) { + lines.push('page_scroll: ' + pageScrollInfo.v.above + '↑ ' + pageScrollInfo.v.below + '↓'); + } + lines.push('---'); + + const root = document.body || document.documentElement; + if (root) walk(root, 0, null); + + // Hidden interactive elements hint + if (REPORT_HIDDEN && hiddenInteractives.length > 0) { + lines.push('---'); + lines.push('hidden_interactive (' + hiddenInteractives.length + '):'); + const shown = hiddenInteractives.slice(0, 10); + for (const h of shown) { + const label = h.text ? ' "' + h.text + '"' : ''; + lines.push(' <' + h.tag + '>' + label + ' ~' + h.pagesAway + ' pages ' + h.direction); + } + if (hiddenInteractives.length > 10) lines.push(' …' + (hiddenInteractives.length - 10) + ' more'); + } + + // Footer + lines.push('---'); + lines.push('interactive: ' + interactiveIndex + ' | iframes: ' + iframeCount); + + // Store hashes on window for next diff snapshot + try { window.__opencli_prev_hashes = JSON.stringify(currentHashes); } catch {} + + return lines.join('\\n'); +})() + `.trim(); +} diff --git a/src/browser/index.ts b/src/browser/index.ts index 4ee3a93f..ce9b8618 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -9,6 +9,8 @@ export { Page } from './page.js'; export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js'; export { CDPBridge } from './cdp.js'; export { isDaemonRunning } from './daemon-client.js'; +export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js'; +export type { SnapshotOptions } from './dom-snapshot.js'; import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js'; import { __test__ as cdpTest } from './cdp.js'; diff --git a/src/browser/page.ts b/src/browser/page.ts index d16adfb1..ddb139ec 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -14,6 +14,7 @@ import { formatSnapshot } from '../snapshotFormatter.js'; import type { IPage } from '../types.js'; import { sendCommand } from './daemon-client.js'; import { wrapForEval } from './utils.js'; +import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js'; import { clickJs, typeTextJs, @@ -79,7 +80,30 @@ export class Page implements IPage { return Array.isArray(result) ? result : []; } - async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise { + async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean; viewportExpand?: number; maxTextLength?: number } = {}): Promise { + // Primary: use the advanced DOM snapshot engine with multi-layer pruning + const snapshotJs = generateSnapshotJs({ + viewportExpand: opts.viewportExpand ?? 800, + maxDepth: Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)), + interactiveOnly: opts.interactive ?? false, + maxTextLength: opts.maxTextLength ?? 120, + includeScrollInfo: true, + bboxDedup: true, + }); + + try { + const result = await sendCommand('exec', { code: snapshotJs, ...this._workspaceOpt(), ...this._tabOpt() }); + // The advanced engine already produces a clean, pruned, LLM-friendly output. + // Do NOT pass through formatSnapshot — its format is incompatible. + return result; + } catch { + // Fallback: basic DOM snapshot (original implementation) + return this._basicSnapshot(opts); + } + } + + /** Fallback basic snapshot — original buildTree approach */ + private async _basicSnapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise { const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)); const code = ` (async () => { @@ -93,7 +117,7 @@ export class Page implements IPage { let indent = ' '.repeat(depth); let line = indent + role; - if (name) line += ' "' + name.replace(/"/g, '\\\\"') + '"'; + if (name) line += ' "' + name.replace(/"/g, '\\\\\\"') + '"'; if (node.tagName?.toLowerCase() === 'a' && node.href) line += ' [' + node.href + ']'; if (node.tagName?.toLowerCase() === 'input') line += ' [' + (node.type || 'text') + ']'; @@ -129,6 +153,16 @@ export class Page implements IPage { await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() }); } + async scrollTo(ref: string): Promise { + const code = scrollToRefJs(ref); + return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() }); + } + + async getFormState(): Promise { + const code = getFormStateJs(); + return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() }); + } + async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise { if (typeof options === 'number') { await new Promise(resolve => setTimeout(resolve, options * 1000)); diff --git a/src/clis/xiaohongshu/creator-note-detail.test.ts b/src/clis/xiaohongshu/creator-note-detail.test.ts index aa527ff3..e40ae0db 100644 --- a/src/clis/xiaohongshu/creator-note-detail.test.ts +++ b/src/clis/xiaohongshu/creator-note-detail.test.ts @@ -18,6 +18,8 @@ function createPageMock(evaluateResult: any): IPage { click: vi.fn().mockResolvedValue(undefined), typeText: vi.fn().mockResolvedValue(undefined), pressKey: vi.fn().mockResolvedValue(undefined), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), wait: vi.fn().mockResolvedValue(undefined), tabs: vi.fn().mockResolvedValue([]), closeTab: vi.fn().mockResolvedValue(undefined), diff --git a/src/clis/xiaohongshu/creator-notes.test.ts b/src/clis/xiaohongshu/creator-notes.test.ts index 6a94cb12..bc0fe145 100644 --- a/src/clis/xiaohongshu/creator-notes.test.ts +++ b/src/clis/xiaohongshu/creator-notes.test.ts @@ -22,6 +22,8 @@ function createPageMock(evaluateResult: any, interceptedRequests: any[] = []): I click: vi.fn().mockResolvedValue(undefined), typeText: vi.fn().mockResolvedValue(undefined), pressKey: vi.fn().mockResolvedValue(undefined), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), wait: vi.fn().mockResolvedValue(undefined), tabs: vi.fn().mockResolvedValue([]), closeTab: vi.fn().mockResolvedValue(undefined), diff --git a/src/pipeline/executor.test.ts b/src/pipeline/executor.test.ts index 7dfddd96..059def6e 100644 --- a/src/pipeline/executor.test.ts +++ b/src/pipeline/executor.test.ts @@ -16,6 +16,8 @@ function createMockPage(overrides: Partial = {}): IPage { click: vi.fn(), typeText: vi.fn(), pressKey: vi.fn(), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), wait: vi.fn(), tabs: vi.fn().mockResolvedValue([]), closeTab: vi.fn(), diff --git a/src/types.ts b/src/types.ts index 832e0b46..9077852f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,10 +17,19 @@ export interface IPage { httpOnly?: boolean; expirationDate?: number; }>>; - snapshot(opts?: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean }): Promise; + snapshot(opts?: { + interactive?: boolean; + compact?: boolean; + maxDepth?: number; + raw?: boolean; + viewportExpand?: number; + maxTextLength?: number; + }): Promise; click(ref: string): Promise; typeText(ref: string, text: string): Promise; pressKey(key: string): Promise; + scrollTo(ref: string): Promise; + getFormState(): Promise; wait(options: number | { text?: string; time?: number; timeout?: number }): Promise; tabs(): Promise; closeTab(index?: number): Promise;