/ src / browser / page.ts
page.ts
  1  /**
  2   * Page abstraction — implements IPage by sending commands to the daemon.
  3   *
  4   * All browser operations are ultimately 'exec' (JS evaluation via CDP)
  5   * plus a few native Chrome Extension APIs (tabs, cookies, navigate).
  6   *
  7   * IMPORTANT: After goto(), we remember the page identity (targetId) returned
  8   * by the navigate action and pass it to all subsequent commands. This ensures
  9   * page-scoped operations target the correct page without guessing.
 10   */
 11  
 12  import type { BrowserCookie, ScreenshotOptions } from '../types.js';
 13  import { sendCommand, sendCommandFull } from './daemon-client.js';
 14  import { wrapForEval } from './utils.js';
 15  import { saveBase64ToFile } from '../utils.js';
 16  import { generateStealthJs } from './stealth.js';
 17  import { waitForDomStableJs } from './dom-helpers.js';
 18  import { BasePage } from './base-page.js';
 19  import { classifyBrowserError } from './errors.js';
 20  import { log } from '../logger.js';
 21  
 22  function isUnsupportedNetworkCaptureError(err: unknown): boolean {
 23    const message = err instanceof Error ? err.message : String(err);
 24    const normalized = message.toLowerCase();
 25    return (normalized.includes('unknown action') && normalized.includes('network-capture'))
 26      || (normalized.includes('network capture') && normalized.includes('not supported'));
 27  }
 28  
 29  /**
 30   * Page — implements IPage by talking to the daemon via HTTP.
 31   */
 32  export class Page extends BasePage {
 33    private readonly _idleTimeout: number | undefined;
 34  
 35    constructor(private readonly workspace: string = 'default', idleTimeout?: number) {
 36      super();
 37      this._idleTimeout = idleTimeout;
 38    }
 39  
 40    /** Active page identity (targetId), set after navigate and used in all subsequent commands */
 41    private _page: string | undefined;
 42    private _networkCaptureUnsupported = false;
 43    private _networkCaptureWarned = false;
 44  
 45    /** Helper: spread workspace into command params */
 46    private _wsOpt(): { workspace: string; idleTimeout?: number } {
 47      return { workspace: this.workspace, ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }) };
 48    }
 49  
 50    /** Helper: spread workspace + page identity into command params */
 51    private _cmdOpts(): Record<string, unknown> {
 52      return {
 53        workspace: this.workspace,
 54        ...(this._page !== undefined && { page: this._page }),
 55        ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
 56      };
 57    }
 58  
 59    async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
 60      const result = await sendCommandFull('navigate', {
 61        url,
 62        ...this._cmdOpts(),
 63      });
 64      // Remember the page identity (targetId) for subsequent calls
 65      if (result.page) {
 66        this._page = result.page;
 67      }
 68      this._lastUrl = url;
 69      // Inject stealth + settle in a single round-trip instead of two sequential exec calls.
 70      // The stealth guard flag prevents double-injection; settle uses DOM stability detection.
 71      if (options?.waitUntil !== 'none') {
 72        const maxMs = options?.settleMs ?? 1000;
 73        const combinedCode = `${generateStealthJs()};\n${waitForDomStableJs(maxMs, Math.min(500, maxMs))}`;
 74        const combinedOpts = {
 75          code: combinedCode,
 76          ...this._cmdOpts(),
 77        };
 78        try {
 79          await sendCommand('exec', combinedOpts);
 80        } catch (err) {
 81          const advice = classifyBrowserError(err);
 82          // Only settle-retry on target navigation (SPA client-side redirects).
 83          // Extension/daemon errors are already retried by sendCommandRaw —
 84          // retrying them here would silently swallow real failures.
 85          if (advice.kind !== 'target-navigation') throw err;
 86          try {
 87            await new Promise((r) => setTimeout(r, advice.delayMs));
 88            await sendCommand('exec', combinedOpts);
 89          } catch (retryErr) {
 90            if (classifyBrowserError(retryErr).kind !== 'target-navigation') throw retryErr;
 91          }
 92        }
 93      } else {
 94        // Even with waitUntil='none', still inject stealth (best-effort)
 95        try {
 96          await sendCommand('exec', {
 97            code: generateStealthJs(),
 98            ...this._cmdOpts(),
 99          });
100        } catch {
101          // Non-fatal: stealth is best-effort
102        }
103      }
104    }
105  
106    /** Get the active page identity (targetId) */
107    getActivePage(): string | undefined {
108      return this._page;
109    }
110  
111    /** Bind this Page instance to a specific page identity (targetId). */
112    setActivePage(page?: string): void {
113      this._page = page;
114      this._lastUrl = null;
115    }
116    private _markUnsupportedNetworkCapture(): void {
117      this._networkCaptureUnsupported = true;
118      if (this._networkCaptureWarned) return;
119      this._networkCaptureWarned = true;
120      log.warn(
121        'Browser Bridge extension does not support network capture; continuing without it. ' +
122        'Explore output may miss API endpoints until you reload or reinstall the extension.',
123      );
124    }
125  
126    async evaluate(js: string): Promise<unknown> {
127      const code = wrapForEval(js);
128      try {
129        return await sendCommand('exec', { code, ...this._cmdOpts() });
130      } catch (err) {
131        const advice = classifyBrowserError(err);
132        if (advice.kind !== 'target-navigation') throw err;
133        await new Promise((resolve) => setTimeout(resolve, advice.delayMs));
134        return sendCommand('exec', { code, ...this._cmdOpts() });
135      }
136    }
137  
138    async getCookies(opts: { domain?: string; url?: string } = {}): Promise<BrowserCookie[]> {
139      const result = await sendCommand('cookies', { ...this._wsOpt(), ...opts });
140      return Array.isArray(result) ? result : [];
141    }
142  
143    /** Close the automation window in the extension */
144    async closeWindow(): Promise<void> {
145      try {
146        await sendCommand('close-window', { ...this._wsOpt() });
147      } catch {
148        // Window may already be closed or daemon may be down
149      } finally {
150        this._page = undefined;
151        this._lastUrl = null;
152        this._networkCaptureUnsupported = false;
153        this._networkCaptureWarned = false;
154      }
155    }
156  
157    async tabs(): Promise<unknown[]> {
158      const result = await sendCommand('tabs', { op: 'list', ...this._wsOpt() });
159      return Array.isArray(result) ? result : [];
160    }
161  
162    async newTab(url?: string): Promise<string | undefined> {
163      const result = await sendCommandFull('tabs', {
164        op: 'new',
165        ...(url !== undefined && { url }),
166        ...this._wsOpt(),
167      });
168      this._lastUrl = null;
169      return result.page;
170    }
171  
172    async closeTab(target?: number | string): Promise<void> {
173      const params: Record<string, unknown> = { op: 'close', ...this._wsOpt() };
174      if (typeof target === 'number') params.index = target;
175      else if (typeof target === 'string') params.page = target;
176      else if (this._page !== undefined) params.page = this._page;
177  
178      const result = await sendCommand('tabs', params) as { closed?: string } | null;
179      const closedPage = typeof result?.closed === 'string' ? result.closed : undefined;
180  
181      if ((closedPage && closedPage === this._page) || (!closedPage && (target === undefined || target === this._page))) {
182        this._page = undefined;
183        this._lastUrl = null;
184      }
185    }
186  
187    async selectTab(target: number | string): Promise<void> {
188      const result = await sendCommandFull('tabs', {
189        op: 'select',
190        ...(typeof target === 'number' ? { index: target } : { page: target }),
191        ...this._wsOpt(),
192      });
193      if (result.page) this._page = result.page;
194      this._lastUrl = null;
195    }
196  
197    /**
198     * Capture a screenshot via CDP Page.captureScreenshot.
199     */
200    async screenshot(options: ScreenshotOptions = {}): Promise<string> {
201      const base64 = await sendCommand('screenshot', {
202        ...this._cmdOpts(),
203        format: options.format,
204        quality: options.quality,
205        fullPage: options.fullPage,
206      }) as string;
207  
208      if (options.path) {
209        await saveBase64ToFile(base64, options.path);
210      }
211  
212      return base64;
213    }
214  
215    async startNetworkCapture(pattern: string = ''): Promise<boolean> {
216      if (this._networkCaptureUnsupported) return false;
217      try {
218        await sendCommand('network-capture-start', {
219          pattern,
220          ...this._cmdOpts(),
221        });
222        return true;
223      } catch (err) {
224        if (!isUnsupportedNetworkCaptureError(err)) throw err;
225        this._markUnsupportedNetworkCapture();
226        return false;
227      }
228    }
229  
230    async readNetworkCapture(): Promise<unknown[]> {
231      if (this._networkCaptureUnsupported) return [];
232      try {
233        const result = await sendCommand('network-capture-read', {
234          ...this._cmdOpts(),
235        });
236        return Array.isArray(result) ? result : [];
237      } catch (err) {
238        if (!isUnsupportedNetworkCaptureError(err)) throw err;
239        this._markUnsupportedNetworkCapture();
240        return [];
241      }
242    }
243    /**
244     * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
245     * Chrome reads the files directly from the local filesystem, avoiding the
246     * payload size limits of base64-in-evaluate.
247     */
248    async setFileInput(files: string[], selector?: string): Promise<void> {
249      const result = await sendCommand('set-file-input', {
250        files,
251        selector,
252        ...this._cmdOpts(),
253      }) as { count?: number };
254      if (!result?.count) {
255        throw new Error('setFileInput returned no count — command may not be supported by the extension');
256      }
257    }
258  
259    async insertText(text: string): Promise<void> {
260      const result = await sendCommand('insert-text', {
261        text,
262        ...this._cmdOpts(),
263      }) as { inserted?: boolean };
264      if (!result?.inserted) {
265        throw new Error('insertText returned no inserted flag — command may not be supported by the extension');
266      }
267    }
268  
269    async frames(): Promise<Array<{ index: number; frameId: string; url: string; name: string }>> {
270      const result = await sendCommand('frames', { ...this._cmdOpts() });
271      return Array.isArray(result) ? result : [];
272    }
273  
274    async evaluateInFrame(js: string, frameIndex: number): Promise<unknown> {
275      const code = wrapForEval(js);
276      return sendCommand('exec', { code, frameIndex, ...this._cmdOpts() });
277    }
278  
279    async cdp(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
280      return sendCommand('cdp', {
281        cdpMethod: method,
282        cdpParams: params,
283        ...this._cmdOpts(),
284      });
285    }
286  
287    /** CDP native click fallback — called when JS el.click() fails */
288    protected override async tryNativeClick(x: number, y: number): Promise<boolean> {
289      try {
290        await this.nativeClick(x, y);
291        return true;
292      } catch {
293        return false;
294      }
295    }
296  
297    /** Precise click using DOM.getContentQuads/getBoxModel for inline elements */
298    async clickWithQuads(ref: string): Promise<void> {
299      const safeRef = JSON.stringify(ref);
300      const cssSelector = `[data-opencli-ref="${ref.replace(/"/g, '\\"')}"]`;
301  
302      // Scroll element into view first
303      await this.evaluate(`
304        (() => {
305          const el = document.querySelector('[data-opencli-ref="' + ${safeRef} + '"]');
306          if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' });
307          return !!el;
308        })()
309      `);
310  
311      try {
312        // Find DOM node via CDP
313        const doc = await this.cdp('DOM.getDocument', {}) as { root: { nodeId: number } };
314        const result = await this.cdp('DOM.querySelectorAll', {
315          nodeId: doc.root.nodeId,
316          selector: cssSelector,
317        }) as { nodeIds: number[] };
318  
319        if (!result.nodeIds?.length) throw new Error('DOM node not found');
320  
321        const nodeId = result.nodeIds[0];
322  
323        // Try getContentQuads first (precise for inline elements)
324        try {
325          const quads = await this.cdp('DOM.getContentQuads', { nodeId }) as { quads: number[][] };
326          if (quads.quads?.length) {
327            const q = quads.quads[0];
328            const cx = (q[0] + q[2] + q[4] + q[6]) / 4;
329            const cy = (q[1] + q[3] + q[5] + q[7]) / 4;
330            await this.nativeClick(Math.round(cx), Math.round(cy));
331            return;
332          }
333        } catch { /* fallthrough */ }
334  
335        // Try getBoxModel
336        try {
337          const box = await this.cdp('DOM.getBoxModel', { nodeId }) as { model: { content: number[] } };
338          if (box.model?.content) {
339            const c = box.model.content;
340            const cx = (c[0] + c[2] + c[4] + c[6]) / 4;
341            const cy = (c[1] + c[3] + c[5] + c[7]) / 4;
342            await this.nativeClick(Math.round(cx), Math.round(cy));
343            return;
344          }
345        } catch { /* fallthrough */ }
346      } catch { /* fallthrough */ }
347  
348      // Final fallback: regular click
349      await this.evaluate(`
350        (() => {
351          const el = document.querySelector('[data-opencli-ref="' + ${safeRef} + '"]');
352          if (!el) throw new Error('Element not found: ' + ${safeRef});
353          el.click();
354          return 'clicked';
355        })()
356      `);
357    }
358  
359    async nativeClick(x: number, y: number): Promise<void> {
360      await this.cdp('Input.dispatchMouseEvent', {
361        type: 'mousePressed',
362        x, y,
363        button: 'left',
364        clickCount: 1,
365      });
366      await this.cdp('Input.dispatchMouseEvent', {
367        type: 'mouseReleased',
368        x, y,
369        button: 'left',
370        clickCount: 1,
371      });
372    }
373  
374    async nativeType(text: string): Promise<void> {
375      // Use Input.insertText for reliable Unicode/CJK text insertion
376      await this.cdp('Input.insertText', { text });
377    }
378  
379    async nativeKeyPress(key: string, modifiers: string[] = []): Promise<void> {
380      let modifierFlags = 0;
381      for (const mod of modifiers) {
382        if (mod === 'Alt') modifierFlags |= 1;
383        if (mod === 'Ctrl') modifierFlags |= 2;
384        if (mod === 'Meta') modifierFlags |= 4;
385        if (mod === 'Shift') modifierFlags |= 8;
386      }
387      await this.cdp('Input.dispatchKeyEvent', {
388        type: 'keyDown',
389        key,
390        modifiers: modifierFlags,
391      });
392      await this.cdp('Input.dispatchKeyEvent', {
393        type: 'keyUp',
394        key,
395        modifiers: modifierFlags,
396      });
397    }
398  }