@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.
- package/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/bun.lock +55 -29
- package/package.json +9 -9
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +9 -9
- package/packages/core/src/agent/acceptance-checks.ts +7 -1
- package/packages/core/src/config/loader.ts +5 -0
- package/packages/core/src/tools/web/browser-backend.ts +129 -0
- package/packages/core/src/tools/web/browser-service.ts +75 -35
- package/packages/core/src/tools/web/webview-backend.ts +412 -0
- package/test/acceptance-checks.test.ts +403 -0
- package/test/browser-backend.test.ts +308 -0
- package/test/tool-selector-runtime-tools.test.ts +117 -0
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* BrowserService —
|
|
2
|
+
* BrowserService — automatización de navegador, con backend intercambiable.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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?:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
396
|
-
|
|
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<
|
|
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():
|
|
459
|
+
getViewSync(): BrowserBackend | null {
|
|
416
460
|
return _client;
|
|
417
461
|
}
|
|
418
462
|
|
|
419
|
-
async getPage(): Promise<
|
|
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:
|
|
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:
|
|
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:
|
|
542
|
+
view: BrowserBackend,
|
|
494
543
|
selector: string
|
|
495
544
|
): Promise<string> {
|
|
496
|
-
|
|
497
|
-
|
|
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
|
+
}
|