/ src / browser / dom-helpers.ts
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  }