dom-helpers.ts
1 /** 2 * Shared DOM operation JS generators. 3 * 4 * Used by both Page (daemon mode) and CDPPage (direct CDP mode) 5 * to eliminate code duplication for click, type, press, wait, scroll, etc. 6 */ 7 8 /** Shared element lookup JS fragment (4-strategy resolution) */ 9 function resolveElementJs(safeRef: string, selectorSet: string): string { 10 return ` 11 const ref = ${safeRef}; 12 let el = document.querySelector('[data-opencli-ref="' + ref + '"]'); 13 if (!el) el = document.querySelector('[data-ref="' + ref + '"]'); 14 if (!el && ref.match(/^[a-zA-Z#.\\[]/)) { 15 try { el = document.querySelector(ref); } catch {} 16 } 17 if (!el) { 18 const idx = parseInt(ref, 10); 19 if (!isNaN(idx)) { 20 el = document.querySelectorAll('${selectorSet}')[idx]; 21 } 22 }`; 23 } 24 25 /** Generate JS to click an element by ref. 26 * Returns { status, x, y, w, h } for CDP fallback when JS click fails. */ 27 export function clickJs(ref: string): string { 28 const safeRef = JSON.stringify(ref); 29 return ` 30 (() => { 31 ${resolveElementJs(safeRef, 'a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])')} 32 if (!el) throw new Error('Element not found: ' + ref); 33 el.scrollIntoView({ behavior: 'instant', block: 'center' }); 34 const rect = el.getBoundingClientRect(); 35 const x = Math.round(rect.left + rect.width / 2); 36 const y = Math.round(rect.top + rect.height / 2); 37 try { 38 el.click(); 39 return { status: 'clicked', x, y, w: Math.round(rect.width), h: Math.round(rect.height) }; 40 } catch (e) { 41 return { status: 'js_failed', x, y, w: Math.round(rect.width), h: Math.round(rect.height), error: e.message }; 42 } 43 })() 44 `; 45 } 46 47 /** Generate JS to type text into an element by ref. 48 * Uses native setter for React compat + execCommand for contenteditable. */ 49 export function typeTextJs(ref: string, text: string): string { 50 const safeRef = JSON.stringify(ref); 51 const safeText = JSON.stringify(text); 52 return ` 53 (() => { 54 ${resolveElementJs(safeRef, 'input, textarea, [contenteditable="true"]')} 55 if (!el) throw new Error('Element not found: ' + ref); 56 el.focus(); 57 if (el.isContentEditable) { 58 // Select all content + delete, then insert (supports undo, works with rich text editors) 59 const sel = window.getSelection(); 60 const range = document.createRange(); 61 range.selectNodeContents(el); 62 sel.removeAllRanges(); 63 sel.addRange(range); 64 document.execCommand('delete', false); 65 document.execCommand('insertText', false, ${safeText}); 66 el.dispatchEvent(new Event('input', { bubbles: true })); 67 } else { 68 // Use native setter for React/framework compatibility (match element type) 69 const proto = el instanceof HTMLTextAreaElement 70 ? HTMLTextAreaElement.prototype 71 : HTMLInputElement.prototype; 72 const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; 73 if (nativeSetter) { 74 nativeSetter.call(el, ${safeText}); 75 } else { 76 el.value = ${safeText}; 77 } 78 el.dispatchEvent(new Event('input', { bubbles: true })); 79 el.dispatchEvent(new Event('change', { bubbles: true })); 80 } 81 return 'typed'; 82 })() 83 `; 84 } 85 86 /** Generate JS to press a keyboard key */ 87 export function pressKeyJs(key: string): string { 88 return ` 89 (() => { 90 const el = document.activeElement || document.body; 91 el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true })); 92 el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true })); 93 return 'pressed'; 94 })() 95 `; 96 } 97 98 /** Generate JS to wait for text to appear in the page */ 99 export function waitForTextJs(text: string, timeoutMs: number): string { 100 return ` 101 new Promise((resolve, reject) => { 102 const deadline = Date.now() + ${timeoutMs}; 103 const check = () => { 104 if (document.body.innerText.includes(${JSON.stringify(text)})) return resolve('found'); 105 if (Date.now() > deadline) return reject(new Error('Text not found: ' + ${JSON.stringify(text)})); 106 setTimeout(check, 200); 107 }; 108 check(); 109 }) 110 `; 111 } 112 113 /** Generate JS for scroll */ 114 export function scrollJs(direction: string, amount: number): string { 115 const dx = direction === 'left' ? -amount : direction === 'right' ? amount : 0; 116 const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0; 117 return `window.scrollBy(${dx}, ${dy})`; 118 } 119 120 /** Generate JS for auto-scroll with lazy-load detection */ 121 export function autoScrollJs(times: number, delayMs: number): string { 122 return ` 123 (async () => { 124 if (!document.body) return; 125 for (let i = 0; i < ${times}; i++) { 126 const lastHeight = document.body.scrollHeight; 127 window.scrollTo(0, lastHeight); 128 await new Promise(resolve => { 129 let timeoutId; 130 const observer = new MutationObserver(() => { 131 if (document.body.scrollHeight > lastHeight) { 132 clearTimeout(timeoutId); 133 observer.disconnect(); 134 setTimeout(resolve, 100); 135 } 136 }); 137 observer.observe(document.body, { childList: true, subtree: true }); 138 timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, ${delayMs}); 139 }); 140 } 141 })() 142 `; 143 } 144 145 /** Generate JS to read performance resource entries as network requests */ 146 export function networkRequestsJs(includeStatic: boolean): string { 147 return ` 148 (() => { 149 const entries = performance.getEntriesByType('resource'); 150 return entries 151 ${includeStatic ? '' : '.filter(e => !["img", "font", "css", "script"].some(t => e.initiatorType === t))'} 152 .map(e => ({ 153 url: e.name, 154 type: e.initiatorType, 155 duration: Math.round(e.duration), 156 size: e.transferSize || 0, 157 })); 158 })() 159 `; 160 } 161 162 /** 163 * Generate JS to wait until the DOM stabilizes (no mutations for `quietMs`), 164 * with a hard cap at `maxMs`. Uses MutationObserver in the browser. 165 * 166 * Returns as soon as the page stops changing, avoiding unnecessary fixed waits. 167 * If document.body is not available, falls back to a fixed sleep of maxMs. 168 */ 169 export function waitForDomStableJs(maxMs: number, quietMs: number): string { 170 return ` 171 new Promise(resolve => { 172 if (!document.body) { 173 setTimeout(() => resolve('nobody'), ${maxMs}); 174 return; 175 } 176 let timer = null; 177 let cap = null; 178 const done = (reason) => { 179 clearTimeout(timer); 180 clearTimeout(cap); 181 obs.disconnect(); 182 resolve(reason); 183 }; 184 const resetQuiet = () => { 185 clearTimeout(timer); 186 timer = setTimeout(() => done('quiet'), ${quietMs}); 187 }; 188 const obs = new MutationObserver(resetQuiet); 189 obs.observe(document.body, { childList: true, subtree: true, attributes: true }); 190 resetQuiet(); 191 cap = setTimeout(() => done('capped'), ${maxMs}); 192 }) 193 `; 194 } 195 196 /** 197 * Generate JS to wait until window.__opencli_xhr has ≥1 captured response. 198 * Polls every 100ms. Resolves 'captured' on success; rejects after maxMs. 199 * Used after installInterceptor() + goto() instead of a fixed sleep. 200 */ 201 export function waitForCaptureJs(maxMs: number): string { 202 return ` 203 new Promise((resolve, reject) => { 204 const deadline = Date.now() + ${maxMs}; 205 const check = () => { 206 if ((window.__opencli_xhr || []).length > 0) return resolve('captured'); 207 if (Date.now() > deadline) return reject(new Error('No network capture within ${maxMs / 1000}s')); 208 setTimeout(check, 100); 209 }; 210 check(); 211 }) 212 `; 213 } 214 215 /** 216 * Generate JS to wait until document.querySelector(selector) returns a match. 217 * Uses MutationObserver for near-instant resolution; falls back to reject after timeoutMs. 218 */ 219 export function waitForSelectorJs(selector: string, timeoutMs: number): string { 220 return ` 221 new Promise((resolve, reject) => { 222 const sel = ${JSON.stringify(selector)}; 223 if (document.querySelector(sel)) return resolve('found'); 224 const cap = setTimeout(() => { 225 obs.disconnect(); 226 reject(new Error('Selector not found: ' + sel)); 227 }, ${timeoutMs}); 228 const obs = new MutationObserver(() => { 229 if (document.querySelector(sel)) { 230 clearTimeout(cap); 231 obs.disconnect(); 232 resolve('found'); 233 } 234 }); 235 obs.observe(document.body || document.documentElement, { childList: true, subtree: true }); 236 }) 237 `; 238 }