@johpaz/hive-sdk 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,18 @@
1
1
  /**
2
- * BrowserService — Browser automation via agent-browser CLI (Rust).
2
+ * BrowserService — automatización de navegador, con backend intercambiable.
3
3
  *
4
- * Flujo:
4
+ * `AgentBrowserBackend` (este archivo) habla con el CLI de agent-browser por
5
+ * subproceso: maneja Chrome de verdad y corre headless, así que es el único que
6
+ * sirve en Docker o en un servidor sin display. Por eso sigue siendo el default.
7
+ *
8
+ * `WebViewBackend` (webview-backend.ts) usa `Bun.WebView` in-process — sin
9
+ * instalación ni subprocesos, mucho más rápido — pero necesita entorno gráfico.
10
+ * Se elige con `tools.browser.backend` o `HIVE_BROWSER_BACKEND`.
11
+ *
12
+ * Flujo del backend por CLI:
5
13
  * 1. Detecta si agent-browser está instalado (lazy install en primer uso).
6
14
  * 2. Ejecuta comandos via CLI con --json para output estructurado.
7
15
  * 3. El daemon de agent-browser maneja Chrome internamente via CDP.
8
- * 4. Las herramientas de browser usan AgentBrowserView (API compatible con CDPClient).
9
16
  */
10
17
 
11
18
  import { logger } from "../../utils/logger.ts";
@@ -13,6 +20,13 @@ import type { Config } from "../../config/loader.ts";
13
20
  import { existsSync, mkdirSync, readFileSync, rmSync } from "fs";
14
21
  import { homedir, tmpdir } from "os";
15
22
  import { dirname, join, resolve } from "path";
23
+ import {
24
+ resolveBackendKind,
25
+ type BrowserBackend,
26
+ type BrowserBackendKind,
27
+ type ScreenshotOptions,
28
+ type SnapshotOptions,
29
+ } from "./browser-backend.ts";
16
30
 
17
31
  const log = logger.child("browser-service");
18
32
 
@@ -117,7 +131,7 @@ async function ensureChromeInstalled(): Promise<void> {
117
131
 
118
132
  // ─── AgentBrowserView (API compatible con CDPClient) ──────────────────────────
119
133
 
120
- export class AgentBrowserView {
134
+ export class AgentBrowserView implements BrowserBackend {
121
135
  private sessionName: string;
122
136
  private _url = "";
123
137
 
@@ -162,12 +176,7 @@ export class AgentBrowserView {
162
176
  return res.data?.result as T;
163
177
  }
164
178
 
165
- async screenshot(options?: {
166
- encoding?: "blob" | "buffer" | "base64" | "shmem";
167
- format?: "png" | "jpeg" | "webp";
168
- quality?: number;
169
- clip?: { x: number; y: number; width: number; height: number; scale: number };
170
- }): Promise<string> {
179
+ async screenshot(options?: ScreenshotOptions): Promise<string> {
171
180
  // Build args
172
181
  const args: string[] = ["screenshot"];
173
182
 
@@ -264,8 +273,21 @@ export class AgentBrowserView {
264
273
  if (!res.success) throw new Error(res.error || "resize failed");
265
274
  }
266
275
 
276
+ /** Screenshot recortado a un elemento — agent-browser acepta el selector posicional. */
277
+ async screenshotElement(selector: string): Promise<string> {
278
+ const res = await this.run(["screenshot", selector]);
279
+ if (!res.success) throw new Error(res.error || `screenshot failed: ${selector}`);
280
+
281
+ const path = res.data?.path as string;
282
+ if (!path) throw new Error("screenshot did not return a path");
283
+
284
+ const base64 = Buffer.from(readFileSync(path)).toString("base64");
285
+ try { rmSync(path); } catch { /* ignore */ }
286
+ return base64;
287
+ }
288
+
267
289
  /** Capture accessibility tree snapshot (compact, AI-optimized). ~200-600 chars vs ~3000+ innerText. */
268
- async snapshot(options?: { compact?: boolean; depth?: number; interactiveOnly?: boolean }): Promise<string> {
290
+ async snapshot(options?: SnapshotOptions): Promise<string> {
269
291
  const args = ["snapshot"];
270
292
  if (options?.compact !== false) args.push("-c");
271
293
  if (options?.depth) args.push("-d", String(options.depth));
@@ -326,11 +348,17 @@ export type LaunchSpec = { kind: "remote"; cdpUrl: string };
326
348
 
327
349
  // ─── BrowserService (singleton) ───────────────────────────────────────────────
328
350
 
329
- export type BrowserView = AgentBrowserView;
351
+ /** Alias histórico: las tools sólo dependen del contrato, no de la implementación. */
352
+ export type BrowserView = BrowserBackend;
353
+
354
+ /** Re-export para que quien importe el servicio no tenga que conocer el módulo del contrato. */
355
+ export type { BrowserBackend, BrowserBackendKind } from "./browser-backend.ts";
356
+ export { isWebViewSupported, resolveBackendKind } from "./browser-backend.ts";
330
357
 
331
- let _client: AgentBrowserView | null = null;
358
+ let _client: BrowserBackend | null = null;
332
359
  let _available = false;
333
360
  let _launching = false;
361
+ let _kind: BrowserBackendKind = "agent-browser";
334
362
 
335
363
  export class BrowserService {
336
364
  private static instance: BrowserService | null = null;
@@ -357,6 +385,17 @@ export class BrowserService {
357
385
  return false;
358
386
  }
359
387
 
388
+ _kind = resolveBackendKind(b?.backend);
389
+
390
+ if (_kind === "webview") {
391
+ // No hay nada que instalar ni que descargar: el WebView se crea al primer
392
+ // uso. Si el entorno no lo soporta, ensureView() falla ahí y el servicio
393
+ // queda no disponible, igual que cuando agent-browser no arranca.
394
+ _available = true;
395
+ log.info("✅ Backend de navegador: Bun.WebView (in-process, sin Chrome)");
396
+ return true;
397
+ }
398
+
360
399
  const installed = await isAgentBrowserInstalled();
361
400
 
362
401
  if (!installed) {
@@ -392,8 +431,13 @@ export class BrowserService {
392
431
  }
393
432
  _launching = true;
394
433
  try {
395
- const sessionName = this.config.tools?.browser?.sessionName ?? DEFAULT_SESSION_NAME;
396
- _client = new AgentBrowserView(sessionName);
434
+ if (_kind === "webview") {
435
+ const { WebViewBackend } = await import("./webview-backend.ts");
436
+ _client = new WebViewBackend({ show: this.config.tools?.browser?.headless === false });
437
+ } else {
438
+ const sessionName = this.config.tools?.browser?.sessionName ?? DEFAULT_SESSION_NAME;
439
+ _client = new AgentBrowserView(sessionName);
440
+ }
397
441
  log.info("✅ Browser abierto — el usuario verá las acciones del agente");
398
442
  return true;
399
443
  } catch (err) {
@@ -406,20 +450,25 @@ export class BrowserService {
406
450
  }
407
451
  }
408
452
 
409
- async getView(): Promise<AgentBrowserView | null> {
453
+ async getView(): Promise<BrowserBackend | null> {
410
454
  if (!_available) return null;
411
455
  await this._ensureLaunched();
412
456
  return _client;
413
457
  }
414
458
 
415
- getViewSync(): AgentBrowserView | null {
459
+ getViewSync(): BrowserBackend | null {
416
460
  return _client;
417
461
  }
418
462
 
419
- async getPage(): Promise<AgentBrowserView | null> {
463
+ async getPage(): Promise<BrowserBackend | null> {
420
464
  return this.getView();
421
465
  }
422
466
 
467
+ /** Qué backend quedó activo — lo reporta `hive doctor` y los tests. */
468
+ getBackendKind(): BrowserBackendKind {
469
+ return _kind;
470
+ }
471
+
423
472
  isAvailable(): boolean {
424
473
  return _available;
425
474
  }
@@ -428,8 +477,8 @@ export class BrowserService {
428
477
  return _available && _client !== null;
429
478
  }
430
479
 
431
- getInfo(): { running: boolean } {
432
- return { running: this.isRunning() };
480
+ getInfo(): { running: boolean; backend: BrowserBackendKind } {
481
+ return { running: this.isRunning(), backend: _kind };
433
482
  }
434
483
 
435
484
  async stop(): Promise<void> {
@@ -462,7 +511,7 @@ export function getBrowserService(): BrowserService | null {
462
511
  // ─── Helpers (misma API que antes) ───────────────────────────────────────────
463
512
 
464
513
  export async function waitForSelector(
465
- view: AgentBrowserView,
514
+ view: BrowserBackend,
466
515
  selector: string,
467
516
  timeout = 30000
468
517
  ): Promise<void> {
@@ -476,7 +525,7 @@ export async function waitForSelector(
476
525
  }
477
526
 
478
527
  export async function waitForCondition(
479
- view: AgentBrowserView,
528
+ view: BrowserBackend,
480
529
  expression: string,
481
530
  timeout = 30000
482
531
  ): Promise<void> {
@@ -490,19 +539,10 @@ export async function waitForCondition(
490
539
  }
491
540
 
492
541
  export async function screenshotElement(
493
- view: AgentBrowserView,
542
+ view: BrowserBackend,
494
543
  selector: string
495
544
  ): Promise<string> {
496
- const res = await (view as any).run(["screenshot", selector]);
497
- if (!res.success) throw new Error(res.error || `screenshot failed: ${selector}`);
498
-
499
- const path = res.data?.path as string;
500
- if (!path) throw new Error("screenshot did not return a path");
501
-
502
- const data = readFileSync(path);
503
- const base64 = Buffer.from(data).toString("base64");
504
-
505
- try { rmSync(path); } catch { /* ignore */ }
506
-
507
- return base64;
545
+ // Antes esto hacía `(view as any).run([...])`, atándose a la implementación por
546
+ // CLI: con dos backends el recorte es responsabilidad de cada uno.
547
+ return view.screenshotElement(selector);
508
548
  }
@@ -0,0 +1,412 @@
1
+ /**
2
+ * WebViewBackend — `BrowserBackend` sobre `Bun.WebView` (Bun >= 1.3).
3
+ *
4
+ * Corre in-process: no hay subproceso, ni instalación de ~75 MB, ni descarga de
5
+ * Chrome. Un `evaluate` cuesta ~0.25 ms contra los ~68 ms de piso que tiene cada
6
+ * invocación del CLI de agent-browser. A cambio necesita entorno gráfico, así
7
+ * que no reemplaza a agent-browser en Docker ni en un servidor headless.
8
+ *
9
+ * Dos restricciones del motor mandan sobre el diseño de este archivo:
10
+ *
11
+ * 1. `Bun.WebView` acepta **una sola operación pendiente por vez**; dos
12
+ * llamadas solapadas fallan con `ERR_INVALID_STATE: a simple operation is
13
+ * already pending`. Todo pasa por una cola serializada.
14
+ * 2. No expone árbol de accesibilidad. `snapshot()` se sintetiza recorriendo
15
+ * el DOM, imitando el formato que emite agent-browser para que el modelo
16
+ * vea lo mismo con cualquiera de los dos backends.
17
+ */
18
+
19
+ import { logger } from "../../utils/logger.ts";
20
+ import { resolveWebViewEngine, type BrowserBackend, type ScreenshotOptions, type SnapshotOptions, type WebViewEngine } from "./browser-backend.ts";
21
+
22
+ const log = logger.child("webview-backend");
23
+
24
+ /** Tope del texto del snapshot: un DOM grande no puede comerse el contexto. */
25
+ const SNAPSHOT_CHAR_LIMIT = 20_000;
26
+
27
+ /**
28
+ * Forma real de `Bun.WebView` en 1.3.14, verificada contra el prototipo.
29
+ *
30
+ * No se usan los tipos de `bun-types` a propósito: declaran `back()`/`forward()`
31
+ * y el runtime expone `goBack()`/`goForward()`. Contra los tipos, la navegación
32
+ * hacia atrás compila y explota en ejecución.
33
+ */
34
+ interface BunWebView {
35
+ navigate(url: string): Promise<void>;
36
+ evaluate(script: string): Promise<unknown>;
37
+ screenshot(): Promise<Blob>;
38
+ cdp(method: string, params?: Record<string, unknown>): Promise<unknown>;
39
+ click(selector: string): Promise<void>;
40
+ type(text: string): Promise<void>;
41
+ press(key: string, modifiers?: Record<string, boolean>): Promise<void>;
42
+ scroll(dx: number, dy: number): Promise<void>;
43
+ scrollTo(selector: string): Promise<void>;
44
+ resize(width: number, height: number): Promise<void>;
45
+ goBack(): Promise<void>;
46
+ goForward(): Promise<void>;
47
+ reload(): Promise<void>;
48
+ close(): void;
49
+ readonly url: string;
50
+ readonly title: string;
51
+ readonly loading: boolean;
52
+ }
53
+
54
+ /**
55
+ * El script que sintetiza el árbol de accesibilidad. Se inyecta como texto, así
56
+ * que no puede cerrar sobre nada del scope de TypeScript: los parámetros entran
57
+ * interpolados como literales JSON.
58
+ */
59
+ function buildSnapshotScript(options: Required<SnapshotOptions>): string {
60
+ return `(() => {
61
+ const MAX_DEPTH = ${JSON.stringify(options.depth)};
62
+ const COMPACT = ${JSON.stringify(options.compact)};
63
+ const INTERACTIVE_ONLY = ${JSON.stringify(options.interactiveOnly)};
64
+ const LIMIT = ${SNAPSHOT_CHAR_LIMIT};
65
+
66
+ const ROLE_BY_TAG = {
67
+ A: "link", BUTTON: "button", P: "paragraph", IMG: "img", TEXTAREA: "textbox",
68
+ SELECT: "combobox", OPTION: "option", UL: "list", OL: "list", LI: "listitem",
69
+ TABLE: "table", TR: "row", TD: "cell", TH: "columnheader", FORM: "form",
70
+ NAV: "navigation", MAIN: "main", HEADER: "banner", FOOTER: "contentinfo",
71
+ ASIDE: "complementary", LABEL: "label", ARTICLE: "article", SECTION: "region",
72
+ DIALOG: "dialog", SUMMARY: "button", IFRAME: "iframe", VIDEO: "video", AUDIO: "audio",
73
+ };
74
+ const INTERACTIVE = new Set(["link", "button", "textbox", "checkbox", "radio", "combobox", "option", "searchbox"]);
75
+ const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK", "TITLE", "SVG", "PATH"]);
76
+
77
+ function inputRole(el) {
78
+ const type = (el.getAttribute("type") || "text").toLowerCase();
79
+ if (type === "checkbox") return "checkbox";
80
+ if (type === "radio") return "radio";
81
+ if (type === "search") return "searchbox";
82
+ if (type === "button" || type === "submit" || type === "reset" || type === "image") return "button";
83
+ if (type === "hidden") return null;
84
+ return "textbox";
85
+ }
86
+
87
+ function roleOf(el) {
88
+ const explicit = el.getAttribute("role");
89
+ if (explicit) return explicit.trim().split(/\\s+/)[0];
90
+ if (el.tagName === "INPUT") return inputRole(el);
91
+ if (/^H[1-6]$/.test(el.tagName)) return "heading";
92
+ if (el.tagName === "A") return el.hasAttribute("href") ? "link" : null;
93
+ return ROLE_BY_TAG[el.tagName] || null;
94
+ }
95
+
96
+ function ownText(el) {
97
+ let text = "";
98
+ for (const node of el.childNodes) {
99
+ if (node.nodeType === 3) text += node.nodeValue;
100
+ // Los inline sin rol propio son parte del nombre del padre, no nodos aparte.
101
+ else if (node.nodeType === 1 && !roleOf(node) && node.childElementCount === 0) {
102
+ text += node.textContent || "";
103
+ }
104
+ }
105
+ return text.replace(/\\s+/g, " ").trim();
106
+ }
107
+
108
+ function nameOf(el) {
109
+ const aria = el.getAttribute("aria-label");
110
+ if (aria && aria.trim()) return aria.trim();
111
+
112
+ const labelledBy = el.getAttribute("aria-labelledby");
113
+ if (labelledBy) {
114
+ const parts = labelledBy.split(/\\s+/)
115
+ .map((id) => { const target = document.getElementById(id); return target ? (target.textContent || "").trim() : ""; })
116
+ .filter(Boolean);
117
+ if (parts.length) return parts.join(" ").replace(/\\s+/g, " ");
118
+ }
119
+
120
+ if (el.tagName === "IMG") return (el.getAttribute("alt") || "").trim();
121
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
122
+ const type = (el.getAttribute("type") || "").toLowerCase();
123
+ if (type === "button" || type === "submit" || type === "reset") return (el.value || "").trim();
124
+ const placeholder = (el.getAttribute("placeholder") || "").trim();
125
+ if (placeholder) return placeholder;
126
+ if (el.labels && el.labels.length) return (el.labels[0].textContent || "").replace(/\\s+/g, " ").trim();
127
+ return (el.getAttribute("name") || "").trim();
128
+ }
129
+
130
+ const own = ownText(el);
131
+ if (own) return own;
132
+ return (el.getAttribute("title") || "").trim();
133
+ }
134
+
135
+ function visible(el) {
136
+ if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") return false;
137
+ const style = getComputedStyle(el);
138
+ if (style.display === "none" || style.visibility === "hidden") return false;
139
+ return true;
140
+ }
141
+
142
+ function attrsOf(el, role) {
143
+ const attrs = [];
144
+ if (role === "heading") attrs.push("level=" + el.tagName.slice(1));
145
+ if (el.disabled) attrs.push("disabled");
146
+ if (el.checked) attrs.push("checked");
147
+ if (el.getAttribute("aria-expanded")) attrs.push("expanded=" + el.getAttribute("aria-expanded"));
148
+ return attrs;
149
+ }
150
+
151
+ const lines = [];
152
+ let refSeq = 0;
153
+ let truncated = false;
154
+
155
+ function walk(el, depth) {
156
+ if (truncated) return;
157
+ for (const child of el.children) {
158
+ if (truncated) return;
159
+ if (SKIP_TAGS.has(child.tagName)) continue;
160
+ if (!visible(child)) continue;
161
+
162
+ const role = roleOf(child);
163
+ const name = role ? nameOf(child) : "";
164
+ const interactive = role ? INTERACTIVE.has(role) : false;
165
+ // Un nodo se emite si aporta algo: un rol con nombre, o algo accionable.
166
+ let emit = Boolean(role) && (Boolean(name) || interactive);
167
+ if (INTERACTIVE_ONLY && !interactive) emit = false;
168
+ if (COMPACT && role && !name && !interactive) emit = false;
169
+
170
+ if (emit && depth < MAX_DEPTH) {
171
+ const attrs = attrsOf(child, role);
172
+ if (name || interactive) attrs.push("ref=e" + ++refSeq);
173
+ let label = "- " + role;
174
+ if (name) {
175
+ const shown = COMPACT && name.length > 120 ? name.slice(0, 120) + "…" : name;
176
+ label += ' "' + shown.replace(/"/g, "'") + '"';
177
+ }
178
+ if (attrs.length) label += " [" + attrs.join(", ") + "]";
179
+ const line = " ".repeat(depth) + label;
180
+ if (lines.join("\\n").length + line.length > LIMIT) { truncated = true; return; }
181
+ lines.push(line);
182
+ }
183
+
184
+ // Sin línea propia, los hijos suben de nivel: así el árbol no se llena de
185
+ // sangría por cada <div> de maquetado.
186
+ walk(child, emit && depth < MAX_DEPTH ? depth + 1 : depth);
187
+ }
188
+ }
189
+
190
+ walk(document.body || document.documentElement, 0);
191
+ if (truncated) lines.push("… (snapshot truncado)");
192
+ return lines.join("\\n");
193
+ })()`;
194
+ }
195
+
196
+ export class WebViewBackend implements BrowserBackend {
197
+ private view: BunWebView | null = null;
198
+ private _url = "";
199
+ /** Cola de una sola vía: WebView rechaza operaciones solapadas. */
200
+ private queue: Promise<unknown> = Promise.resolve();
201
+
202
+ constructor(
203
+ private readonly options: {
204
+ width?: number;
205
+ height?: number;
206
+ show?: boolean;
207
+ engine?: WebViewEngine;
208
+ } = {},
209
+ ) {}
210
+
211
+ private ensureView(): BunWebView {
212
+ if (this.view) return this.view;
213
+
214
+ const WebView = (globalThis as { Bun?: { WebView?: unknown } }).Bun?.WebView;
215
+ if (typeof WebView !== "function") {
216
+ throw new Error("Bun.WebView no está disponible en este runtime (requiere Bun >= 1.3)");
217
+ }
218
+
219
+ const engine = this.options.engine ?? resolveWebViewEngine();
220
+ if (!engine) {
221
+ throw new Error(
222
+ "Bun.WebView no tiene motor utilizable: WebKit sólo existe en macOS y no se encontró Chrome. " +
223
+ "Instalá Chrome o definí BUN_CHROME_PATH.",
224
+ );
225
+ }
226
+
227
+ // `url: false` es obligatorio para automatización desatendida: sin eso el
228
+ // motor chrome intenta CONECTARSE a un Chrome que ya esté corriendo, y esa
229
+ // ruta abre un diálogo "Allow remote debugging?" que cuelga el proceso
230
+ // esperando un click que en un servidor no llega nunca.
231
+ const backend =
232
+ engine === "chrome"
233
+ ? { type: "chrome" as const, url: false as const, stderr: "ignore" as const }
234
+ : ("webkit" as const);
235
+
236
+ // Sin `url` inicial a propósito: construir con uno deja una navegación
237
+ // pendiente y el primer navigate() explota con ERR_INVALID_STATE.
238
+ const Ctor = WebView as unknown as new (opts: unknown) => BunWebView;
239
+ this.view = new Ctor({
240
+ backend,
241
+ show: this.options.show ?? false,
242
+ width: this.options.width ?? 1280,
243
+ height: this.options.height ?? 800,
244
+ });
245
+ log.info(`✅ WebView abierto (motor: ${engine}, in-process)`);
246
+ return this.view;
247
+ }
248
+
249
+ /** Serializa: cada operación espera a que termine la anterior. */
250
+ private run<T>(operation: (view: BunWebView) => Promise<T>): Promise<T> {
251
+ const next = this.queue.then(
252
+ () => operation(this.ensureView()),
253
+ () => operation(this.ensureView()),
254
+ );
255
+ // La cola no debe romperse porque una operación haya fallado.
256
+ this.queue = next.catch(() => undefined);
257
+ return next;
258
+ }
259
+
260
+ get url(): string {
261
+ return this.view?.url || this._url;
262
+ }
263
+
264
+ get title(): string {
265
+ return this.view?.title || "";
266
+ }
267
+
268
+ get loading(): boolean {
269
+ return this.view?.loading ?? false;
270
+ }
271
+
272
+ async navigate(url: string): Promise<void> {
273
+ const target = /^[a-z]+:/i.test(url) ? url : `https://${url}`;
274
+ await this.run((view) => view.navigate(target));
275
+ this._url = this.view?.url || target;
276
+ }
277
+
278
+ async evaluate<T = unknown>(script: string): Promise<T> {
279
+ const trimmed = script.trim();
280
+ let wrapped = script;
281
+ if (/\bawait\b/.test(script) && !trimmed.startsWith("(async") && !trimmed.startsWith("async function")) {
282
+ wrapped = trimmed.startsWith("return")
283
+ ? `(async () => { ${script} })()`
284
+ : `(async () => { return ${script}; })()`;
285
+ }
286
+ return (await this.run((view) => view.evaluate(wrapped))) as T;
287
+ }
288
+
289
+ async screenshot(_options?: ScreenshotOptions): Promise<string> {
290
+ const blob = await this.run((view) => view.screenshot());
291
+ return Buffer.from(await blob.arrayBuffer()).toString("base64");
292
+ }
293
+
294
+ async screenshotElement(selector: string): Promise<string> {
295
+ const box = await this.evaluate<{ x: number; y: number; width: number; height: number } | null>(
296
+ `(() => {
297
+ const el = document.querySelector(${JSON.stringify(selector)});
298
+ if (!el) return null;
299
+ el.scrollIntoView({ block: "center", inline: "center" });
300
+ const r = el.getBoundingClientRect();
301
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
302
+ })()`,
303
+ );
304
+ if (!box || box.width <= 0 || box.height <= 0) {
305
+ throw new Error(`screenshot failed: elemento no visible o inexistente: ${selector}`);
306
+ }
307
+
308
+ // WebView.screenshot() no recorta, pero el puente CDP sí acepta `clip`.
309
+ const shot = await this.run((view) =>
310
+ view.cdp("Page.captureScreenshot", {
311
+ format: "png",
312
+ clip: { x: box.x, y: box.y, width: box.width, height: box.height, scale: 1 },
313
+ }),
314
+ );
315
+ const data = (shot as { data?: string })?.data;
316
+ if (!data) throw new Error(`screenshot failed: CDP no devolvió imagen para ${selector}`);
317
+ return data;
318
+ }
319
+
320
+ async snapshot(options?: SnapshotOptions): Promise<string> {
321
+ const script = buildSnapshotScript({
322
+ compact: options?.compact !== false,
323
+ depth: options?.depth ?? 12,
324
+ interactiveOnly: options?.interactiveOnly ?? false,
325
+ });
326
+ return (await this.evaluate<string>(script)) || "";
327
+ }
328
+
329
+ async click(selector: string, _options?: Record<string, unknown>): Promise<void> {
330
+ await this.run((view) => view.click(selector));
331
+ }
332
+
333
+ async type(text: string): Promise<void> {
334
+ await this.run((view) => view.type(text));
335
+ }
336
+
337
+ async typeIn(selector: string, text: string): Promise<void> {
338
+ const focused = await this.evaluate<boolean>(
339
+ `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.focus(); return true; })()`,
340
+ );
341
+ if (!focused) throw new Error(`type failed: elemento no encontrado: ${selector}`);
342
+ await this.run((view) => view.type(text));
343
+ }
344
+
345
+ async fill(selector: string, text: string): Promise<void> {
346
+ // `fill` reemplaza; `type` agrega. Se limpia primero y se disparan los
347
+ // eventos que esperan React y compañía para registrar el cambio.
348
+ const ok = await this.evaluate<boolean>(
349
+ `(() => {
350
+ const el = document.querySelector(${JSON.stringify(selector)});
351
+ if (!el) return false;
352
+ el.focus();
353
+ el.value = "";
354
+ el.dispatchEvent(new Event("input", { bubbles: true }));
355
+ return true;
356
+ })()`,
357
+ );
358
+ if (!ok) throw new Error(`fill failed: elemento no encontrado: ${selector}`);
359
+ await this.run((view) => view.type(text));
360
+ await this.evaluate(
361
+ `(() => {
362
+ const el = document.querySelector(${JSON.stringify(selector)});
363
+ if (el) el.dispatchEvent(new Event("change", { bubbles: true }));
364
+ })()`,
365
+ );
366
+ }
367
+
368
+ async press(key: string, options?: { modifiers?: string[] }): Promise<void> {
369
+ const modifiers: Record<string, boolean> = {};
370
+ for (const modifier of options?.modifiers ?? []) {
371
+ const normalized = modifier.toLowerCase();
372
+ if (normalized === "control" || normalized === "ctrl") modifiers.ctrl = true;
373
+ else if (normalized === "shift") modifiers.shift = true;
374
+ else if (normalized === "alt") modifiers.alt = true;
375
+ else if (normalized === "meta" || normalized === "cmd") modifiers.meta = true;
376
+ }
377
+ await this.run((view) => view.press(key, modifiers));
378
+ }
379
+
380
+ async scroll(dx: number, dy: number): Promise<void> {
381
+ await this.run((view) => view.scroll(dx, dy));
382
+ }
383
+
384
+ async scrollTo(selector: string, _options?: { behavior?: "smooth" | "instant" }): Promise<void> {
385
+ await this.run((view) => view.scrollTo(selector));
386
+ }
387
+
388
+ async back(): Promise<void> {
389
+ await this.run((view) => view.goBack());
390
+ }
391
+
392
+ async forward(): Promise<void> {
393
+ await this.run((view) => view.goForward());
394
+ }
395
+
396
+ async reload(): Promise<void> {
397
+ await this.run((view) => view.reload());
398
+ }
399
+
400
+ async resize(width: number, height: number): Promise<void> {
401
+ await this.run((view) => view.resize(width, height));
402
+ }
403
+
404
+ close(): void {
405
+ try {
406
+ this.view?.close();
407
+ } catch {
408
+ /* ya cerrado */
409
+ }
410
+ this.view = null;
411
+ }
412
+ }