@johpaz/hive-sdk 0.1.5 → 0.2.0

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.
Files changed (182) hide show
  1. package/CHANGELOG.md +167 -0
  2. package/README.md +11 -1
  3. package/package.json +26 -10
  4. package/packages/core/src/agent/acceptance-checks.ts +16 -10
  5. package/packages/core/src/agent/agent-catalog.ts +3 -3
  6. package/packages/core/src/agent/agent-loop.ts +115 -26
  7. package/packages/core/src/agent/capability-search.ts +2 -2
  8. package/packages/core/src/agent/catalog-selector.ts +4 -4
  9. package/packages/core/src/agent/compaction.ts +10 -9
  10. package/packages/core/src/agent/context-compiler.ts +58 -34
  11. package/packages/core/src/agent/conversation-store.ts +31 -7
  12. package/packages/core/src/agent/curator.ts +4 -4
  13. package/packages/core/src/agent/delegation-runtime.ts +5 -5
  14. package/packages/core/src/agent/goal-runner.ts +9 -9
  15. package/packages/core/src/agent/index.ts +1 -0
  16. package/packages/core/src/agent/llm-client.ts +98 -37
  17. package/packages/core/src/agent/llm-providers/anthropic.ts +4 -4
  18. package/packages/core/src/agent/llm-providers/deepseek.ts +1 -1
  19. package/packages/core/src/agent/llm-providers/gemini.ts +4 -4
  20. package/packages/core/src/agent/llm-providers/groq.ts +1 -1
  21. package/packages/core/src/agent/llm-providers/hiveagents.ts +3 -3
  22. package/packages/core/src/agent/llm-providers/interface.ts +2 -2
  23. package/packages/core/src/agent/llm-providers/kimi.ts +1 -1
  24. package/packages/core/src/agent/llm-providers/minimax.ts +1 -1
  25. package/packages/core/src/agent/llm-providers/mistral.ts +1 -1
  26. package/packages/core/src/agent/llm-providers/modelscope.ts +1 -1
  27. package/packages/core/src/agent/llm-providers/nvidia.ts +1 -1
  28. package/packages/core/src/agent/llm-providers/ollama.ts +4 -4
  29. package/packages/core/src/agent/llm-providers/openai-compat-base.ts +9 -5
  30. package/packages/core/src/agent/llm-providers/openai.ts +1 -1
  31. package/packages/core/src/agent/llm-providers/opencode-go.ts +1 -1
  32. package/packages/core/src/agent/llm-providers/openrouter.ts +1 -1
  33. package/packages/core/src/agent/llm-providers/qwen.ts +1 -1
  34. package/packages/core/src/agent/llm-providers/z-ai.ts +1 -1
  35. package/packages/core/src/agent/mcp-result-normalizer.ts +192 -0
  36. package/packages/core/src/agent/playbook-selector.ts +4 -4
  37. package/packages/core/src/agent/prompt-builder.ts +5 -5
  38. package/packages/core/src/agent/proof-packet.ts +5 -5
  39. package/packages/core/src/agent/providers/index.ts +4 -4
  40. package/packages/core/src/agent/realtime-providers/gemini-live.ts +238 -0
  41. package/packages/core/src/agent/realtime-providers/index.ts +29 -0
  42. package/packages/core/src/agent/realtime-providers/interface.ts +108 -0
  43. package/packages/core/src/agent/reflector.ts +6 -6
  44. package/packages/core/src/agent/run-store.ts +8 -8
  45. package/packages/core/src/agent/service.ts +10 -10
  46. package/packages/core/src/agent/skill-selector.ts +6 -6
  47. package/packages/core/src/agent/thread-id.ts +71 -0
  48. package/packages/core/src/agent/thread-store.ts +250 -0
  49. package/packages/core/src/agent/tool-selector.ts +7 -5
  50. package/packages/core/src/agent/tracer.ts +5 -5
  51. package/packages/core/src/api/createAgent.ts +1 -1
  52. package/packages/core/src/artifacts/store.ts +84 -3
  53. package/packages/core/src/canvas/emitter.ts +2 -2
  54. package/packages/core/src/channels/telegram.ts +1 -1
  55. package/packages/core/src/channels/webchat.ts +1 -1
  56. package/packages/core/src/config/loader.ts +15 -1
  57. package/packages/core/src/events/agent-bus.ts +3 -3
  58. package/packages/core/src/events/channel-narration.ts +3 -3
  59. package/packages/core/src/events/event-bus.ts +1 -1
  60. package/packages/core/src/events/narration.ts +3 -3
  61. package/packages/core/src/gateway/delegation-groups.ts +4 -4
  62. package/packages/core/src/gateway/durable-queue.ts +5 -5
  63. package/packages/core/src/gateway/job-store.ts +5 -5
  64. package/packages/core/src/gateway/notification-inbox.ts +2 -2
  65. package/packages/core/src/gateway/server.ts +2 -2
  66. package/packages/core/src/mcp/MCPClient.ts +3 -3
  67. package/packages/core/src/mcp/hot-reload.ts +5 -5
  68. package/packages/core/src/mcp/tool-sync.ts +5 -5
  69. package/packages/core/src/mcp/transports/index.ts +2 -2
  70. package/packages/core/src/mcp/transports/sse.ts +1 -1
  71. package/packages/core/src/models/index.ts +36 -0
  72. package/packages/core/src/multimodal/index.ts +2 -2
  73. package/packages/core/src/multimodal/vision-service.ts +6 -6
  74. package/packages/core/src/plugins/loader.ts +4 -1
  75. package/packages/core/src/resilience/circuit-breaker.ts +16 -5
  76. package/packages/core/src/resilience/retry.ts +1 -1
  77. package/packages/core/src/scheduler/CronScheduler.ts +6 -6
  78. package/packages/core/src/scheduler/integration.ts +9 -9
  79. package/packages/core/src/sessions/index.ts +266 -0
  80. package/packages/core/src/storage/bootstrap.ts +34 -8
  81. package/packages/core/src/storage/causal-events.ts +1 -1
  82. package/packages/core/src/storage/collections.ts +32 -1
  83. package/packages/core/src/storage/crypto.ts +16 -2
  84. package/packages/core/src/storage/hive.ts +1 -1
  85. package/packages/core/src/storage/hivedb.ts +10 -1
  86. package/packages/core/src/storage/onboarding.ts +6 -6
  87. package/packages/core/src/storage/reconcile.ts +5 -5
  88. package/packages/core/src/storage/seed.ts +103 -13
  89. package/packages/core/src/storage/usage.ts +3 -3
  90. package/packages/core/src/swarm/AgentExecutor.ts +2 -2
  91. package/packages/core/src/swarm/Coordinator.ts +8 -8
  92. package/packages/core/src/swarm/EventBridge.ts +2 -2
  93. package/packages/core/src/swarm/RoleSwarm.ts +234 -0
  94. package/packages/core/src/swarm/TaskGraph.ts +2 -2
  95. package/packages/core/src/swarm/index.ts +7 -0
  96. package/packages/core/src/swarm/presets/HiveLearnPreset.ts +2 -2
  97. package/packages/core/src/swarm/presets/ResearchPreset.ts +2 -2
  98. package/packages/core/src/swarm/strategies/ParallelStrategy.ts +1 -1
  99. package/packages/core/src/swarm/strategies/PriorityStrategy.ts +3 -3
  100. package/packages/core/src/tools/ToolExecutor.ts +7 -3
  101. package/packages/core/src/tools/core/index.ts +2 -2
  102. package/packages/core/src/tools/cron/index.ts +4 -4
  103. package/packages/core/src/tools/web/artifact-inspect.ts +2 -2
  104. package/packages/core/src/tools/web/artifact-read.ts +162 -0
  105. package/packages/core/src/tools/web/browser-backend.ts +226 -0
  106. package/packages/core/src/tools/web/browser-click.ts +2 -2
  107. package/packages/core/src/tools/web/browser-extract.ts +2 -2
  108. package/packages/core/src/tools/web/browser-navigate.ts +2 -2
  109. package/packages/core/src/tools/web/browser-screenshot.ts +12 -5
  110. package/packages/core/src/tools/web/browser-script.ts +2 -2
  111. package/packages/core/src/tools/web/browser-service.ts +85 -366
  112. package/packages/core/src/tools/web/browser-session.ts +125 -0
  113. package/packages/core/src/tools/web/browser-type.ts +2 -2
  114. package/packages/core/src/tools/web/browser-wait.ts +2 -2
  115. package/packages/core/src/tools/web/computer-use.ts +553 -0
  116. package/packages/core/src/tools/web/index.ts +8 -1
  117. package/packages/core/src/tools/web/webview-backend.ts +851 -0
  118. package/packages/core/src/utils/index.ts +1 -0
  119. package/packages/core/src/utils/logger.ts +12 -4
  120. package/packages/core/src/utils/redact-binary.ts +17 -0
  121. package/packages/core/src/utils/toon.ts +1 -1
  122. package/packages/core/src/voice/index.ts +6 -6
  123. package/bun.lock +0 -833
  124. package/bunfig.toml +0 -9
  125. package/docs/API-AGENTS.md +0 -367
  126. package/docs/API-CONTEXT-COMPILER.md +0 -249
  127. package/docs/API-DAG-SCHEDULER.md +0 -273
  128. package/docs/API-TOOLS-SKILLS-CHANNELS.md +0 -446
  129. package/docs/API-WORKERS-EVENTS.md +0 -299
  130. package/docs/HIVE-HARNESS.md +0 -113
  131. package/docs/INDEX.md +0 -190
  132. package/docs/TEMPLATE-HIVE-APP.md +0 -360
  133. package/packages/cli/package.json +0 -17
  134. package/packages/cli/src/commands/create-app.test.ts +0 -180
  135. package/packages/core/package.json +0 -70
  136. package/packages/core/src/api/createAgent.test.ts +0 -160
  137. package/packages/core/src/canvas/canvas.test.ts +0 -36
  138. package/packages/core/src/channels/channels.test.ts +0 -18
  139. package/packages/core/src/ethics/EthicsGuard.test.ts +0 -108
  140. package/packages/core/src/gateway/gateway.test.ts +0 -38
  141. package/packages/core/src/memory/Scratchpad.test.ts +0 -68
  142. package/packages/core/src/scheduler/scheduler.test.ts +0 -15
  143. package/packages/core/src/skills/skills.test.ts +0 -62
  144. package/packages/core/src/swarm/swarm.test.ts +0 -24
  145. package/packages/core/src/tool-runtime/tool-runtime.test.ts +0 -99
  146. package/packages/core/src/tools/ToolRegistry.test.ts +0 -98
  147. package/packages/core/src/tools/api/api-request.test.ts +0 -164
  148. package/packages/core/src/tools/web/browser-service.test.ts +0 -83
  149. package/packages/core/src/workers/workers.test.ts +0 -41
  150. package/scripts/bump-version.ts +0 -248
  151. package/scripts/generate-skill-bundle.ts +0 -108
  152. package/test/agent-loop-terminal-synthesis.test.ts +0 -32
  153. package/test/catalog-agents-stay-enabled.test.ts +0 -117
  154. package/test/causal-events.test.ts +0 -117
  155. package/test/compaction.test.ts +0 -105
  156. package/test/context-compiler.test.ts +0 -269
  157. package/test/curator.test.ts +0 -130
  158. package/test/durable-queue.test.ts +0 -114
  159. package/test/harness-barrel.test.ts +0 -64
  160. package/test/hive-helpers.test.ts +0 -130
  161. package/test/hivedb-search.test.ts +0 -189
  162. package/test/internal-turns.test.ts +0 -166
  163. package/test/job-idempotency.test.ts +0 -68
  164. package/test/job-retry-backoff.test.ts +0 -184
  165. package/test/job-store.test.ts +0 -381
  166. package/test/llm-retry.test.ts +0 -97
  167. package/test/memory-perf.test.ts +0 -774
  168. package/test/minimal-loadout.test.ts +0 -78
  169. package/test/model-catalog.test.ts +0 -105
  170. package/test/preload.ts +0 -12
  171. package/test/reflector.test.ts +0 -320
  172. package/test/retention-cap.test.ts +0 -91
  173. package/test/retired-capabilities-pruned.test.ts +0 -192
  174. package/test/run-store.test.ts +0 -355
  175. package/test/scratchpad.test.ts +0 -74
  176. package/test/secrets-durability.test.ts +0 -119
  177. package/test/seed-model-reseed.test.ts +0 -155
  178. package/test/setup-agent-seed.test.ts +0 -264
  179. package/test/tool-inventory.test.ts +0 -65
  180. package/test/tool-runtime.test.ts +0 -258
  181. package/test/toon.test.ts +0 -429
  182. package/tsconfig.json +0 -42
@@ -0,0 +1,851 @@
1
+ /**
2
+ * WebViewBackend — `BrowserBackend` sobre `Bun.WebView` (Bun >= 1.3).
3
+ *
4
+ * Corre in-process: no hay subproceso, ni instalación, ni descarga de Chrome.
5
+ * Un `evaluate` cuesta ~0,3 ms. Con motor chrome corre headless —Bun lanza el
6
+ * navegador con `--headless`— así que también sirve en un servidor sin display;
7
+ * lo único que necesita es un Chromium instalado.
8
+ *
9
+ * Tres restricciones del motor mandan sobre el diseño de este archivo:
10
+ *
11
+ * 1. `Bun.WebView` acepta **una sola operación pendiente por vista**; dos
12
+ * llamadas solapadas fallan con `ERR_INVALID_STATE: a simple operation is
13
+ * already pending`. Todo pasa por una cola serializada. (El límite es por
14
+ * vista: varias instancias sí trabajan en paralelo.)
15
+ * 2. El perfil de Chrome es efímero y no configurable —`userDataDir` y `args`
16
+ * se ignoran—, así que las cookies se guardan y restauran a mano por CDP
17
+ * (`browser-session.ts`). Sin eso, cada reinicio perdería los logins.
18
+ * 3. El motor webkit (macOS) no expone CDP. Todo lo que use `cdp()` tiene que
19
+ * tener camino alternativo: por eso `snapshot()` cae al DOM y `screenshot()`
20
+ * ignora las opciones cuando no hay puente CDP.
21
+ */
22
+
23
+ import { logger } from "../../utils/logger.ts";
24
+ import { resolveWebViewEngine, type BrowserBackend, type ScreenshotOptions, type SnapshotOptions, type WebViewEngine } from "./browser-backend.ts";
25
+ import { loadStoredCookies, sessionPersistenceEnabled, storeCookies } from "./browser-session.ts";
26
+
27
+ const log = logger.child("webview-backend");
28
+
29
+ /** Tope del texto del snapshot: un DOM grande no puede comerse el contexto. */
30
+ const SNAPSHOT_CHAR_LIMIT = 20_000;
31
+
32
+ /**
33
+ * Espera antes de volcar las cookies al almacén. Un login son varias
34
+ * navegaciones seguidas (formulario, redirect, destino) y no tiene sentido
35
+ * guardar en cada una; con esta ventana se guarda una vez, al final.
36
+ */
37
+ const SESSION_SAVE_DEBOUNCE_MS = 3_000;
38
+
39
+ /**
40
+ * Tope para las operaciones que el motor puede dejar pendientes para siempre.
41
+ *
42
+ * `WebView.click()` sobre un selector que no existe nunca resuelve, y como la
43
+ * cola es de una sola vía, esa promesa cuelga **todo** el navegador: la
44
+ * siguiente tool espera detrás y el agente se queda mudo. Con el tope, la
45
+ * operación falla, la vista se descarta y la próxima abre una limpia.
46
+ */
47
+ const OPERACION_TIMEOUT_MS = 15_000;
48
+
49
+ /** Nombres de tecla de uso común → los que entiende `Bun.WebView`. */
50
+ const ALIAS_DE_TECLA: Record<string, string> = {
51
+ return: "Enter",
52
+ enter: "Enter",
53
+ esc: "Escape",
54
+ escape: "Escape",
55
+ del: "Delete",
56
+ delete: "Delete",
57
+ back: "Backspace",
58
+ backspace: "Backspace",
59
+ space: " ",
60
+ spacebar: " ",
61
+ tab: "Tab",
62
+ up: "ArrowUp",
63
+ down: "ArrowDown",
64
+ left: "ArrowLeft",
65
+ right: "ArrowRight",
66
+ pageup: "PageUp",
67
+ pagedown: "PageDown",
68
+ home: "Home",
69
+ end: "End",
70
+ };
71
+
72
+ /**
73
+ * Forma real de `Bun.WebView`, verificada contra el prototipo en 1.4.0.
74
+ *
75
+ * No se usan los tipos de `bun-types` a propósito: declaran `back()`/`forward()`
76
+ * y el runtime expone `goBack()`/`goForward()`. Contra los tipos, la navegación
77
+ * hacia atrás compila y explota en ejecución.
78
+ */
79
+ interface BunWebView {
80
+ navigate(url: string): Promise<void>;
81
+ evaluate(script: string): Promise<unknown>;
82
+ screenshot(): Promise<Blob>;
83
+ cdp(method: string, params?: Record<string, unknown>): Promise<unknown>;
84
+ click(selector: string): Promise<void>;
85
+ type(text: string): Promise<void>;
86
+ press(key: string, modifiers?: Record<string, boolean>): Promise<void>;
87
+ scroll(dx: number, dy: number): Promise<void>;
88
+ scrollTo(selector: string): Promise<void>;
89
+ resize(width: number, height: number): Promise<void>;
90
+ goBack(): Promise<void>;
91
+ goForward(): Promise<void>;
92
+ reload(): Promise<void>;
93
+ close(): void;
94
+ readonly url: string;
95
+ readonly title: string;
96
+ readonly loading: boolean;
97
+ }
98
+
99
+ /**
100
+ * El script que sintetiza el árbol de accesibilidad. Se inyecta como texto, así
101
+ * que no puede cerrar sobre nada del scope de TypeScript: los parámetros entran
102
+ * interpolados como literales JSON.
103
+ */
104
+ function buildSnapshotScript(options: Required<SnapshotOptions>): string {
105
+ return `(() => {
106
+ const MAX_DEPTH = ${JSON.stringify(options.depth)};
107
+ const COMPACT = ${JSON.stringify(options.compact)};
108
+ const INTERACTIVE_ONLY = ${JSON.stringify(options.interactiveOnly)};
109
+ const LIMIT = ${SNAPSHOT_CHAR_LIMIT};
110
+
111
+ const ROLE_BY_TAG = {
112
+ A: "link", BUTTON: "button", P: "paragraph", IMG: "img", TEXTAREA: "textbox",
113
+ SELECT: "combobox", OPTION: "option", UL: "list", OL: "list", LI: "listitem",
114
+ TABLE: "table", TR: "row", TD: "cell", TH: "columnheader", FORM: "form",
115
+ NAV: "navigation", MAIN: "main", HEADER: "banner", FOOTER: "contentinfo",
116
+ ASIDE: "complementary", LABEL: "label", ARTICLE: "article", SECTION: "region",
117
+ DIALOG: "dialog", SUMMARY: "button", IFRAME: "iframe", VIDEO: "video", AUDIO: "audio",
118
+ };
119
+ const INTERACTIVE = new Set(["link", "button", "textbox", "checkbox", "radio", "combobox", "option", "searchbox"]);
120
+ const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK", "TITLE", "SVG", "PATH"]);
121
+
122
+ function inputRole(el) {
123
+ const type = (el.getAttribute("type") || "text").toLowerCase();
124
+ if (type === "checkbox") return "checkbox";
125
+ if (type === "radio") return "radio";
126
+ if (type === "search") return "searchbox";
127
+ if (type === "button" || type === "submit" || type === "reset" || type === "image") return "button";
128
+ if (type === "hidden") return null;
129
+ return "textbox";
130
+ }
131
+
132
+ function roleOf(el) {
133
+ const explicit = el.getAttribute("role");
134
+ if (explicit) return explicit.trim().split(/\\s+/)[0];
135
+ if (el.tagName === "INPUT") return inputRole(el);
136
+ if (/^H[1-6]$/.test(el.tagName)) return "heading";
137
+ if (el.tagName === "A") return el.hasAttribute("href") ? "link" : null;
138
+ return ROLE_BY_TAG[el.tagName] || null;
139
+ }
140
+
141
+ function ownText(el) {
142
+ let text = "";
143
+ for (const node of el.childNodes) {
144
+ if (node.nodeType === 3) text += node.nodeValue;
145
+ // Los inline sin rol propio son parte del nombre del padre, no nodos aparte.
146
+ else if (node.nodeType === 1 && !roleOf(node) && node.childElementCount === 0) {
147
+ text += node.textContent || "";
148
+ }
149
+ }
150
+ return text.replace(/\\s+/g, " ").trim();
151
+ }
152
+
153
+ function nameOf(el) {
154
+ const aria = el.getAttribute("aria-label");
155
+ if (aria && aria.trim()) return aria.trim();
156
+
157
+ const labelledBy = el.getAttribute("aria-labelledby");
158
+ if (labelledBy) {
159
+ const parts = labelledBy.split(/\\s+/)
160
+ .map((id) => { const target = document.getElementById(id); return target ? (target.textContent || "").trim() : ""; })
161
+ .filter(Boolean);
162
+ if (parts.length) return parts.join(" ").replace(/\\s+/g, " ");
163
+ }
164
+
165
+ if (el.tagName === "IMG") return (el.getAttribute("alt") || "").trim();
166
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
167
+ const type = (el.getAttribute("type") || "").toLowerCase();
168
+ if (type === "button" || type === "submit" || type === "reset") return (el.value || "").trim();
169
+ const placeholder = (el.getAttribute("placeholder") || "").trim();
170
+ if (placeholder) return placeholder;
171
+ if (el.labels && el.labels.length) return (el.labels[0].textContent || "").replace(/\\s+/g, " ").trim();
172
+ return (el.getAttribute("name") || "").trim();
173
+ }
174
+
175
+ const own = ownText(el);
176
+ if (own) return own;
177
+ return (el.getAttribute("title") || "").trim();
178
+ }
179
+
180
+ function visible(el) {
181
+ if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") return false;
182
+ const style = getComputedStyle(el);
183
+ if (style.display === "none" || style.visibility === "hidden") return false;
184
+ return true;
185
+ }
186
+
187
+ function attrsOf(el, role) {
188
+ const attrs = [];
189
+ if (role === "heading") attrs.push("level=" + el.tagName.slice(1));
190
+ if (el.disabled) attrs.push("disabled");
191
+ if (el.checked) attrs.push("checked");
192
+ if (el.getAttribute("aria-expanded")) attrs.push("expanded=" + el.getAttribute("aria-expanded"));
193
+ return attrs;
194
+ }
195
+
196
+ const lines = [];
197
+ let refSeq = 0;
198
+ let truncated = false;
199
+
200
+ function walk(el, depth) {
201
+ if (truncated) return;
202
+ for (const child of el.children) {
203
+ if (truncated) return;
204
+ if (SKIP_TAGS.has(child.tagName)) continue;
205
+ if (!visible(child)) continue;
206
+
207
+ const role = roleOf(child);
208
+ const name = role ? nameOf(child) : "";
209
+ const interactive = role ? INTERACTIVE.has(role) : false;
210
+ // Un nodo se emite si aporta algo: un rol con nombre, o algo accionable.
211
+ let emit = Boolean(role) && (Boolean(name) || interactive);
212
+ if (INTERACTIVE_ONLY && !interactive) emit = false;
213
+ if (COMPACT && role && !name && !interactive) emit = false;
214
+
215
+ if (emit && depth < MAX_DEPTH) {
216
+ const attrs = attrsOf(child, role);
217
+ if (name || interactive) attrs.push("ref=e" + ++refSeq);
218
+ let label = "- " + role;
219
+ if (name) {
220
+ const shown = COMPACT && name.length > 120 ? name.slice(0, 120) + "…" : name;
221
+ label += ' "' + shown.replace(/"/g, "'") + '"';
222
+ }
223
+ if (attrs.length) label += " [" + attrs.join(", ") + "]";
224
+ const line = " ".repeat(depth) + label;
225
+ if (lines.join("\\n").length + line.length > LIMIT) { truncated = true; return; }
226
+ lines.push(line);
227
+ }
228
+
229
+ // Sin línea propia, los hijos suben de nivel: así el árbol no se llena de
230
+ // sangría por cada <div> de maquetado.
231
+ walk(child, emit && depth < MAX_DEPTH ? depth + 1 : depth);
232
+ }
233
+ }
234
+
235
+ walk(document.body || document.documentElement, 0);
236
+ if (truncated) lines.push("… (snapshot truncado)");
237
+ return lines.join("\\n");
238
+ })()`;
239
+ }
240
+
241
+ // ─── snapshot por árbol de accesibilidad (CDP) ────────────────────────────────
242
+
243
+ /**
244
+ * Un nodo tal como lo devuelve `Accessibility.getFullAXTree`.
245
+ *
246
+ * Se tipa a mano y flojo: es JSON crudo del protocolo, y Chrome agrega campos
247
+ * entre versiones. Sólo se declara lo que este archivo lee.
248
+ */
249
+ interface AxNode {
250
+ nodeId: string;
251
+ ignored?: boolean;
252
+ role?: { value?: string };
253
+ name?: { value?: string };
254
+ properties?: Array<{ name?: string; value?: { value?: unknown } }>;
255
+ childIds?: string[];
256
+ }
257
+
258
+ /** Roles que no aportan una línea: contenedores y nodos de texto interno. */
259
+ const AX_SKIP_ROLES = new Set([
260
+ "generic", "none", "presentation", "GenericContainer", "InlineTextBox",
261
+ "StaticText", "LineBreak", "RootWebArea", "WebArea", "Ignored",
262
+ ]);
263
+
264
+ /** Roles accionables: se emiten aunque no tengan nombre accesible. */
265
+ const AX_INTERACTIVE = new Set([
266
+ "link", "button", "textbox", "checkbox", "radio", "combobox", "option",
267
+ "searchbox", "menuitem", "menuitemcheckbox", "menuitemradio", "tab",
268
+ "switch", "slider", "spinbutton",
269
+ ]);
270
+
271
+ /** Propiedades del nodo que sí vale la pena mostrarle al modelo. */
272
+ const AX_SHOWN_PROPERTIES = ["level", "disabled", "checked", "expanded", "required", "selected"];
273
+
274
+ function axProperty(node: AxNode, name: string): unknown {
275
+ return node.properties?.find((p) => p.name === name)?.value?.value;
276
+ }
277
+
278
+ /**
279
+ * Convierte el árbol de accesibilidad en el mismo texto que produce el
280
+ * recorrido del DOM: `- rol "nombre" [attrs, ref=eN]`, con sangría por cada
281
+ * nivel realmente emitido.
282
+ *
283
+ * Es la fuente preferida porque es la que Chrome le da a un lector de pantalla:
284
+ * resuelve nombres accesibles, roles implícitos y contenido oculto sin que este
285
+ * archivo tenga que reimplementar esas reglas.
286
+ */
287
+ function formatAxTree(nodes: AxNode[], options: Required<SnapshotOptions>): string {
288
+ const byId = new Map<string, AxNode>();
289
+ const hijos = new Set<string>();
290
+ for (const node of nodes) {
291
+ byId.set(node.nodeId, node);
292
+ for (const child of node.childIds ?? []) hijos.add(child);
293
+ }
294
+ const raices = nodes.filter((n) => !hijos.has(n.nodeId));
295
+
296
+ const lines: string[] = [];
297
+ let refSeq = 0;
298
+ let largo = 0;
299
+ let truncado = false;
300
+
301
+ /** El nombre de un nodo de texto sale de sus hijos StaticText, como en el DOM. */
302
+ function nombreDe(node: AxNode): string {
303
+ const propio = (node.name?.value ?? "").toString().replace(/\s+/g, " ").trim();
304
+ if (propio) return propio;
305
+
306
+ let texto = "";
307
+ for (const id of node.childIds ?? []) {
308
+ const child = byId.get(id);
309
+ if (child?.role?.value === "StaticText") texto += " " + (child.name?.value ?? "");
310
+ }
311
+ return texto.replace(/\s+/g, " ").trim();
312
+ }
313
+
314
+ function atributos(node: AxNode, role: string): string[] {
315
+ const attrs: string[] = [];
316
+ for (const name of AX_SHOWN_PROPERTIES) {
317
+ const value = axProperty(node, name);
318
+ if (value === undefined || value === false || value === "false") continue;
319
+ // `heading` ya dice que es un encabezado; lo informativo es el nivel.
320
+ if (name === "level" && role !== "heading") continue;
321
+ attrs.push(value === true || value === "true" ? name : `${name}=${value}`);
322
+ }
323
+ return attrs;
324
+ }
325
+
326
+ function walk(node: AxNode, depth: number): void {
327
+ if (truncado) return;
328
+
329
+ const role = node.role?.value ?? "";
330
+ const saltear = node.ignored === true || AX_SKIP_ROLES.has(role) || !role;
331
+ const interactivo = AX_INTERACTIVE.has(role);
332
+ const name = saltear ? "" : nombreDe(node);
333
+
334
+ let emitir = !saltear && (Boolean(name) || interactivo);
335
+ if (options.interactiveOnly && !interactivo) emitir = false;
336
+ if (options.compact && !name && !interactivo) emitir = false;
337
+
338
+ if (emitir && depth < options.depth) {
339
+ const attrs = atributos(node, role);
340
+ if (name || interactivo) attrs.push("ref=e" + ++refSeq);
341
+ let label = "- " + role;
342
+ if (name) {
343
+ const shown = options.compact && name.length > 120 ? name.slice(0, 120) + "…" : name;
344
+ label += ' "' + shown.replace(/"/g, "'") + '"';
345
+ }
346
+ if (attrs.length) label += " [" + attrs.join(", ") + "]";
347
+
348
+ const line = " ".repeat(depth) + label;
349
+ if (largo + line.length > SNAPSHOT_CHAR_LIMIT) {
350
+ truncado = true;
351
+ return;
352
+ }
353
+ lines.push(line);
354
+ largo += line.length + 1;
355
+ } else {
356
+ emitir = false;
357
+ }
358
+
359
+ // Igual que en el DOM: si el nodo no emitió línea, sus hijos no bajan de
360
+ // nivel, así el árbol no se hunde por cada contenedor de maquetado.
361
+ const siguiente = emitir && depth < options.depth ? depth + 1 : depth;
362
+ for (const id of node.childIds ?? []) {
363
+ const child = byId.get(id);
364
+ if (child) walk(child, siguiente);
365
+ if (truncado) return;
366
+ }
367
+ }
368
+
369
+ for (const raiz of raices) walk(raiz, 0);
370
+ if (truncado) lines.push("… (snapshot truncado)");
371
+ return lines.join("\n");
372
+ }
373
+
374
+ export class WebViewBackend implements BrowserBackend {
375
+ private view: BunWebView | null = null;
376
+ private _url = "";
377
+ /** Cola de una sola vía: WebView rechaza operaciones solapadas. */
378
+ private queue: Promise<unknown> = Promise.resolve();
379
+ /** Motor con el que se abrió la vista; sólo `chrome` tiene puente CDP. */
380
+ private engine: WebViewEngine | null = null;
381
+ /** `Accessibility.enable` se manda una vez por vista, no en cada snapshot. */
382
+ private axEnabled = false;
383
+ /** La sesión guardada se restaura una sola vez, antes de la primera página. */
384
+ private sessionRestored = false;
385
+ private saveTimer: ReturnType<typeof setTimeout> | null = null;
386
+
387
+ constructor(
388
+ private readonly options: {
389
+ width?: number;
390
+ height?: number;
391
+ show?: boolean;
392
+ engine?: WebViewEngine;
393
+ /** Guardar y restaurar cookies entre procesos. Default: sí. */
394
+ persistSession?: boolean;
395
+ } = {},
396
+ ) {}
397
+
398
+ /** ¿Hay puente CDP? El motor webkit de macOS no lo tiene. */
399
+ private get hasCdp(): boolean {
400
+ return (this.engine ?? this.options.engine ?? resolveWebViewEngine()) === "chrome";
401
+ }
402
+
403
+ private ensureView(): BunWebView {
404
+ if (this.view) return this.view;
405
+
406
+ const WebView = (globalThis as { Bun?: { WebView?: unknown } }).Bun?.WebView;
407
+ if (typeof WebView !== "function") {
408
+ throw new Error("Bun.WebView no está disponible en este runtime (requiere Bun >= 1.3)");
409
+ }
410
+
411
+ const engine = this.options.engine ?? resolveWebViewEngine();
412
+ if (!engine) {
413
+ throw new Error(
414
+ "Bun.WebView no tiene motor utilizable: WebKit sólo existe en macOS y no se encontró Chrome. " +
415
+ "Instala Chrome o define BUN_CHROME_PATH.",
416
+ );
417
+ }
418
+
419
+ // `url: false` es obligatorio para automatización desatendida: sin eso el
420
+ // motor chrome intenta CONECTARSE a un Chrome que ya esté corriendo, y esa
421
+ // ruta abre un diálogo "Allow remote debugging?" que cuelga el proceso
422
+ // esperando un click que en un servidor no llega nunca.
423
+ const backend =
424
+ engine === "chrome"
425
+ ? { type: "chrome" as const, url: false as const, stderr: "ignore" as const }
426
+ : ("webkit" as const);
427
+
428
+ // Sin `url` inicial a propósito: construir con uno deja una navegación
429
+ // pendiente y el primer navigate() explota con ERR_INVALID_STATE.
430
+ const Ctor = WebView as unknown as new (opts: unknown) => BunWebView;
431
+ this.view = new Ctor({
432
+ backend,
433
+ show: this.options.show ?? false,
434
+ width: this.options.width ?? 1280,
435
+ height: this.options.height ?? 800,
436
+ });
437
+ this.engine = engine;
438
+ log.info(`✅ WebView abierto (motor: ${engine}, in-process)`);
439
+ return this.view;
440
+ }
441
+
442
+ /** Serializa: cada operación espera a que termine la anterior. */
443
+ private run<T>(operation: (view: BunWebView) => Promise<T>): Promise<T> {
444
+ const next = this.queue.then(
445
+ () => operation(this.ensureView()),
446
+ () => operation(this.ensureView()),
447
+ );
448
+ // La cola no debe romperse porque una operación haya fallado.
449
+ this.queue = next.catch(() => undefined);
450
+ return next;
451
+ }
452
+
453
+ /**
454
+ * Como `run`, pero con tope. Si se cumple, la vista queda descartada: su cola
455
+ * sigue esperando a una operación que no va a volver, así que lo único
456
+ * recuperable es abrir otra.
457
+ */
458
+ private async runVigilado<T>(
459
+ operation: (view: BunWebView) => Promise<T>,
460
+ quehacia: string,
461
+ ms = OPERACION_TIMEOUT_MS,
462
+ ): Promise<T> {
463
+ let timer: ReturnType<typeof setTimeout> | undefined;
464
+ const limite = new Promise<never>((_, reject) => {
465
+ timer = setTimeout(() => reject(new Error(`${quehacia} no respondió en ${ms}ms`)), ms);
466
+ (timer as { unref?: () => void }).unref?.();
467
+ });
468
+
469
+ try {
470
+ return await Promise.race([this.run(operation), limite]);
471
+ } catch (error) {
472
+ if ((error as Error).message.includes("no respondió")) {
473
+ log.warn(`${quehacia} colgó el WebView; se descarta la vista`);
474
+ this.close();
475
+ }
476
+ throw error;
477
+ } finally {
478
+ clearTimeout(timer);
479
+ }
480
+ }
481
+
482
+ get url(): string {
483
+ return this.view?.url || this._url;
484
+ }
485
+
486
+ get title(): string {
487
+ return this.view?.title || "";
488
+ }
489
+
490
+ get loading(): boolean {
491
+ return this.view?.loading ?? false;
492
+ }
493
+
494
+ async navigate(url: string): Promise<void> {
495
+ const target = /^[a-z]+:/i.test(url) ? url : `https://${url}`;
496
+ await this.restoreSession();
497
+ await this.run((view) => view.navigate(target));
498
+ this._url = this.view?.url || target;
499
+ this.scheduleSessionSave();
500
+ }
501
+
502
+ // ─── sesión persistente ─────────────────────────────────────────────────────
503
+
504
+ /**
505
+ * Devuelve las cookies guardadas al navegador, una sola vez y antes de la
506
+ * primera página real.
507
+ *
508
+ * Hace falta un `about:blank` previo porque `cdp()` no tiene sesión hasta que
509
+ * la vista navegó alguna vez, y `Network.setCookies` tiene que correr antes
510
+ * de la página de destino para que el request ya salga autenticado.
511
+ */
512
+ private async restoreSession(): Promise<void> {
513
+ if (this.sessionRestored) return;
514
+ this.sessionRestored = true;
515
+ if (!sessionPersistenceEnabled(this.options.persistSession) || !this.hasCdp) return;
516
+
517
+ try {
518
+ const cookies = await loadStoredCookies();
519
+ if (!cookies.length) return;
520
+
521
+ await this.run(async (view) => {
522
+ await view.navigate("about:blank");
523
+ await view.cdp("Network.setCookies", { cookies });
524
+ });
525
+ log.info(`sesión del navegador restaurada (${cookies.length} cookies)`);
526
+ } catch (err) {
527
+ // Una sesión que no se pudo restaurar es un login perdido, no un fallo de
528
+ // la navegación: el agente sigue, sólo que deslogueado.
529
+ log.warn(`no se pudo restaurar la sesión: ${(err as Error).message}`);
530
+ }
531
+ }
532
+
533
+ /** Agenda un volcado de cookies; las llamadas seguidas se funden en una. */
534
+ private scheduleSessionSave(): void {
535
+ if (!sessionPersistenceEnabled(this.options.persistSession) || !this.hasCdp) return;
536
+ if (this.saveTimer) clearTimeout(this.saveTimer);
537
+
538
+ this.saveTimer = setTimeout(() => {
539
+ this.saveTimer = null;
540
+ void this.saveSession();
541
+ }, SESSION_SAVE_DEBOUNCE_MS);
542
+ // Un guardado pendiente no puede ser motivo para que el proceso no termine.
543
+ (this.saveTimer as { unref?: () => void }).unref?.();
544
+ }
545
+
546
+ /**
547
+ * Vuelca la sesión ya, sin esperar la ventana del debounce. Lo usan el
548
+ * apagado ordenado y los tests; el camino normal es `scheduleSessionSave()`.
549
+ */
550
+ /**
551
+ * Guarda la sesión ahora y falla si no puede.
552
+ *
553
+ * A diferencia del guardado periódico, aquí alguien pidió expresamente que la
554
+ * sesión quede en disco, así que callarse un fallo convierte el problema en
555
+ * "la cookie no aparece" mucho más tarde y en otro sitio — que es justo cómo
556
+ * se vivió desde una integración continua, sin forma de saber la causa.
557
+ */
558
+ async flushSession(): Promise<void> {
559
+ if (this.saveTimer) {
560
+ clearTimeout(this.saveTimer);
561
+ this.saveTimer = null;
562
+ }
563
+ await this.saveSession(true);
564
+ }
565
+
566
+ private async saveSession(estricto = false): Promise<void> {
567
+ const rendirse = (motivo: string) => {
568
+ if (estricto) throw new Error(`no se pudo guardar la sesión: ${motivo}`);
569
+ };
570
+ if (!this.view) return rendirse("no hay ninguna vista abierta");
571
+ // Apagada por configuración: no hay nada que guardar y tampoco un problema
572
+ // que contar. Sólo se protesta cuando debía guardar y no pudo.
573
+ if (!sessionPersistenceEnabled(this.options.persistSession)) return;
574
+ if (!this.hasCdp) {
575
+ return rendirse(`el motor «${this.engine ?? this.options.engine ?? "?"}» no expone CDP`);
576
+ }
577
+ try {
578
+ const res = (await this.run((view) => view.cdp("Network.getAllCookies"))) as {
579
+ cookies?: unknown[];
580
+ };
581
+ const cookies = res?.cookies ?? [];
582
+ const guardadas = await storeCookies(cookies);
583
+ if (guardadas) log.debug(`sesión del navegador guardada (${guardadas} cookies)`);
584
+ else if (estricto && cookies.length === 0) {
585
+ throw new Error("el navegador no reportó ninguna cookie");
586
+ }
587
+ } catch (err) {
588
+ if (estricto) throw err;
589
+ log.warn(`no se pudo guardar la sesión: ${(err as Error).message}`);
590
+ }
591
+ }
592
+
593
+ async evaluate<T = unknown>(script: string): Promise<T> {
594
+ const trimmed = script.trim();
595
+ let wrapped = script;
596
+ if (/\bawait\b/.test(script) && !trimmed.startsWith("(async") && !trimmed.startsWith("async function")) {
597
+ wrapped = trimmed.startsWith("return")
598
+ ? `(async () => { ${script} })()`
599
+ : `(async () => { return ${script}; })()`;
600
+ }
601
+ return (await this.run((view) => view.evaluate(wrapped))) as T;
602
+ }
603
+
604
+ async screenshot(options?: ScreenshotOptions): Promise<string> {
605
+ const format = options?.format ?? "png";
606
+ const clip = options?.clip;
607
+
608
+ // `view.screenshot()` sólo sabe hacer un PNG del viewport entero. Todo lo
609
+ // demás —jpeg, calidad, recorte— sale por CDP, que además evita mandarle al
610
+ // modelo un PNG de 1 MB cuando pidió un jpeg al 70%.
611
+ const necesitaCdp = format !== "png" || Boolean(clip) || options?.quality !== undefined;
612
+ if (necesitaCdp && this.hasCdp) {
613
+ const params: Record<string, unknown> = { format };
614
+ if (format !== "png" && options?.quality !== undefined) params.quality = options.quality;
615
+ if (clip) params.clip = clip;
616
+
617
+ const shot = (await this.run((view) => view.cdp("Page.captureScreenshot", params))) as {
618
+ data?: string;
619
+ };
620
+ if (shot?.data) return shot.data;
621
+ log.warn("CDP no devolvió imagen; se usa la captura nativa del WebView");
622
+ }
623
+
624
+ const blob = await this.run((view) => view.screenshot());
625
+ return Buffer.from(await blob.arrayBuffer()).toString("base64");
626
+ }
627
+
628
+ async screenshotElement(selector: string): Promise<string> {
629
+ const box = await this.evaluate<{ x: number; y: number; width: number; height: number } | null>(
630
+ `(() => {
631
+ const el = document.querySelector(${JSON.stringify(selector)});
632
+ if (!el) return null;
633
+ el.scrollIntoView({ block: "center", inline: "center" });
634
+ const r = el.getBoundingClientRect();
635
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
636
+ })()`,
637
+ );
638
+ if (!box || box.width <= 0 || box.height <= 0) {
639
+ throw new Error(`screenshot failed: elemento no visible o inexistente: ${selector}`);
640
+ }
641
+
642
+ // WebView.screenshot() no recorta, pero el puente CDP sí acepta `clip`.
643
+ const shot = await this.run((view) =>
644
+ view.cdp("Page.captureScreenshot", {
645
+ format: "png",
646
+ clip: { x: box.x, y: box.y, width: box.width, height: box.height, scale: 1 },
647
+ }),
648
+ );
649
+ const data = (shot as { data?: string })?.data;
650
+ if (!data) throw new Error(`screenshot failed: CDP no devolvió imagen para ${selector}`);
651
+ return data;
652
+ }
653
+
654
+ async snapshot(options?: SnapshotOptions): Promise<string> {
655
+ const opciones: Required<SnapshotOptions> = {
656
+ compact: options?.compact !== false,
657
+ depth: options?.depth ?? 12,
658
+ interactiveOnly: options?.interactiveOnly ?? false,
659
+ };
660
+
661
+ // Primero el árbol de accesibilidad de verdad: es el que Chrome le entrega
662
+ // a un lector de pantalla, así que resuelve nombres accesibles, roles
663
+ // implícitos y contenido oculto mejor que cualquier recorrido del DOM.
664
+ if (this.hasCdp) {
665
+ try {
666
+ const tree = (await this.run(async (view) => {
667
+ if (!this.axEnabled) {
668
+ await view.cdp("Accessibility.enable");
669
+ this.axEnabled = true;
670
+ }
671
+ return view.cdp("Accessibility.getFullAXTree");
672
+ })) as { nodes?: AxNode[] };
673
+
674
+ // Un árbol vacío es una página vacía, no un fallo: se devuelve tal cual.
675
+ return formatAxTree(tree?.nodes ?? [], opciones);
676
+ } catch (err) {
677
+ log.warn(`árbol de accesibilidad no disponible, se recorre el DOM: ${(err as Error).message}`);
678
+ }
679
+ }
680
+
681
+ return (await this.evaluate<string>(buildSnapshotScript(opciones))) || "";
682
+ }
683
+
684
+ /**
685
+ * Puente CDP crudo, por la misma cola que el resto. Sólo con motor chrome:
686
+ * el WebKit de macOS no lo tiene, y por eso todo lo que lo use necesita un
687
+ * camino alternativo.
688
+ */
689
+ async cdp<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T> {
690
+ if (!this.hasCdp) {
691
+ throw new Error("Bun.WebView con motor webkit no expone CDP");
692
+ }
693
+ return (await this.run((view) => view.cdp(method, params))) as T;
694
+ }
695
+
696
+ async click(selector: string, options?: Record<string, unknown>): Promise<void> {
697
+ // El motor no falla cuando el selector no existe: se queda esperando a que
698
+ // aparezca, para siempre. Comprobarlo acá convierte ese cuelgue en el error
699
+ // que la tool sabe reportar.
700
+ const existe = await this.evaluate<boolean>(
701
+ `!!document.querySelector(${JSON.stringify(selector)})`,
702
+ );
703
+ if (!existe) throw new Error(`click failed: elemento no encontrado: ${selector}`);
704
+
705
+ // `browser_click` acepta un timeout y hasta ahora nadie lo miraba.
706
+ const tope = typeof options?.timeout === "number" ? options.timeout : undefined;
707
+ await this.runVigilado((view) => view.click(selector), `click(${selector})`, tope);
708
+ // Un submit de login deja la cookie sin que haya un navigate() explícito.
709
+ this.scheduleSessionSave();
710
+ }
711
+
712
+ async type(text: string): Promise<void> {
713
+ await this.run((view) => view.type(text));
714
+ }
715
+
716
+ async typeIn(selector: string, text: string): Promise<void> {
717
+ const focused = await this.evaluate<boolean>(
718
+ `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.focus(); return true; })()`,
719
+ );
720
+ if (!focused) throw new Error(`type failed: elemento no encontrado: ${selector}`);
721
+ await this.run((view) => view.type(text));
722
+ }
723
+
724
+ async fill(selector: string, text: string): Promise<void> {
725
+ // `fill` reemplaza; `type` agrega. Se limpia primero y se disparan los
726
+ // eventos que esperan React y compañía para registrar el cambio.
727
+ const ok = await this.evaluate<boolean>(
728
+ `(() => {
729
+ const el = document.querySelector(${JSON.stringify(selector)});
730
+ if (!el) return false;
731
+ el.focus();
732
+ el.value = "";
733
+ el.dispatchEvent(new Event("input", { bubbles: true }));
734
+ return true;
735
+ })()`,
736
+ );
737
+ if (!ok) throw new Error(`fill failed: elemento no encontrado: ${selector}`);
738
+ await this.run((view) => view.type(text));
739
+ await this.evaluate(
740
+ `(() => {
741
+ const el = document.querySelector(${JSON.stringify(selector)});
742
+ if (el) el.dispatchEvent(new Event("change", { bubbles: true }));
743
+ })()`,
744
+ );
745
+ }
746
+
747
+ async press(key: string, options?: { modifiers?: string[] }): Promise<void> {
748
+ // El motor sólo acepta su propia lista ("Enter", "Escape", "ArrowUp"...) y
749
+ // lanza con cualquier otro nombre. Los modelos y el código viejo escriben
750
+ // "Return", "Esc" o "Del", que son los nombres de toda la vida.
751
+ const tecla = ALIAS_DE_TECLA[key.toLowerCase()] ?? key;
752
+
753
+ const modifiers: Record<string, boolean> = {};
754
+ for (const modifier of options?.modifiers ?? []) {
755
+ const normalized = modifier.toLowerCase();
756
+ if (normalized === "control" || normalized === "ctrl") modifiers.ctrl = true;
757
+ else if (normalized === "shift") modifiers.shift = true;
758
+ else if (normalized === "alt") modifiers.alt = true;
759
+ else if (normalized === "meta" || normalized === "cmd") modifiers.meta = true;
760
+ }
761
+ await this.run((view) => view.press(tecla, modifiers));
762
+ }
763
+
764
+ async scroll(dx: number, dy: number): Promise<void> {
765
+ await this.run((view) => view.scroll(dx, dy));
766
+ }
767
+
768
+ async scrollTo(selector: string, _options?: { behavior?: "smooth" | "instant" }): Promise<void> {
769
+ await this.run((view) => view.scrollTo(selector));
770
+ }
771
+
772
+ /**
773
+ * Ir y volver en el historial.
774
+ *
775
+ * `goBack()`/`goForward()` del motor **no resuelven nunca** con páginas HTTP
776
+ * reales —con `data:` URLs sí, que es por lo que pasaba desapercibido— y como
777
+ * la cola es de una sola vía, dejaban colgado todo el navegador. Con CDP se
778
+ * pide el historial y se salta a la entrada que toca, que además permite
779
+ * decir "no hay página anterior" en vez de esperar a que algo pase.
780
+ */
781
+ private async irEnHistorial(delta: -1 | 1): Promise<void> {
782
+ if (!this.hasCdp) {
783
+ await this.runVigilado(
784
+ (view) => (delta < 0 ? view.goBack() : view.goForward()),
785
+ delta < 0 ? "back()" : "forward()",
786
+ );
787
+ return;
788
+ }
789
+
790
+ const historial = await this.cdp<{
791
+ currentIndex: number;
792
+ entries: Array<{ id: number; url: string }>;
793
+ }>("Page.getNavigationHistory");
794
+
795
+ const destino = historial.entries[historial.currentIndex + delta];
796
+ if (!destino) {
797
+ throw new Error(delta < 0 ? "no hay página anterior" : "no hay página siguiente");
798
+ }
799
+
800
+ await this.cdp("Page.navigateToHistoryEntry", { entryId: destino.id });
801
+ this._url = destino.url;
802
+ await this.esperarCarga();
803
+ }
804
+
805
+ /**
806
+ * Espera a que la página termine de cargar. `navigateToHistoryEntry` vuelve
807
+ * apenas dispara la navegación, así que sin esto el `evaluate` siguiente lee
808
+ * la página vieja.
809
+ */
810
+ private async esperarCarga(ms = 5_000): Promise<void> {
811
+ const limite = Date.now() + ms;
812
+ while (Date.now() < limite) {
813
+ const listo = await this.evaluate<boolean>('document.readyState === "complete"').catch(() => false);
814
+ if (listo) return;
815
+ await Bun.sleep(25);
816
+ }
817
+ }
818
+
819
+ async back(): Promise<void> {
820
+ await this.irEnHistorial(-1);
821
+ }
822
+
823
+ async forward(): Promise<void> {
824
+ await this.irEnHistorial(1);
825
+ }
826
+
827
+ async reload(): Promise<void> {
828
+ await this.run((view) => view.reload());
829
+ }
830
+
831
+ async resize(width: number, height: number): Promise<void> {
832
+ await this.run((view) => view.resize(width, height));
833
+ }
834
+
835
+ close(): void {
836
+ if (this.saveTimer) {
837
+ clearTimeout(this.saveTimer);
838
+ this.saveTimer = null;
839
+ }
840
+ try {
841
+ this.view?.close();
842
+ } catch {
843
+ /* ya cerrado */
844
+ }
845
+ this.view = null;
846
+ this.axEnabled = false;
847
+ // La cola encadenaba sobre la vista que se fue: si quedó una operación
848
+ // pendiente, todo lo que viniera detrás esperaría a un fantasma.
849
+ this.queue = Promise.resolve();
850
+ }
851
+ }