2 Commits

Author SHA1 Message Date
egutierrez 83f1d7c8d3 docs(browser): actualiza .md de cdp_wait_load/type_text/type_ref (evento, insertText, growth log)
Sincroniza la documentación con los cambios de comportamiento:

- cdp_wait_load.md: descripción y notas reflejan el cambio de polling a evento Page.loadEventFired con fast path; bump a v1.1.0; añade tag de grupo 'navegator' y growth log.
- cdp_type_text.md: corrige la nota (envía 2 eventos keyDown+keyUp, no 3; ya no manda el char extra que duplicaba) y la pausa aleatoria; documenta la función hermana rápida CdpInsertText; bump a v1.1.0; tag 'navegator'; growth log.
- cdp_type_ref.md: documenta CdpTypeRefFast (camino rápido insertText) frente a CdpTypeRef (camino human); bump a v1.1.0; growth log.
2026-06-13 14:27:17 +02:00
egutierrez 216cad4c12 perf(browser): acelera CDP — enable cacheado, wait_load por evento, timeout en sendCDP, escritura insertText
Optimiza el dominio browser para que el manejo del navegador via CDP sea mucho más rápido en automatización propia, manteniendo el camino sigiloso disponible.

- CDPConn cachea los enable de Accessibility/Network/Page por conexión (ensureAX/ensureNetwork/ensurePage): elimina un round-trip redundante en cada percepción y espera, que son las operaciones más frecuentes del bucle percibir->actuar del agente.
- sendCDP adquiere timeout (cdpCmdTimeout 30s): antes una respuesta que Chrome nunca enviaba colgaba la goroutine del tool indefinidamente; ahora falla limpio y el retry puede reconectar.
- CdpWaitLoad pasa de polling de document.readyState cada 200ms a esperar el evento Page.loadEventFired, con fast path inicial de readyState y re-chequeo anti-carrera tras suscribir. Si la página ya está cargada retorna en microsegundos.
- cdp_wait_idle usa ensureNetwork y deja de hacer Network.disable al salir (borraba el estado y forzaba el enable de nuevo).
- Nuevas funciones de escritura rápida: CdpInsertText (todo el texto en un solo Input.insertText) y CdpTypeRefFast (focus + insertText). El chequeo de foco se extrajo a assertEditableFocus, compartido con CdpTypeText.
- CdpTypeText pasa su pausa entre caracteres de 10ms fija a aleatoria 15-65ms (ritmo humano irregular).
- El modo 'auto' se añade al perfil de ratón (MouseProfileForMode, mouseHumanDefaults, clickPauseMs) como alias rápido de 'fast'.

No se tocan las firmas públicas existentes; CdpTypeRef y CdpTypeText conservan su comportamiento (camino human).
2026-06-13 14:27:10 +02:00
11 changed files with 207 additions and 60 deletions
+2 -2
View File
@@ -51,12 +51,12 @@ func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
}
// clickPauseMs devuelve la pausa (ms) entre press y release según el modo de
// velocidad: human 30-90, fast 5-15, instant 0.
// velocidad: human 30-90, auto/fast 5-15, instant 0.
func clickPauseMs(mode string) int {
switch mode {
case "instant":
return 0
case "fast":
case "fast", "auto":
return 5 + rand.Intn(11) // 5..15
default: // "human" o ""
return 30 + rand.Intn(61) // 30..90
+70 -5
View File
@@ -14,8 +14,16 @@ import (
"strings"
"sync"
"sync/atomic"
"time"
)
// cdpCmdTimeout es el tope que sendCDP espera por la respuesta a un comando antes
// de rendirse. Sin el, una respuesta que Chrome nunca envia (tab cerrada a media
// peticion, proceso colgado) bloquearia la goroutine del tool para siempre — el
// agente lo percibe como "lentitud infinita". Con el timeout, el tool falla limpio
// y el retry de withConn puede reconectar.
const cdpCmdTimeout = 30 * time.Second
// EventHandler es invocado cuando llega un evento CDP del metodo subscrito.
// El handler corre en la goroutine del readLoop — debe ser rapido o despachar
// a un canal/goroutine propio. params puede ser nil si Chrome no envia.
@@ -36,6 +44,15 @@ type CDPConn struct {
handlers map[string][]EventHandler
hMu sync.Mutex
// axEnabled/netEnabled/pageEnabled cachean si ya enviamos el enable de cada
// dominio CDP en esta conexion. enable/disable es idempotente pero cuesta un
// round-trip; en el hot path del agente (percibir->actuar repetido) re-enviar
// Accessibility.enable / Network.enable en cada llamada duplica los RTT.
// Habilitar una vez y cachear el flag elimina ese coste por percepcion/espera.
axEnabled atomic.Bool
netEnabled atomic.Bool
pageEnabled atomic.Bool
// frameCtx cachea el executionContextId del isolated world por frameID, para
// que CdpEvalInFrame no cree un mundo aislado nuevo en cada llamada.
// frameCtxMu protege solo el lazy-init del puntero (el cache tiene su mutex).
@@ -250,12 +267,60 @@ func (c *CDPConn) sendCDP(method string, params map[string]any) (map[string]any,
return nil, fmt.Errorf("cdp send %s: %w", method, err)
}
// Esperar respuesta
resp := <-ch
if resp.Error != nil {
return nil, fmt.Errorf("cdp %s: error %d: %s", method, resp.Error.Code, resp.Error.Message)
// Esperar respuesta (con timeout para no colgar el tool indefinidamente).
select {
case resp := <-ch:
if resp.Error != nil {
return nil, fmt.Errorf("cdp %s: error %d: %s", method, resp.Error.Code, resp.Error.Message)
}
return resp.Result, nil
case <-time.After(cdpCmdTimeout):
c.pendMu.Lock()
delete(c.pending, id)
c.pendMu.Unlock()
return nil, fmt.Errorf("cdp %s: sin respuesta tras %s (conexion colgada?)", method, cdpCmdTimeout)
}
return resp.Result, nil
}
// ensureAX habilita el dominio Accessibility una sola vez por conexion (necesario
// antes de Accessibility.getFullAXTree). Idempotente y cacheado: la segunda y
// sucesivas llamadas son no-op, evitando un round-trip por percepcion.
func (c *CDPConn) ensureAX() error {
if c.axEnabled.Load() {
return nil
}
if _, err := c.sendCDP("Accessibility.enable", nil); err != nil {
return err
}
c.axEnabled.Store(true)
return nil
}
// ensureNetwork habilita el dominio Network una sola vez por conexion. Cacheado:
// no lo deshabilitamos al terminar una espera (eso borraria el estado y forzaria
// el enable de nuevo); los handlers de eventos se desregistran por su cancel().
func (c *CDPConn) ensureNetwork() error {
if c.netEnabled.Load() {
return nil
}
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return err
}
c.netEnabled.Store(true)
return nil
}
// ensurePage habilita el dominio Page una sola vez por conexion (necesario para
// recibir Page.loadEventFired y demas eventos de ciclo de vida de la pagina).
func (c *CDPConn) ensurePage() error {
if c.pageEnabled.Load() {
return nil
}
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return err
}
c.pageEnabled.Store(true)
return nil
}
// readLoop lee mensajes del WebSocket y los enruta a los canales pendientes
+4 -2
View File
@@ -72,8 +72,10 @@ func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error) {
return "", fmt.Errorf("cdp get ax outline: conexion nula")
}
// Accessibility.enable es idempotente; necesario antes de getFullAXTree.
if _, err := c.sendCDP("Accessibility.enable", nil); err != nil {
// Accessibility.enable (idempotente, cacheado por conexion): necesario antes de
// getFullAXTree. Cachear el flag evita un round-trip extra en cada percepcion,
// que es la operacion mas frecuente del bucle percibir->actuar del agente.
if err := c.ensureAX(); err != nil {
return "", fmt.Errorf("cdp get ax outline: Accessibility.enable: %w", err)
}
+8 -7
View File
@@ -9,11 +9,12 @@ import (
// MouseHumanOpts configura el movimiento humano del ratón.
type MouseHumanOpts struct {
// Mode es la política de velocidad: "human" (default, ""), "fast" o "instant".
// Controla los defaults de Steps/DurationMs/JitterPx y la pausa press/release:
// Mode es la política de velocidad: "auto"/"fast" (rápido), "human" (sigiloso,
// también "") o "instant". Controla los defaults de Steps/DurationMs/JitterPx y
// la pausa press/release:
// - auto/fast: recta ~5 pts, 40-80ms, jitter mínimo (eventos de ratón reales,
// rápido — modo por defecto del MCP para automatización propia).
// - human: Bézier ~25 pts, 350-800ms, jitter 2px (sigilo anti-bot alto).
// - fast: recta ~5 pts, 40-80ms, jitter mínimo (eventos de ratón reales,
// para scraping masivo propio).
// - instant: sin movimiento de ratón (CdpMoveMouseHuman es no-op); el click
// por #ref usa element.click() JS. Para tests y fallback sin bbox.
// Los valores explícitos (Steps/DurationMs/JitterPx != 0) ganan al preset del modo.
@@ -37,7 +38,7 @@ type MouseHumanOpts struct {
// Un modo desconocido se trata como "human" (el más seguro).
func MouseProfileForMode(mode string) MouseHumanOpts {
switch mode {
case "fast", "instant", "human", "":
case "auto", "fast", "instant", "human", "":
return MouseHumanOpts{Mode: mode, FromX: -1, FromY: -1}
default:
return MouseHumanOpts{Mode: "human", FromX: -1, FromY: -1}
@@ -56,14 +57,14 @@ func mouseHumanDefaults(opts MouseHumanOpts) MouseHumanOpts {
opts.DurationMs = 1
}
// JitterPx se queda en 0.
case "fast":
case "fast", "auto":
if opts.Steps <= 0 {
opts.Steps = 5
}
if opts.DurationMs <= 0 {
opts.DurationMs = 40 + rand.Intn(41) // 40..80
}
// JitterPx se queda en lo recibido (0 por defecto, sin jitter en fast).
// JitterPx se queda en lo recibido (0 por defecto, sin jitter en fast/auto).
default: // "human" o ""
if opts.Steps <= 0 {
opts.Steps = 25
+14
View File
@@ -14,3 +14,17 @@ func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error {
}
return CdpTypeText(c, text)
}
// CdpTypeRefFast enfoca el elemento del #ref e inserta el texto en UN solo
// round-trip (Input.insertText), sin teclear caracter por caracter. Es el camino
// rápido del modo automático: equivale a focus(ref) → CdpInsertText. Para sitios
// con detección por pulsación usa CdpTypeRef (modo human, char por char).
func CdpTypeRefFast(c *CDPConn, backendNodeID int, text string) error {
if c == nil {
return fmt.Errorf("cdp type ref fast: conexión nil")
}
if _, err := c.sendCDP("DOM.focus", map[string]any{"backendNodeId": backendNodeID}); err != nil {
return fmt.Errorf("cdp type ref fast: focus ref %d: %w", backendNodeID, err)
}
return CdpInsertText(c, text)
}
+6 -2
View File
@@ -3,10 +3,10 @@ name: cdp_type_ref
kind: function
lang: go
domain: browser
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error"
description: "Enfoca el elemento identificado por su #ref del AX outline vía DOM.focus y escribe el texto dado usando CdpTypeText. El #ref es el backendDOMNodeId estable del nodo DOM. El elemento debe aceptar input de texto (input, textarea, contenteditable)."
description: "Enfoca el elemento identificado por su #ref del AX outline vía DOM.focus y escribe el texto dado usando CdpTypeText (carácter a carácter, camino human). El #ref es el backendDOMNodeId estable del nodo DOM. Para el camino rápido (un solo round-trip Input.insertText) hay CdpTypeRefFast. El elemento debe aceptar input de texto (input, textarea, contenteditable)."
tags: [cdp, browser, action, ref, humanized, navegator]
uses_functions: [cdp_type_text_go_browser]
uses_types: []
@@ -49,3 +49,7 @@ Tras `page_perceive` / `render_ax_outline`, cuando el agente quiere escribir en
- `DOM.focus` falla si el elemento no es focusable (no es `input`, `textarea`, `contenteditable`, o similar). El error indica el ref y la causa.
- Si el elemento necesita un click previo para activarse (algunos inputs con JS custom), combinar con `CdpClickRef` antes de `CdpTypeRef`.
- No hace scroll previo — si el elemento no está visible en el viewport el focus CDP puede fallar en algunos navegadores. Combinar con `CdpClickRef` (que sí hace scroll) si hay dudas.
## Capability growth log
- v1.1.0 (2026-06-13) — Nueva función hermana `CdpTypeRefFast`: enfoca el #ref e inserta el texto en un solo round-trip (`Input.insertText`) en vez de teclear carácter a carácter. Es el camino rápido del modo automático del MCP (`dom_type_ref` con `mode=auto`); `CdpTypeRef` queda como el camino human (carácter a carácter con pausas aleatorias) para sitios con detección por pulsación.
+45 -15
View File
@@ -2,27 +2,38 @@ package browser
import (
"fmt"
"math/rand"
"strings"
"time"
)
// CdpTypeText escribe texto en el elemento activo de la pagina caracter por caracter.
// Usa Input.dispatchKeyEvent para simular pulsaciones de teclado reales.
// Recomienda usar CdpClick primero para enfocar el elemento objetivo.
// assertEditableFocus verifica que el activeElement de la pagina acepta texto
// (input/textarea/select/contentEditable). Sin foco, los caracteres se pierden
// silenciosamente (van a document.body); devolvemos un error claro en vez de
// "escribir a la nada". Compartido por CdpTypeText (camino human) y CdpInsertText
// (camino rapido).
func assertEditableFocus(c *CDPConn) error {
focus, ferr := CdpEvaluate(c, `(function(){var a=document.activeElement;if(!a)return 'none';var t=a.tagName.toLowerCase();return (t==='input'||t==='textarea'||t==='select'||a.isContentEditable)?'ok':t;})()`)
if ferr != nil {
return fmt.Errorf("verificar foco: %w", ferr)
}
if strings.TrimSpace(focus) != "ok" {
return fmt.Errorf("no hay campo de texto enfocado (activeElement: %s); enfoca el input primero", strings.TrimSpace(focus))
}
return nil
}
// CdpTypeText escribe texto en el elemento activo de la pagina caracter por
// caracter, con una pausa ALEATORIA entre teclas. Es el camino "human": emite
// keyDown/keyUp reales por tecla (sitios que validan pulsacion a pulsacion
// reaccionan) y el ritmo irregular reduce la deteccion de automatizacion. Para el
// camino rapido (modo auto) usa CdpInsertText: un solo round-trip, sin teclear.
func CdpTypeText(c *CDPConn, text string) error {
if c == nil {
return fmt.Errorf("cdp type text: conexion nula")
}
// Verificar que hay un campo editable enfocado. Sin foco, los caracteres se
// pierden silenciosamente (van a document.body). Devolvemos error claro en vez
// de "escribir a la nada".
focus, ferr := CdpEvaluate(c, `(function(){var a=document.activeElement;if(!a)return 'none';var t=a.tagName.toLowerCase();return (t==='input'||t==='textarea'||t==='select'||a.isContentEditable)?'ok':t;})()`)
if ferr != nil {
return fmt.Errorf("cdp type text: verificar foco: %w", ferr)
}
if strings.TrimSpace(focus) != "ok" {
return fmt.Errorf("cdp type text: no hay campo de texto enfocado (activeElement: %s); usa CdpClick sobre el input primero", strings.TrimSpace(focus))
if err := assertEditableFocus(c); err != nil {
return fmt.Errorf("cdp type text: %w", err)
}
// keyDown (con `text`) ya inserta el caracter en el elemento focado en
@@ -49,9 +60,28 @@ func CdpTypeText(c *CDPConn, text string) error {
return fmt.Errorf("cdp type text: keyUp %q: %w", charStr, err)
}
// Pequena pausa entre caracteres para simular escritura humana.
time.Sleep(10 * time.Millisecond)
// Pausa ALEATORIA entre caracteres (15-65 ms) para imitar el ritmo
// irregular de un humano escribiendo, en vez de un intervalo de maquina fijo.
time.Sleep(time.Duration(15+rand.Intn(51)) * time.Millisecond)
}
return nil
}
// CdpInsertText inserta todo el texto en el elemento enfocado en UN solo
// round-trip via Input.insertText. Es el camino rapido del modo automatico: no
// emite keyDown/keyUp por tecla, por lo que sitios que validan pulsacion a
// pulsacion (autocompletes muy estrictos) pueden no reaccionar — para esos casos
// usa CdpTypeText (modo human). Requiere un campo editable enfocado.
func CdpInsertText(c *CDPConn, text string) error {
if c == nil {
return fmt.Errorf("cdp insert text: conexion nula")
}
if err := assertEditableFocus(c); err != nil {
return fmt.Errorf("cdp insert text: %w", err)
}
if _, err := c.sendCDP("Input.insertText", map[string]any{"text": text}); err != nil {
return fmt.Errorf("cdp insert text: %w", err)
}
return nil
}
+10 -4
View File
@@ -3,11 +3,11 @@ name: cdp_type_text
kind: function
lang: go
domain: browser
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "func CdpTypeText(c *CDPConn, text string) error"
description: "Escribe texto en el elemento activo de la pagina caracter por caracter via Input.dispatchKeyEvent. Envia eventos keyDown, char y keyUp por cada caracter con 10ms de pausa entre ellos. Usar CdpClick primero para enfocar el elemento."
tags: [chrome, cdp, browser, automation, keyboard, input, devtools]
description: "Escribe texto en el elemento activo de la pagina caracter por caracter via Input.dispatchKeyEvent (camino human). Envia keyDown+keyUp por cada caracter con una pausa ALEATORIA (15-65ms) que imita el ritmo irregular humano. Para el camino rapido (un solo round-trip, sin teclear) usa CdpInsertText. Usar CdpClick primero para enfocar el elemento."
tags: [chrome, cdp, browser, automation, keyboard, input, devtools, navegator]
uses_functions: [cdp_connect_go_browser]
uses_types: []
returns: []
@@ -39,4 +39,10 @@ CdpTypeText(conn, "golang websocket")
## Notas
Envia tres eventos por caracter: `keyDown`, `char` (dispara el evento `input` del DOM) y `keyUp`. La pausa de 10ms entre caracteres simula escritura humana y ayuda con inputs que tienen debounce. Para texto largo, considerar inyectar directamente via `CdpEvaluate` con `element.value = "..."` + evento `input`.
Envia dos eventos por caracter: `keyDown` (con `text`, que ya inserta el caracter en Chrome) y `keyUp`. No envia un evento `char` extra: lo duplicaba en sitios que reaccionan a eventos `input` (DuckDuckGo, Google). La pausa ALEATORIA de 15-65ms entre caracteres imita el ritmo irregular humano (reduce deteccion) y ayuda con inputs que tienen debounce.
Para el camino rapido del modo automatico hay `CdpInsertText` (todo el texto en un solo `Input.insertText`, sin keyDown/keyUp por tecla) — mucho mas rapido, pero sitios que validan pulsacion a pulsacion pueden no reaccionar. Para texto largo donde no importa el sigilo, `CdpInsertText` es preferible.
## Capability growth log
- v1.1.0 (2026-06-13) — La pausa entre caracteres pasa de 10ms fija a aleatoria 15-65ms (ritmo no-máquina). Nueva función hermana `CdpInsertText`: inserta todo el texto en un solo round-trip (`Input.insertText`) para el modo automático rápido. Se extrajo el chequeo de foco a `assertEditableFocus` (compartido).
+6 -3
View File
@@ -136,11 +136,14 @@ func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
})
defer cancel3()
// Habilitar dominio Network (igual que cdp_har_record).
if _, err := c.sendCDP("Network.enable", nil); err != nil {
// Habilitar dominio Network (idempotente, cacheado por conexion). NO lo
// deshabilitamos al salir: Network.disable borraria el estado y el siguiente
// wait_idle pagaria el enable de nuevo (round-trip extra). Los handlers de
// eventos se desregistran por sus cancel() de defer, que es lo unico necesario
// para dejar de contar.
if err := c.ensureNetwork(); err != nil {
return fmt.Errorf("cdp wait idle: Network.enable: %w", err)
}
defer c.sendCDP("Network.disable", nil) //nolint:errcheck
deadline := time.Now().Add(opts.Timeout)
pollInterval := time.Duration(opts.PollMs) * time.Millisecond
+34 -16
View File
@@ -6,9 +6,11 @@ import (
)
// CdpWaitLoad espera a que la página actual termine de cargar completamente.
// Hace polling de document.readyState via Runtime.evaluate cada 200ms hasta
// que sea "complete", o hasta que se agote el timeout.
// Retorna error si el timeout se agota o si CdpEvaluate falla (conexion rota).
// Bloquea hasta recibir el evento CDP Page.loadEventFired (sin polling): suscribe
// el evento via OnEvent y espera en un canal con timeout. Antes de esperar hace un
// fast path comprobando document.readyState — si la página ya está "complete",
// retorna de inmediato sin armar el handler.
// Retorna error si el timeout se agota o si no logra habilitar el dominio Page.
func CdpWaitLoad(c *CDPConn, timeout time.Duration) error {
if c == nil {
return fmt.Errorf("cdp wait load: conexion nula")
@@ -17,19 +19,35 @@ func CdpWaitLoad(c *CDPConn, timeout time.Duration) error {
timeout = 30 * time.Second
}
deadline := time.Now().Add(timeout)
interval := 200 * time.Millisecond
for time.Now().Before(deadline) {
result, err := CdpEvaluate(c, "document.readyState")
if err != nil {
return fmt.Errorf("cdp wait load: error evaluando readyState: %w", err)
}
if result == "complete" {
return nil
}
time.Sleep(interval)
// Fast path: si el documento ya terminó de cargar, no esperamos eventos.
if rs, err := CdpEvaluate(c, "document.readyState"); err == nil && rs == "complete" {
return nil
}
return fmt.Errorf("cdp wait load: pagina no cargo despues de %s", timeout)
// Habilitar Page (idempotente, cacheado) y suscribir el evento de carga.
if err := c.ensurePage(); err != nil {
return fmt.Errorf("cdp wait load: Page.enable: %w", err)
}
loaded := make(chan struct{}, 1)
cancel := c.OnEvent("Page.loadEventFired", func(_ string, _ map[string]any) {
select {
case loaded <- struct{}{}:
default:
}
})
defer cancel()
// Re-chequear readyState tras suscribir: si la carga terminó entre el fast
// path y el registro del handler, ya no llegaría el evento (carrera) — lo
// captamos aquí en vez de colgarnos hasta el timeout.
if rs, err := CdpEvaluate(c, "document.readyState"); err == nil && rs == "complete" {
return nil
}
select {
case <-loaded:
return nil
case <-time.After(timeout):
return fmt.Errorf("cdp wait load: pagina no cargo despues de %s", timeout)
}
}
+8 -4
View File
@@ -3,11 +3,11 @@ name: cdp_wait_load
kind: function
lang: go
domain: browser
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "func CdpWaitLoad(c *CDPConn, timeout time.Duration) error"
description: "Espera a que la pagina actual termine de cargar completamente. Hace polling de document.readyState via Runtime.evaluate cada 200ms hasta que sea \"complete\", o hasta que se agote el timeout. Retorna error inmediato si CdpEvaluate falla (la conexion puede estar rota)."
tags: [chrome, cdp, browser, automation, wait, polling, devtools, readystate, load]
description: "Espera a que la pagina actual termine de cargar completamente. Bloquea hasta recibir el evento CDP Page.loadEventFired (sin polling), con un fast path inicial de document.readyState: si ya esta complete, retorna de inmediato. Retorna error si se agota el timeout o si no logra habilitar el dominio Page."
tags: [chrome, cdp, browser, automation, wait, event, devtools, readystate, load, loadeventfired, navegator]
uses_functions: [cdp_evaluate_go_browser]
uses_types: []
returns: []
@@ -42,6 +42,10 @@ html, _ := CdpGetHTML(conn)
## Notas
A diferencia de `CdpWaitElement`, que ignora errores de `CdpEvaluate` durante el polling (la pagina puede aun no estar lista), `CdpWaitLoad` retorna el error inmediatamente porque un fallo en `document.readyState` indica una conexion rota, no una condicion transitoria.
Bloquea esperando el evento CDP `Page.loadEventFired` (sin polling). Antes de esperar hace un fast path con `document.readyState`: si la página ya está `complete`, retorna de inmediato sin armar el handler. Tras suscribir el evento re-chequea `readyState` una vez más para no perder la carga por una carrera entre el fast path y el registro del handler. Habilita el dominio `Page` vía `ensurePage` (cacheado por conexión, idempotente).
Si `timeout <= 0` usa 30s por defecto (mas largo que `CdpWaitElement` porque la carga completa de red puede tardar mas que la aparicion de un elemento DOM).
## Capability growth log
- v1.1.0 (2026-06-13) — De polling de `document.readyState` cada 200ms a esperar el evento `Page.loadEventFired` (vía `OnEvent` + canal con timeout), con fast path inicial de `readyState`. Elimina los round-trips de polling y la cuantización de ±200ms: si la página ya está cargada retorna en microsegundos.