From 0f498efe8dae2f014bc4fb8692a6bc5005ca4998 Mon Sep 17 00:00:00 2001 From: shaun0927 <70629228+shaun0927@users.noreply.github.com> Date: Tue, 12 May 2026 23:58:56 +0900 Subject: [PATCH 01/10] Surface custom interactive controls in DOM snapshots Detect cursor/onClick/tabindex/contenteditable elements only during interactive DOM reads, annotate why they were included, and clear temporary markers after the CDP snapshot so default read_page behavior stays unchanged. Constraint: Implemented as an additive interactiveOnly DOM pass to avoid changing default snapshot output or OpenChrome action semantics. Rejected: Broad static inclusion of all pointer-cursor descendants | it bloats snapshots and duplicates inherited cursor containers. Confidence: high Scope-risk: moderate Directive: Keep future discovery heuristics opt-in or filter-scoped unless benchmark evidence shows default-token improvements. Tested: ./node_modules/.bin/jest tests/dom/dom-serializer.test.ts --runInBand Tested: ./node_modules/.bin/tsc -p tsconfig.json --noEmit Not-tested: Live browser validation is documented in the PR checklist for post-merge manual verification. --- src/dom/dom-serializer.ts | 113 +++++++++++++++++++++++++++++-- tests/dom/dom-serializer.test.ts | 35 ++++++++++ 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/src/dom/dom-serializer.ts b/src/dom/dom-serializer.ts index d58bd483..3bd25022 100644 --- a/src/dom/dom-serializer.ts +++ b/src/dom/dom-serializer.ts @@ -92,6 +92,8 @@ const CONTAINER_TAGS = new Set([ 'div', 'section', 'article', 'main', 'aside', 'header', 'footer', 'nav', 'span', ]); +const INTERACTIVE_HINT_ATTR = 'data-oc-interactive-hints'; + /** * Parse flat attributes array into a map */ @@ -116,6 +118,7 @@ function escapeAttributeValue(value: string): string { * Check if a node is interactive */ function isInteractive(tagName: string, attrMap: Map): boolean { + if (attrMap.has(INTERACTIVE_HINT_ATTR)) return true; if (INTERACTIVE_TAGS.has(tagName)) return true; const role = attrMap.get('role'); if (role && INTERACTIVE_ROLES.has(role)) return true; @@ -176,7 +179,10 @@ function formatElement( } const attrStr = attrParts.length > 0 ? ' ' + attrParts.join(' ') : ''; - const interactiveMarker = interactive ? ' ★' : ''; + const hints = attrMap.get(INTERACTIVE_HINT_ATTR); + const interactiveMarker = interactive + ? ` ★${hints ? ` [${hints}]` : ''}` + : ''; const line = `${indent}[${node.backendNodeId}]<${tagName}${attrStr}/>${textContent}${interactiveMarker}`; return line; } @@ -529,6 +535,90 @@ function serializeNode( } } +async function markCursorInteractiveElements(page: Page): Promise { + await withTimeout( + page.evaluate((hintAttr: string) => { + const interactiveRoles = new Set([ + 'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox', + 'menu', 'menuitem', 'tab', 'switch', 'slider', + ]); + const interactiveTags = new Set(['a', 'button', 'input', 'select', 'textarea', 'details', 'summary']); + const roots: Array = [document]; + + for (let i = 0; i < roots.length; i++) { + const root = roots[i]; + const all = Array.from(root.querySelectorAll('*')) as HTMLElement[]; + for (const el of all) { + if (el.shadowRoot) roots.push(el.shadowRoot); + if (el.closest('[hidden], [aria-hidden="true"]')) continue; + + const tag = el.tagName.toLowerCase(); + if (interactiveTags.has(tag)) continue; + const role = el.getAttribute('role'); + if (role && interactiveRoles.has(role.toLowerCase())) continue; + + const style = getComputedStyle(el); + const hasCursorPointer = style.cursor === 'pointer'; + const hasOnClick = el.hasAttribute('onclick') || typeof el.onclick === 'function'; + const tabIndex = el.getAttribute('tabindex'); + const hasTabIndex = tabIndex !== null && tabIndex !== '-1'; + const editable = el.getAttribute('contenteditable'); + const isEditable = editable === '' || editable === 'true'; + + if (!hasCursorPointer && !hasOnClick && !hasTabIndex && !isEditable) continue; + if (hasCursorPointer && !hasOnClick && !hasTabIndex && !isEditable) { + const parent = el.parentElement; + if (parent && getComputedStyle(parent).cursor === 'pointer') continue; + } + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) continue; + + const hints: string[] = []; + if (hasCursorPointer) hints.push('cursor:pointer'); + if (hasOnClick) hints.push('onclick'); + if (hasTabIndex) hints.push('tabindex'); + if (isEditable) hints.push('contenteditable'); + + const hiddenInput = el.querySelector('input[type="radio"], input[type="checkbox"]') as HTMLInputElement | null; + if (hiddenInput) { + const inputStyle = getComputedStyle(hiddenInput); + const hidden = inputStyle.display === 'none' || inputStyle.visibility === 'hidden' || hiddenInput.hidden; + if (hidden) { + hints.push(`${hiddenInput.type}:${hiddenInput.indeterminate ? 'mixed' : String(hiddenInput.checked)}`); + } + } + + el.setAttribute(hintAttr, hints.join(', ')); + } + } + }, INTERACTIVE_HINT_ATTR), + 5000, + 'serializeDOM:markCursorInteractiveElements', + ); +} + +async function clearCursorInteractiveMarkers(page: Page): Promise { + try { + await withTimeout( + page.evaluate((hintAttr: string) => { + const roots: Array = [document]; + for (let i = 0; i < roots.length; i++) { + const root = roots[i]; + const all = Array.from(root.querySelectorAll('*')) as HTMLElement[]; + for (const el of all) { + if (el.shadowRoot) roots.push(el.shadowRoot); + if (el.hasAttribute(hintAttr)) el.removeAttribute(hintAttr); + } + } + }, INTERACTIVE_HINT_ATTR), + 5000, + 'serializeDOM:clearCursorInteractiveMarkers', + ); + } catch { + // Best-effort cleanup only; a failed cleanup must not break read_page. + } +} + /** * Serialize a page's DOM into a compact text representation */ @@ -571,6 +661,10 @@ export async function serializeDOM( 'serializeDOM:pageStats', ) as PageStats; + if (interactiveOnly) { + await markCursorInteractiveElements(page); + } + // Get DOM tree via CDP. Always pierce at the CDP layer so shadowRoots are // present; ctx.pierceIframes below controls whether iframe contentDocument // subtrees are emitted. When callers request bounded output depth, avoid @@ -583,11 +677,18 @@ export async function serializeDOM( // fall back to an unbounded CDP fetch in that case so iframe body content // within maxDepth is not silently dropped. const documentDepth = maxDepth >= 0 && !pierceIframes ? maxDepth + 1 : -1; - const { root } = await cdpClient.send<{ root: DOMNode }>( - page, - 'DOM.getDocument', - { depth: documentDepth, pierce: true }, - ); + let root: DOMNode; + try { + ({ root } = await cdpClient.send<{ root: DOMNode }>( + page, + 'DOM.getDocument', + { depth: documentDepth, pierce: true }, + )); + } finally { + if (interactiveOnly) { + await clearCursorInteractiveMarkers(page); + } + } const ctx: SerializeContext = { lines: [], diff --git a/tests/dom/dom-serializer.test.ts b/tests/dom/dom-serializer.test.ts index 0f23e234..e3b529c7 100644 --- a/tests/dom/dom-serializer.test.ts +++ b/tests/dom/dom-serializer.test.ts @@ -439,6 +439,41 @@ describe('DOM Serializer', () => { expect(result.content).not.toContain('[902]'); // plain div }); + test('includes cursor/onClick hints for custom interactive elements without leaking marker attrs', async () => { + const customDoc = { + nodeId: 1, backendNodeId: 1, nodeType: 9, nodeName: '#document', localName: '', + children: [{ + nodeId: 2, backendNodeId: 2, nodeType: 1, nodeName: 'BODY', localName: 'body', + attributes: [], + children: [ + { + nodeId: 3, backendNodeId: 910, nodeType: 1, nodeName: 'DIV', localName: 'div', + attributes: ['data-oc-interactive-hints', 'cursor:pointer, onclick', 'class', 'card'], + children: [{ + nodeId: 4, backendNodeId: 4, nodeType: 3, nodeName: '#text', localName: '', + nodeValue: 'Open settings', + }], + }, + { + nodeId: 5, backendNodeId: 911, nodeType: 1, nodeName: 'DIV', localName: 'div', + attributes: ['class', 'plain'], + children: [], + }, + ], + }], + }; + + const page = createMockPageForDOM(); + const cdpClient = createMockCDPClientForDOM(customDoc); + + const result = await serializeDOM(page as never, cdpClient as never, { includePageStats: false, interactiveOnly: true }); + + expect(result.content).toContain('[910]
Open settings ★ [cursor:pointer, onclick]'); + expect(result.content).not.toContain('[911]'); + expect(result.content).not.toContain('data-oc-interactive-hints'); + }); + + // 8. Output truncation test('truncates output at maxOutputChars', async () => { // Build a large DOM with many nodes From f1ee0090ee1277c2eb9fb63d8c168260a94cff40 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Wed, 13 May 2026 01:27:33 +0900 Subject: [PATCH 02/10] fix(dom): make cursor interactive hinting non-destructive --- src/dom/dom-serializer.ts | 18 +++++++++++++++--- tests/dom/dom-serializer.test.ts | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/dom/dom-serializer.ts b/src/dom/dom-serializer.ts index 3bd25022..87b255c0 100644 --- a/src/dom/dom-serializer.ts +++ b/src/dom/dom-serializer.ts @@ -588,7 +588,10 @@ async function markCursorInteractiveElements(page: Page): Promise { } } - el.setAttribute(hintAttr, hints.join(', ')); + if (!el.hasAttribute(hintAttr)) { + el.setAttribute(hintAttr, hints.join(', ')); + el.setAttribute(`${hintAttr}-owned`, 'true'); + } } } }, INTERACTIVE_HINT_ATTR), @@ -607,7 +610,10 @@ async function clearCursorInteractiveMarkers(page: Page): Promise { const all = Array.from(root.querySelectorAll('*')) as HTMLElement[]; for (const el of all) { if (el.shadowRoot) roots.push(el.shadowRoot); - if (el.hasAttribute(hintAttr)) el.removeAttribute(hintAttr); + if (el.getAttribute(`${hintAttr}-owned`) === 'true') { + el.removeAttribute(hintAttr); + el.removeAttribute(`${hintAttr}-owned`); + } } } }, INTERACTIVE_HINT_ATTR), @@ -662,7 +668,13 @@ export async function serializeDOM( ) as PageStats; if (interactiveOnly) { - await markCursorInteractiveElements(page); + try { + await markCursorInteractiveElements(page); + } catch { + // Cursor/onclick hint discovery is opportunistic. Large or hostile pages + // should still serialize using native tags and ARIA roles if this pre-scan + // times out or throws. + } } // Get DOM tree via CDP. Always pierce at the CDP layer so shadowRoots are diff --git a/tests/dom/dom-serializer.test.ts b/tests/dom/dom-serializer.test.ts index e3b529c7..198690fa 100644 --- a/tests/dom/dom-serializer.test.ts +++ b/tests/dom/dom-serializer.test.ts @@ -870,4 +870,18 @@ describe('DOM Serializer', () => { expect(result.content).toMatch(/--page-separator--[\s\S]*\[12\] { + const page = createMockPageForDOM(); + const cdpClient = createMockCDPClientForDOM(simpleDoc); + page.evaluate = jest.fn() + .mockResolvedValueOnce({ nodeCount: 1, textLength: 4, truncated: false }) + .mockRejectedValueOnce(new Error('hint scan failed')) + .mockResolvedValueOnce(undefined); + + const result = await serializeDOM(page as never, cdpClient as never, { interactiveOnly: true }); + + expect(result.content).toEqual(expect.any(String)); + expect(cdpClient.send).toHaveBeenCalledWith(page, 'DOM.getDocument', { depth: -1, pierce: true }); + }); }); From dc2f4af70bffb227e68ffe280a20c6d7e14bcd17 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Wed, 13 May 2026 01:42:03 +0900 Subject: [PATCH 03/10] fix(dom): include computed contenteditable hints --- tests/dom/dom-serializer.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/dom/dom-serializer.test.ts b/tests/dom/dom-serializer.test.ts index 198690fa..6a005cc2 100644 --- a/tests/dom/dom-serializer.test.ts +++ b/tests/dom/dom-serializer.test.ts @@ -884,4 +884,30 @@ describe('DOM Serializer', () => { expect(result.content).toEqual(expect.any(String)); expect(cdpClient.send).toHaveBeenCalledWith(page, 'DOM.getDocument', { depth: -1, pierce: true }); }); + + it('treats computed contenteditable controls as interactive hints', async () => { + const page = createMockPageForDOM(); + const cdpClient = createMockCDPClientForDOM(simpleDoc); + page.evaluate = jest.fn() + .mockResolvedValueOnce({ nodeCount: 1, textLength: 4, truncated: false }) + .mockImplementationOnce(async (fn: Function, hintAttr: string) => { + const el = { + tagName: 'DIV', + shadowRoot: null, + onclick: null, + isContentEditable: true, + getAttribute: (name: string) => name === 'contenteditable' ? 'plaintext-only' : null, + hasAttribute: () => false, + setAttribute: jest.fn(), + }; + const root = { querySelectorAll: () => [el] }; + const previousDocument = (global as any).document; + (global as any).document = root; + try { await fn(hintAttr); } finally { (global as any).document = previousDocument; } + expect(el.setAttribute).toHaveBeenCalledWith(hintAttr, 'editable'); + }) + .mockResolvedValueOnce(undefined); + + await serializeDOM(page as never, cdpClient as never, { interactiveOnly: true }); + }); }); From 68bef57568242992364148b4609614881ba220b9 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Wed, 13 May 2026 01:45:33 +0900 Subject: [PATCH 04/10] fix(dom): use computed contenteditable for interactive hints --- src/dom/dom-serializer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dom/dom-serializer.ts b/src/dom/dom-serializer.ts index 87b255c0..ca1c5a28 100644 --- a/src/dom/dom-serializer.ts +++ b/src/dom/dom-serializer.ts @@ -563,7 +563,7 @@ async function markCursorInteractiveElements(page: Page): Promise { const tabIndex = el.getAttribute('tabindex'); const hasTabIndex = tabIndex !== null && tabIndex !== '-1'; const editable = el.getAttribute('contenteditable'); - const isEditable = editable === '' || editable === 'true'; + const isEditable = el.isContentEditable || editable === '' || editable === 'true' || editable === 'plaintext-only'; if (!hasCursorPointer && !hasOnClick && !hasTabIndex && !isEditable) continue; if (hasCursorPointer && !hasOnClick && !hasTabIndex && !isEditable) { From 824aa2810c996ba88c2715f1cec1ebaec7572c8a Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Wed, 13 May 2026 01:56:10 +0900 Subject: [PATCH 05/10] fix(dom): avoid non-abortable hint scan timeout race --- src/dom/dom-serializer.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/dom/dom-serializer.ts b/src/dom/dom-serializer.ts index ca1c5a28..d52226ad 100644 --- a/src/dom/dom-serializer.ts +++ b/src/dom/dom-serializer.ts @@ -536,8 +536,10 @@ function serializeNode( } async function markCursorInteractiveElements(page: Page): Promise { - await withTimeout( - page.evaluate((hintAttr: string) => { + // Await the browser-side scan directly. page.evaluate cannot be aborted by + // racing its promise with a timeout; doing so could let background marker + // mutations continue after serializeDOM has already run cleanup. + await page.evaluate((hintAttr: string) => { const interactiveRoles = new Set([ 'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox', 'menu', 'menuitem', 'tab', 'switch', 'slider', @@ -594,10 +596,7 @@ async function markCursorInteractiveElements(page: Page): Promise { } } } - }, INTERACTIVE_HINT_ATTR), - 5000, - 'serializeDOM:markCursorInteractiveElements', - ); + }, INTERACTIVE_HINT_ATTR); } async function clearCursorInteractiveMarkers(page: Page): Promise { From dfc679a4000aa0f17609ac033b758f1d6ef30f96 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Wed, 13 May 2026 03:52:30 +0900 Subject: [PATCH 06/10] fix(dom): bound cursor hint scan in page context --- src/dom/dom-serializer.ts | 45 +++++-- .../dom/dom-serializer-cursor-budget.test.ts | 112 ++++++++++++++++++ 2 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 tests/dom/dom-serializer-cursor-budget.test.ts diff --git a/src/dom/dom-serializer.ts b/src/dom/dom-serializer.ts index d52226ad..704c67fb 100644 --- a/src/dom/dom-serializer.ts +++ b/src/dom/dom-serializer.ts @@ -93,6 +93,9 @@ const CONTAINER_TAGS = new Set([ ]); const INTERACTIVE_HINT_ATTR = 'data-oc-interactive-hints'; +const INTERACTIVE_HINT_OWNED_ATTR = `${INTERACTIVE_HINT_ATTR}-owned`; +const INTERACTIVE_HINT_SCAN_MAX_MS = 100; +const INTERACTIVE_HINT_SCAN_MAX_ELEMENTS = 2500; /** * Parse flat attributes array into a map @@ -536,22 +539,39 @@ function serializeNode( } async function markCursorInteractiveElements(page: Page): Promise { - // Await the browser-side scan directly. page.evaluate cannot be aborted by - // racing its promise with a timeout; doing so could let background marker - // mutations continue after serializeDOM has already run cleanup. - await page.evaluate((hintAttr: string) => { + // Bound the browser-side scan from inside the evaluated function. Racing + // page.evaluate with a timeout does not abort the in-page work and can leave + // marker mutations running after cleanup; an in-page deadline/node cap exits + // cooperatively without orphaning background DOM changes. + await page.evaluate((hintAttr: string, ownedAttr: string, maxMs: number, maxElements: number) => { const interactiveRoles = new Set([ 'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox', 'menu', 'menuitem', 'tab', 'switch', 'slider', ]); const interactiveTags = new Set(['a', 'button', 'input', 'select', 'textarea', 'details', 'summary']); const roots: Array = [document]; + const now = () => (typeof performance !== 'undefined' && typeof performance.now === 'function') + ? performance.now() + : Date.now(); + const deadline = now() + maxMs; + let inspected = 0; + let budgetExceeded = false; for (let i = 0; i < roots.length; i++) { const root = roots[i]; - const all = Array.from(root.querySelectorAll('*')) as HTMLElement[]; - for (const el of all) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let current = walker.nextNode(); + while (current) { + inspected += 1; + if (inspected > maxElements || now() > deadline) { + budgetExceeded = true; + break; + } + + const el = current as HTMLElement; if (el.shadowRoot) roots.push(el.shadowRoot); + current = walker.nextNode(); + if (el.closest('[hidden], [aria-hidden="true"]')) continue; const tag = el.tagName.toLowerCase(); @@ -592,30 +612,31 @@ async function markCursorInteractiveElements(page: Page): Promise { if (!el.hasAttribute(hintAttr)) { el.setAttribute(hintAttr, hints.join(', ')); - el.setAttribute(`${hintAttr}-owned`, 'true'); + el.setAttribute(ownedAttr, 'true'); } } + if (budgetExceeded) break; } - }, INTERACTIVE_HINT_ATTR); + }, INTERACTIVE_HINT_ATTR, INTERACTIVE_HINT_OWNED_ATTR, INTERACTIVE_HINT_SCAN_MAX_MS, INTERACTIVE_HINT_SCAN_MAX_ELEMENTS); } async function clearCursorInteractiveMarkers(page: Page): Promise { try { await withTimeout( - page.evaluate((hintAttr: string) => { + page.evaluate((hintAttr: string, ownedAttr: string) => { const roots: Array = [document]; for (let i = 0; i < roots.length; i++) { const root = roots[i]; const all = Array.from(root.querySelectorAll('*')) as HTMLElement[]; for (const el of all) { if (el.shadowRoot) roots.push(el.shadowRoot); - if (el.getAttribute(`${hintAttr}-owned`) === 'true') { + if (el.getAttribute(ownedAttr) === 'true') { el.removeAttribute(hintAttr); - el.removeAttribute(`${hintAttr}-owned`); + el.removeAttribute(ownedAttr); } } } - }, INTERACTIVE_HINT_ATTR), + }, INTERACTIVE_HINT_ATTR, INTERACTIVE_HINT_OWNED_ATTR), 5000, 'serializeDOM:clearCursorInteractiveMarkers', ); diff --git a/tests/dom/dom-serializer-cursor-budget.test.ts b/tests/dom/dom-serializer-cursor-budget.test.ts new file mode 100644 index 00000000..d60feb7c --- /dev/null +++ b/tests/dom/dom-serializer-cursor-budget.test.ts @@ -0,0 +1,112 @@ +/// + +import { serializeDOM } from '../../src/dom/dom-serializer'; + +function createMockElement(overrides: Record = {}) { + return { + tagName: 'DIV', + shadowRoot: null, + onclick: null, + parentElement: null, + isContentEditable: false, + computedStyle: { cursor: 'default', display: 'block', visibility: 'visible' }, + closest: jest.fn(() => null), + getAttribute: jest.fn(() => null), + hasAttribute: jest.fn(() => false), + setAttribute: jest.fn(), + querySelector: jest.fn(() => null), + getBoundingClientRect: jest.fn(() => ({ width: 10, height: 10 })), + ...overrides, + }; +} + +async function withMockTreeWalker(elements: any[], run: () => void | Promise) { + const previousDocument = (global as any).document; + const previousNodeFilter = (global as any).NodeFilter; + const previousPerformance = (global as any).performance; + const previousGetComputedStyle = (global as any).getComputedStyle; + + (global as any).document = { + createTreeWalker: (root: { nodes?: any[] }) => { + const nodes = root.nodes ?? []; + let index = -1; + return { + nextNode: () => nodes[++index] ?? null, + }; + }, + nodes: elements, + }; + (global as any).NodeFilter = { SHOW_ELEMENT: 1 }; + (global as any).performance = { now: jest.fn(() => 0) }; + (global as any).getComputedStyle = jest.fn((el: any) => el.computedStyle ?? { + cursor: 'default', + display: 'block', + visibility: 'visible', + }); + + try { + await run(); + } finally { + (global as any).document = previousDocument; + (global as any).NodeFilter = previousNodeFilter; + (global as any).performance = previousPerformance; + (global as any).getComputedStyle = previousGetComputedStyle; + } +} + +describe('DOM serializer cursor hint scan budget', () => { + it('stops cursor hint discovery at the node cap and keeps native interactive fallback', async () => { + const nativeInteractiveDoc = { + nodeId: 1, backendNodeId: 1, nodeType: 9, nodeName: '#document', localName: '', + children: [{ + nodeId: 2, backendNodeId: 2, nodeType: 1, nodeName: 'BODY', localName: 'body', + attributes: [], + children: [{ + nodeId: 3, backendNodeId: 930, nodeType: 1, nodeName: 'BUTTON', localName: 'button', + attributes: [], + children: [], + }], + }], + }; + const page = { + evaluate: jest.fn() + .mockResolvedValueOnce({ + url: 'https://example.com', + title: 'Test Page', + scrollX: 0, + scrollY: 0, + scrollWidth: 1920, + scrollHeight: 3000, + viewportWidth: 1920, + viewportHeight: 1080, + }) + .mockImplementationOnce(async ( + fn: Function, + hintAttr: string, + ownedAttr: string, + maxMs: number, + maxElements: number, + ) => { + const elements = Array.from({ length: maxElements + 5 }, () => createMockElement({ + computedStyle: { cursor: 'pointer', display: 'block', visibility: 'visible' }, + })); + const skippedElement = elements[maxElements]; + + await withMockTreeWalker(elements, () => fn(hintAttr, ownedAttr, maxMs, maxElements)); + + expect(elements[maxElements - 1].setAttribute).toHaveBeenCalledWith(hintAttr, 'cursor:pointer'); + expect(elements[maxElements - 1].setAttribute).toHaveBeenCalledWith(ownedAttr, 'true'); + expect(skippedElement.setAttribute).not.toHaveBeenCalled(); + }) + .mockResolvedValueOnce(undefined), + }; + const cdpClient = { + send: jest.fn().mockResolvedValue({ root: nativeInteractiveDoc }), + }; + + const result = await serializeDOM(page as never, cdpClient as never, { interactiveOnly: true }); + + expect(result.content).toContain('[930]