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 }