feat(browser): auto-commit con 60 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,18 +23,49 @@ func refBoxCenter(c *CDPConn, backendNodeID int) (float64, float64, error) {
|
||||
return cx, cy, nil
|
||||
}
|
||||
|
||||
// CdpClickRef hace click humanizado (Bézier + jitter) sobre el elemento del #ref.
|
||||
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
|
||||
// CdpClickRef hace click sobre el elemento del #ref (un backendDOMNodeId extraído
|
||||
// del AX outline por page_perceive). Por defecto usa click humanizado (Bézier +
|
||||
// jitter) sobre el centro del bbox. Dos casos caen al click via element.click() JS:
|
||||
// - opts.Mode == "instant": sin eventos de ratón reales (rápido, tests).
|
||||
// - el nodo no tiene box model (display:contents, área 0): degradado natural en
|
||||
// vez de fallar con error duro — un elemento clicable sin geometría sí se clica.
|
||||
// Hace scroll al elemento si es necesario antes de calcular las coordenadas.
|
||||
func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp click ref: conexión nil")
|
||||
}
|
||||
if opts.Mode == "instant" {
|
||||
return clickRefViaJS(c, backendNodeID)
|
||||
}
|
||||
// scroll al elemento si no está visible; ignorar error (no fatal)
|
||||
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
|
||||
cx, cy, err := refBoxCenter(c, backendNodeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp click ref: %w", err)
|
||||
// Sin geometría: fallback a element.click() JS en vez de error duro.
|
||||
return clickRefViaJS(c, backendNodeID)
|
||||
}
|
||||
return CdpClickXYHuman(c, cx, cy, opts)
|
||||
}
|
||||
|
||||
// clickRefViaJS resuelve el nodo por backendDOMNodeId y llama element.click() en
|
||||
// el contexto JS de la página. No dispara eventos de ratón reales (mousemove/
|
||||
// mousedown), por lo que algunos listeners de hover no se activan; a cambio
|
||||
// funciona sin geometría y al instante.
|
||||
func clickRefViaJS(c *CDPConn, backendNodeID int) error {
|
||||
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp click ref (js): resolveNode ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
obj, _ := res["object"].(map[string]any)
|
||||
objID, _ := obj["objectId"].(string)
|
||||
if objID == "" {
|
||||
return fmt.Errorf("cdp click ref (js): sin objectId para ref %d", backendNodeID)
|
||||
}
|
||||
if _, err := c.sendCDP("Runtime.callFunctionOn", map[string]any{
|
||||
"objectId": objID,
|
||||
"functionDeclaration": "function(){ this.click(); }",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("cdp click ref (js): click ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,8 +37,10 @@ func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
|
||||
return fmt.Errorf("cdp click xy human: mousePressed: %w", err)
|
||||
}
|
||||
|
||||
// Micro-pausa humana entre press y release (30-90 ms).
|
||||
time.Sleep(time.Duration(30+rand.Intn(61)) * time.Millisecond)
|
||||
// Pausa entre press y release según el modo de velocidad.
|
||||
if pms := clickPauseMs(opts.Mode); pms > 0 {
|
||||
time.Sleep(time.Duration(pms) * time.Millisecond)
|
||||
}
|
||||
|
||||
clickParams["type"] = "mouseReleased"
|
||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||
@@ -47,3 +49,16 @@ func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// clickPauseMs devuelve la pausa (ms) entre press y release según el modo de
|
||||
// velocidad: human 30-90, fast 5-15, instant 0.
|
||||
func clickPauseMs(mode string) int {
|
||||
switch mode {
|
||||
case "instant":
|
||||
return 0
|
||||
case "fast":
|
||||
return 5 + rand.Intn(11) // 5..15
|
||||
default: // "human" o ""
|
||||
return 30 + rand.Intn(61) // 30..90
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,21 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// CdpDisconnect cierra SOLO la conexion WebSocket CDP, sin tocar el proceso
|
||||
// Chrome. Es un alias legible de CdpClose(c, 0): usalo cuando quieras soltar la
|
||||
// sesion pero dejar el navegador vivo (p.ej. el navegador diario en 9222 al que
|
||||
// te adjuntaste, no quieres matarlo).
|
||||
func CdpDisconnect(c *CDPConn) error {
|
||||
return CdpClose(c, 0)
|
||||
}
|
||||
|
||||
// CdpQuit cierra la conexion WebSocket Y mata el proceso Chrome (y su grupo de
|
||||
// proceso completo en Linux nativo). Es un alias legible de CdpClose(c, pid) con
|
||||
// pid > 0: usalo para apagar un Chrome que TU lanzaste con ChromeLaunch.
|
||||
func CdpQuit(c *CDPConn, pid int) error {
|
||||
return CdpClose(c, pid)
|
||||
}
|
||||
|
||||
// CdpClose cierra la conexion WebSocket CDP y, si pid > 0, mata el proceso Chrome.
|
||||
// En Linux nativo mata el grupo de proceso completo (chromium lanza zygote, gpu,
|
||||
// renderers como hijos del mismo grupo cuando ChromeLaunch seteo Setpgid: true).
|
||||
|
||||
@@ -3,11 +3,11 @@ name: cdp_close
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
version: "1.2.0"
|
||||
purity: impure
|
||||
signature: "func CdpClose(c *CDPConn, pid int) error"
|
||||
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle."
|
||||
tags: [chrome, cdp, browser, automation, cleanup, devtools, linux]
|
||||
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle. Wrappers nombrados: CdpDisconnect(c) solo cierra el WebSocket; CdpQuit(c, pid) cierra y mata Chrome."
|
||||
tags: [chrome, cdp, browser, automation, cleanup, devtools, linux, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -20,9 +20,9 @@ params:
|
||||
- name: pid
|
||||
desc: "PID del proceso Chrome (0 para no matar; en Linux nativo este PID es tambien el PGID cuando ChromeLaunch uso Setpgid)"
|
||||
output: "error si falla la desconexion o el cierre del proceso; nil si todo OK"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
tests: ["TestCdpCloseWrappers"]
|
||||
test_file_path: "functions/browser/cdp_close_test.go"
|
||||
file_path: "functions/browser/cdp_close.go"
|
||||
---
|
||||
|
||||
@@ -43,6 +43,14 @@ defer CdpClose(nil, pid) // solo mata Chrome (y su grupo en Linux)
|
||||
|
||||
Usar siempre en `defer` después de `ChromeLaunch` para garantizar cleanup del proceso Chrome y del WebSocket CDP. En Linux nativo mata el árbol completo de procesos (zygote, gpu, renderers) evitando procesos zombie.
|
||||
|
||||
**Elige el wrapper según la intención** (más legible que el `pid` mágico):
|
||||
|
||||
| Quiero... | Usa | Equivale a |
|
||||
|---|---|---|
|
||||
| Soltar la sesión, dejar Chrome vivo (navegador diario en 9222) | `CdpDisconnect(c)` | `CdpClose(c, 0)` |
|
||||
| Apagar el Chrome que yo lancé | `CdpQuit(c, pid)` | `CdpClose(c, pid)` |
|
||||
| Control fino (decidir pid en runtime) | `CdpClose(c, pid)` | — |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Kill por grupo (Linux nativo)**: usa `syscall.Kill(-pid, SIGKILL)` que envía la señal a todos los procesos del grupo. Funciona porque `ChromeLaunch` setea `Setpgid: true` en Linux, haciendo que `pid == pgid`. En WSL+chrome.exe el Setpgid no se aplica, por lo que el fallback a `os.FindProcess(pid).Kill()` maneja ese caso.
|
||||
@@ -56,4 +64,5 @@ Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso so
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.2.0 (2026-06-06) — añade wrappers nombrados CdpDisconnect(c) (solo WebSocket) y CdpQuit(c, pid) (WebSocket + mata Chrome) para desambiguar el `pid` mágico; CdpClose sin cambios de comportamiento.
|
||||
- v1.1.0 (2026-06-05) — Linux-native kill: usa syscall.Kill(-pid, SIGKILL) para matar grupo completo (zygote, gpu, renderers), con fallback a os.FindProcess para WSL+exe o proceso ya terminado
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package browser
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestCdpCloseWrappers es un smoke nil-safe de los wrappers nombrados. Sin Chrome:
|
||||
// con conexión nil y pid 0 no hay nada que cerrar ni matar, así que no debe error.
|
||||
func TestCdpCloseWrappers(t *testing.T) {
|
||||
t.Run("CdpDisconnect(nil) no error (nada que cerrar)", func(t *testing.T) {
|
||||
if err := CdpDisconnect(nil); err != nil {
|
||||
t.Errorf("CdpDisconnect(nil) = %v, esperaba nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CdpQuit(nil, 0) no error (sin conexion ni pid)", func(t *testing.T) {
|
||||
if err := CdpQuit(nil, 0); err != nil {
|
||||
t.Errorf("CdpQuit(nil, 0) = %v, esperaba nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CdpClose(nil, 0) sigue siendo no-op", func(t *testing.T) {
|
||||
if err := CdpClose(nil, 0); err != nil {
|
||||
t.Errorf("CdpClose(nil, 0) = %v, esperaba nil", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -35,6 +35,12 @@ type CDPConn struct {
|
||||
closed bool
|
||||
handlers map[string][]EventHandler
|
||||
hMu sync.Mutex
|
||||
|
||||
// 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).
|
||||
frameCtx *frameCtxCache
|
||||
frameCtxMu sync.Mutex
|
||||
}
|
||||
|
||||
type cdpRequest struct {
|
||||
|
||||
@@ -3,75 +3,119 @@ package browser
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe
|
||||
// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame.
|
||||
// Retorna el resultado serializado como string.
|
||||
func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: conexion nula")
|
||||
}
|
||||
if frameID == "" {
|
||||
return "", fmt.Errorf("cdp eval in frame: frameID vacio")
|
||||
}
|
||||
// frameCtxCache mapea frameID -> executionContextId del isolated world creado
|
||||
// para ese frame. Evita pagar Page.createIsolatedWorld en cada CdpEvalInFrame.
|
||||
// Es puro y testeable de forma aislada (su propio mutex, sin tocar CDP).
|
||||
type frameCtxCache struct {
|
||||
mu sync.Mutex
|
||||
m map[string]int
|
||||
}
|
||||
|
||||
// Page.enable es idempotente; necesario antes de crear mundos aislados
|
||||
if _, err := c.sendCDP("Page.enable", nil); err != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err)
|
||||
}
|
||||
func newFrameCtxCache() *frameCtxCache {
|
||||
return &frameCtxCache{m: map[string]int{}}
|
||||
}
|
||||
|
||||
// Crear un mundo aislado en el frame indicado para no contaminar su contexto JS
|
||||
func (f *frameCtxCache) get(frameID string) (int, bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
id, ok := f.m[frameID]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
func (f *frameCtxCache) set(frameID string, ctxID int) {
|
||||
f.mu.Lock()
|
||||
f.m[frameID] = ctxID
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
func (f *frameCtxCache) invalidate(frameID string) {
|
||||
f.mu.Lock()
|
||||
delete(f.m, frameID)
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// isStaleContextError reconoce el error de CDP cuando un executionContextId
|
||||
// cacheado ya no existe (el frame recargó/navegó y su isolated world murió). Es
|
||||
// puro: decide a partir del texto del error. Permite reintentar recreando el
|
||||
// mundo en vez de fallar.
|
||||
func isStaleContextError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "Cannot find context") ||
|
||||
strings.Contains(s, "context with specified id") ||
|
||||
strings.Contains(s, "Execution context was destroyed") ||
|
||||
strings.Contains(s, "uniqueContextId")
|
||||
}
|
||||
|
||||
// frameCtxCacheLazy devuelve el cache de contextos del frame de esta conexion,
|
||||
// inicializandolo en el primer uso. El mutex de CDPConn solo protege este
|
||||
// lazy-init del puntero.
|
||||
func (c *CDPConn) frameCtxCacheLazy() *frameCtxCache {
|
||||
c.frameCtxMu.Lock()
|
||||
defer c.frameCtxMu.Unlock()
|
||||
if c.frameCtx == nil {
|
||||
c.frameCtx = newFrameCtxCache()
|
||||
}
|
||||
return c.frameCtx
|
||||
}
|
||||
|
||||
// createIsolatedWorld crea un mundo aislado en el frame y devuelve su
|
||||
// executionContextId.
|
||||
func createIsolatedWorld(c *CDPConn, frameID string) (int, error) {
|
||||
ctxRes, err := c.sendCDP("Page.createIsolatedWorld", map[string]any{
|
||||
"frameId": frameID,
|
||||
"worldName": "fn_registry_isolated",
|
||||
"grantUniveralAccess": false,
|
||||
"frameId": frameID,
|
||||
"worldName": "fn_registry_isolated",
|
||||
"grantUniversalAccess": false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: createIsolatedWorld: %w", err)
|
||||
return 0, fmt.Errorf("createIsolatedWorld: %w", err)
|
||||
}
|
||||
|
||||
ctxIDRaw, ok := ctxRes["executionContextId"]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("cdp eval in frame: executionContextId no encontrado en respuesta")
|
||||
return 0, fmt.Errorf("createIsolatedWorld: executionContextId no encontrado en respuesta")
|
||||
}
|
||||
ctxID, ok := ctxIDRaw.(float64)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("cdp eval in frame: executionContextId tipo inesperado: %T", ctxIDRaw)
|
||||
return 0, fmt.Errorf("createIsolatedWorld: executionContextId tipo inesperado: %T", ctxIDRaw)
|
||||
}
|
||||
return int(ctxID), nil
|
||||
}
|
||||
|
||||
// Evaluar la expresion en el contexto aislado del frame
|
||||
// evalInFrameContext ejecuta la expresion en el executionContextId dado y
|
||||
// serializa el resultado como string (mismo patron que CdpEvaluate).
|
||||
func evalInFrameContext(c *CDPConn, ctxID int, frameID, expression string) (string, error) {
|
||||
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
|
||||
"expression": expression,
|
||||
"contextId": int(ctxID),
|
||||
"contextId": ctxID,
|
||||
"returnByValue": true,
|
||||
"awaitPromise": true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: Runtime.evaluate: %w", err)
|
||||
return "", fmt.Errorf("Runtime.evaluate: %w", err)
|
||||
}
|
||||
|
||||
// Verificar excepcion JS
|
||||
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
|
||||
excMap, _ := exc.(map[string]any)
|
||||
text, _ := excMap["text"].(string)
|
||||
return "", fmt.Errorf("cdp eval in frame: excepcion JS en frame %q: %s", frameID, text)
|
||||
return "", fmt.Errorf("excepcion JS en frame %q: %s", frameID, text)
|
||||
}
|
||||
|
||||
// Extraer valor del resultado (mismo patron que CdpEvaluate)
|
||||
resVal, ok := evRes["result"].(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("cdp eval in frame: resultado inesperado: %v", evRes)
|
||||
return "", fmt.Errorf("resultado inesperado: %v", evRes)
|
||||
}
|
||||
|
||||
value, ok := resVal["value"]
|
||||
if !ok {
|
||||
// undefined u otro tipo no serializable
|
||||
typ, _ := resVal["type"].(string)
|
||||
return typ, nil
|
||||
}
|
||||
|
||||
// Strings tal cual; objetos/arrays JS a JSON real (no la repr de Go de "%v").
|
||||
if s, ok := value.(string); ok {
|
||||
return s, nil
|
||||
}
|
||||
@@ -81,3 +125,52 @@ func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe
|
||||
// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame.
|
||||
// Retorna el resultado serializado como string.
|
||||
//
|
||||
// Cachea el executionContextId por frameID en la conexion: la primera llamada
|
||||
// crea el mundo aislado, las siguientes lo reutilizan. Si el contexto cacheado
|
||||
// caducó (el frame navegó/recargó), recrea el mundo una vez y reintenta.
|
||||
func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: conexion nula")
|
||||
}
|
||||
if frameID == "" {
|
||||
return "", fmt.Errorf("cdp eval in frame: frameID vacio")
|
||||
}
|
||||
|
||||
// Page.enable es idempotente; necesario antes de crear mundos aislados.
|
||||
if _, err := c.sendCDP("Page.enable", nil); err != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err)
|
||||
}
|
||||
|
||||
cache := c.frameCtxCacheLazy()
|
||||
|
||||
ctxID, cached := cache.get(frameID)
|
||||
if !cached {
|
||||
newID, err := createIsolatedWorld(c, frameID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: %w", err)
|
||||
}
|
||||
ctxID = newID
|
||||
cache.set(frameID, ctxID)
|
||||
}
|
||||
|
||||
out, evErr := evalInFrameContext(c, ctxID, frameID, expression)
|
||||
if evErr != nil && cached && isStaleContextError(evErr) {
|
||||
// El contexto cacheado murió (frame recargó). Recrear una vez.
|
||||
cache.invalidate(frameID)
|
||||
newID, err := createIsolatedWorld(c, frameID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: %w", err)
|
||||
}
|
||||
cache.set(frameID, newID)
|
||||
out, evErr = evalInFrameContext(c, newID, frameID, expression)
|
||||
}
|
||||
if evErr != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: %w", evErr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
purity: impure
|
||||
version: 1.0.0
|
||||
tested: false
|
||||
description: "Ejecuta una expresión JavaScript en el contexto aislado de un iframe concreto usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame."
|
||||
version: 1.1.0
|
||||
tested: true
|
||||
tests: ["TestCdpEvalInFrame_guards", "TestFrameCtxCache", "TestIsStaleContextError"]
|
||||
test_file_path: "functions/browser/cdp_eval_in_frame_test.go"
|
||||
description: "Ejecuta una expresión JavaScript en el contexto aislado de un iframe concreto usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame. Cachea el executionContextId por frameID en la conexión para no recrear el isolated world en cada llamada; si el contexto caduca (frame recargó) lo recrea una vez y reintenta."
|
||||
tags: [cdp, browser, iframe, javascript, eval, navegator]
|
||||
signature: "func CdpEvalInFrame(c *CDPConn, frameID string, expression string) (string, error)"
|
||||
uses_functions: []
|
||||
@@ -71,3 +73,8 @@ Cuando necesites leer o manipular el DOM de un iframe específico sin afectar el
|
||||
- Si el iframe tiene `sandbox` attribute sin `allow-scripts`, el CDP puede crear el mundo aislado pero las evaluaciones fallarán con excepción de seguridad.
|
||||
- Cross-origin iframes en Chrome permiten evaluación CDP siempre que la conexión tenga acceso al target; no aplican las restricciones CORS de JS normal.
|
||||
- El `frameID` debe obtenerse con `CdpListFrames`; si se pasa un ID obsoleto (frame recargado o destruido), `createIsolatedWorld` retorna error.
|
||||
- **Cache de contexto por frameID**: la primera llamada crea el isolated world; las siguientes reutilizan su `executionContextId` (más rápido). Si el frame navega/recarga, el contexto cacheado caduca; la función detecta el error ("Cannot find context", "Execution context was destroyed") y recrea el mundo una vez automáticamente. El cache vive en la conexión: persiste entre llamadas mientras la conexión esté viva.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-06) — corrige typo `grantUniveralAccess` → `grantUniversalAccess` (la opción nunca se aplicaba); cachea executionContextId por frameID en CDPConn (vía `frameCtxCache`) para no crear un isolated world por llamada; recrea+reintenta una vez si el contexto cacheado caducó.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCdpEvalInFrame_guards cubre precondiciones sin Chrome.
|
||||
func TestCdpEvalInFrame_guards(t *testing.T) {
|
||||
t.Run("conexion nula", func(t *testing.T) {
|
||||
if _, err := CdpEvalInFrame(nil, "f1", "1"); err == nil {
|
||||
t.Fatal("esperaba error con conexion nula")
|
||||
}
|
||||
})
|
||||
t.Run("frameID vacio", func(t *testing.T) {
|
||||
if _, err := CdpEvalInFrame(&CDPConn{}, "", "1"); err == nil {
|
||||
t.Fatal("esperaba error con frameID vacio")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFrameCtxCache cubre el núcleo puro del cache de contextos por frame.
|
||||
func TestFrameCtxCache(t *testing.T) {
|
||||
t.Run("golden: set/get devuelve el ctxId cacheado", func(t *testing.T) {
|
||||
c := newFrameCtxCache()
|
||||
if _, ok := c.get("frameA"); ok {
|
||||
t.Fatal("cache recién creado no debería tener frameA")
|
||||
}
|
||||
c.set("frameA", 42)
|
||||
id, ok := c.get("frameA")
|
||||
if !ok || id != 42 {
|
||||
t.Fatalf("get(frameA) = (%d,%v), esperaba (42,true)", id, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: frames distintos no se pisan", func(t *testing.T) {
|
||||
c := newFrameCtxCache()
|
||||
c.set("frameA", 1)
|
||||
c.set("frameB", 2)
|
||||
if id, _ := c.get("frameA"); id != 1 {
|
||||
t.Errorf("frameA = %d, esperaba 1", id)
|
||||
}
|
||||
if id, _ := c.get("frameB"); id != 2 {
|
||||
t.Errorf("frameB = %d, esperaba 2", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalidate: tras invalidar, get falla (fuerza recrear mundo)", func(t *testing.T) {
|
||||
c := newFrameCtxCache()
|
||||
c.set("frameA", 7)
|
||||
c.invalidate("frameA")
|
||||
if _, ok := c.get("frameA"); ok {
|
||||
t.Error("tras invalidate, get(frameA) debería fallar")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsStaleContextError cubre el discriminador puro que decide si reintentar
|
||||
// recreando el isolated world.
|
||||
func TestIsStaleContextError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil no es stale", nil, false},
|
||||
{"error generico no es stale", errors.New("boom"), false},
|
||||
{"Cannot find context es stale", errors.New("cdp error: Cannot find context with specified id"), true},
|
||||
{"Execution context was destroyed es stale", errors.New("Execution context was destroyed"), true},
|
||||
{"uniqueContextId es stale", errors.New("invalid uniqueContextId"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isStaleContextError(tc.err); got != tc.want {
|
||||
t.Errorf("isStaleContextError(%v) = %v, esperaba %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// findByTextCoreJS es el preludio JS compartido por las dos evaluaciones de
|
||||
// CdpFindRefByText: define norm/matches/leafmost y la lista de nodos candidatos.
|
||||
// Mismo algoritmo "leafmost" que CdpFindByText: prefiere el elemento más interno
|
||||
// que matchea (donde suele vivir el handler), no el contenedor que lo envuelve.
|
||||
const findByTextCoreJS = `
|
||||
var P = %s;
|
||||
var target = P.cs ? P.text : P.text.toLowerCase();
|
||||
var nodes = document.querySelectorAll(P.tag || '*');
|
||||
function norm(v) {
|
||||
v = (v || '').replace(/\s+/g, ' ').trim();
|
||||
return P.cs ? v : v.toLowerCase();
|
||||
}
|
||||
function matches(el) {
|
||||
var v = norm(el.innerText || el.textContent || '');
|
||||
return P.exact ? v === target : v.indexOf(target) >= 0;
|
||||
}
|
||||
function leafmost(el) {
|
||||
for (var i = 0; i < el.children.length; i++) {
|
||||
if (matches(el.children[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}`
|
||||
|
||||
// parseBackendNodeID extrae node.backendNodeId de la respuesta de DOM.describeNode.
|
||||
// Es puro: recibe el mapa ya deserializado por CDP y devuelve el id entero, o un
|
||||
// error claro si la estructura no es la esperada (nodo destruido, respuesta vacía).
|
||||
func parseBackendNodeID(resp map[string]any) (int, error) {
|
||||
node, ok := resp["node"].(map[string]any)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("describeNode: respuesta sin campo node")
|
||||
}
|
||||
raw, ok := node["backendNodeId"]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("describeNode: node sin backendNodeId")
|
||||
}
|
||||
f, ok := raw.(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("describeNode: backendNodeId tipo inesperado %T", raw)
|
||||
}
|
||||
return int(f), nil
|
||||
}
|
||||
|
||||
// CdpFindRefByText busca el primer elemento cuyo innerText matchea `text` y
|
||||
// devuelve su backendDOMNodeId — el mismo identificador estable (#ref) que
|
||||
// produce el outline de page_perceive y que consume CdpClickRef. Así se puede
|
||||
// hacer click-by-text sin pasar por un selector CSS frágil (nth-of-type).
|
||||
//
|
||||
// Retorna (backendNodeID, count, error):
|
||||
// - backendNodeID: ref del primer match, listo para CdpClickRef/CdpHoverRef.
|
||||
// - count: número total de elementos que matchean (tras el filtro leafmost).
|
||||
// count > 1 indica ambigüedad: el caller decide si refinar la búsqueda.
|
||||
// - error: si la conexión es nula, el texto vacío, el eval JS falla o no hay
|
||||
// ningún match (count == 0).
|
||||
//
|
||||
// Identidad unificada con el puente backendDOMNodeId: resuelve el nodo JS a un
|
||||
// RemoteObject (Runtime.evaluate returnByValue=false) y de ahí al nodo DOM
|
||||
// (DOM.describeNode), evitando el round-trip por selector CSS.
|
||||
func CdpFindRefByText(c *CDPConn, text string, opts FindByTextOpts) (int, int, error) {
|
||||
if c == nil {
|
||||
return 0, 0, fmt.Errorf("cdp find ref by text: conexion nula")
|
||||
}
|
||||
if text == "" {
|
||||
return 0, 0, fmt.Errorf("cdp find ref by text: texto vacio")
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"text": text,
|
||||
"tag": opts.Tag,
|
||||
"exact": opts.Exact,
|
||||
"cs": opts.CaseSensitive,
|
||||
})
|
||||
core := fmt.Sprintf(findByTextCoreJS, string(payload))
|
||||
|
||||
// 1. Contar matches (returnByValue=true vía CdpEvaluate).
|
||||
countJS := "(function(){" + core + `
|
||||
var n = 0;
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
if (matches(nodes[i]) && leafmost(nodes[i])) n++;
|
||||
}
|
||||
return n;
|
||||
})()`
|
||||
countStr, err := CdpEvaluate(c, countJS)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("cdp find ref by text: contar matches: %w", err)
|
||||
}
|
||||
count, _ := strconv.Atoi(strings.TrimSpace(countStr))
|
||||
if count == 0 {
|
||||
return 0, 0, fmt.Errorf("cdp find ref by text: no se encontro elemento con texto %q", text)
|
||||
}
|
||||
|
||||
// 2. Resolver el primer match a un RemoteObject (returnByValue=false para
|
||||
// obtener un objectId que apunta al nodo DOM vivo).
|
||||
elJS := "(function(){" + core + `
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
if (matches(nodes[i]) && leafmost(nodes[i])) return nodes[i];
|
||||
}
|
||||
return null;
|
||||
})()`
|
||||
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
|
||||
"expression": elJS,
|
||||
"returnByValue": false,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: evaluate elemento: %w", err)
|
||||
}
|
||||
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
|
||||
excMap, _ := exc.(map[string]any)
|
||||
txt, _ := excMap["text"].(string)
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: excepcion JS: %s", txt)
|
||||
}
|
||||
remote, ok := evRes["result"].(map[string]any)
|
||||
if !ok {
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: respuesta evaluate sin result")
|
||||
}
|
||||
objID, _ := remote["objectId"].(string)
|
||||
if objID == "" {
|
||||
// El conteo dio >0 pero el elemento desapareció entre ambos evals (DOM
|
||||
// mutó): tratamos como no encontrado para no devolver un ref inválido.
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: elemento volátil, sin objectId (el DOM cambió entre conteo y resolución)")
|
||||
}
|
||||
|
||||
// 3. Del RemoteObject al nodo DOM: backendNodeId.
|
||||
dn, err := c.sendCDP("DOM.describeNode", map[string]any{"objectId": objID})
|
||||
if err != nil {
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: describeNode: %w", err)
|
||||
}
|
||||
backendNodeID, err := parseBackendNodeID(dn)
|
||||
if err != nil {
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: %w", err)
|
||||
}
|
||||
return backendNodeID, count, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: cdp_find_ref_by_text
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpFindRefByText(c *CDPConn, text string, opts FindByTextOpts) (int, int, error)"
|
||||
description: "Busca el primer elemento cuyo innerText matchea el texto dado y devuelve su backendDOMNodeId (#ref estable) en vez de un selector CSS. Resuelve el nodo JS a RemoteObject (Runtime.evaluate returnByValue=false) y de ahi al nodo DOM (DOM.describeNode), unificando la identidad con page_perceive y CdpClickRef. Devuelve tambien el numero de matches para detectar ambiguedad. Prefiere elementos hoja (leafmost)."
|
||||
tags: [browser, cdp, find, locator, ref, accessibility, navegator]
|
||||
uses_functions:
|
||||
- cdp_evaluate_go_browser
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [encoding/json, fmt, strconv, strings]
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexion CDP activa obtenida con CdpConnect."
|
||||
- name: text
|
||||
desc: "Texto visible a buscar. Comparacion contra innerText/textContent normalizado (whitespace colapsado)."
|
||||
- name: opts
|
||||
desc: "FindByTextOpts: Tag (filtro por tag, vacio = cualquiera), Exact (default false), CaseSensitive (default false)."
|
||||
output: "(backendNodeID, count, error): backendNodeID es el #ref del primer match listo para CdpClickRef; count es el numero total de matches (>1 = ambiguo); error si conexion nula, texto vacio, eval JS falla o no hay match (count==0)."
|
||||
tested: true
|
||||
tests: ["TestCdpFindRefByText_guards", "TestParseBackendNodeID"]
|
||||
test_file_path: "functions/browser/cdp_find_ref_by_text_test.go"
|
||||
file_path: "functions/browser/cdp_find_ref_by_text.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
c, _ := browser.CdpConnect(9222)
|
||||
defer browser.CdpClose(c, 0)
|
||||
|
||||
// Encontrar el botón "Login" por su texto y clicar por #ref (sin selector CSS).
|
||||
ref, count, err := browser.CdpFindRefByText(c, "Login", browser.FindByTextOpts{Tag: "button"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if count > 1 {
|
||||
log.Printf("aviso: %d elementos matchean 'Login', usando el primero", count)
|
||||
}
|
||||
_ = browser.CdpClickRef(c, ref, browser.MouseProfileForMode("human"))
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras clicar/hacer hover sobre un elemento identificándolo por su texto visible y operar después por `#ref` (backendDOMNodeId) en vez de por un selector CSS frágil. Es el puente entre "lo veo por su texto" y el bucle percibir→actuar de `page_perceive` + `CdpClickRef`. Preferible a `cdp_find_by_text` (que devuelve selector `nth-of-type`) cuando el frontend cambia sus clases/estructura con cada build pero el texto es estable.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **count > 1 = ambigüedad**: la función devuelve el primer match pero te avisa con `count` cuántos hay. Refina con `opts.Tag` o `opts.Exact` si el texto aparece en varios sitios.
|
||||
- **Elemento volátil**: si el DOM muta entre el conteo y la resolución del nodo (SPA re-renderizando), el `objectId` puede venir vacío y la función devuelve error "elemento volátil" en vez de un `#ref` inválido. Reintenta tras `CdpWaitIdle`.
|
||||
- **El #ref es efímero por documento**: el `backendDOMNodeId` es estable mientras el nodo viva, pero se invalida tras navegar o recargar. No lo persistas entre páginas.
|
||||
- **Tests sin Chrome**: el núcleo puro (`parseBackendNodeID`) y los guards se testean sin navegador. El flujo completo (eval + describeNode contra DOM real) requiere Chrome y se valida por e2e.
|
||||
@@ -0,0 +1,70 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCdpFindRefByText_guards cubre las precondiciones (sin Chrome).
|
||||
func TestCdpFindRefByText_guards(t *testing.T) {
|
||||
t.Run("conexion nula", func(t *testing.T) {
|
||||
if _, _, err := CdpFindRefByText(nil, "x", FindByTextOpts{}); err == nil {
|
||||
t.Fatal("esperaba error con conexion nula")
|
||||
}
|
||||
})
|
||||
t.Run("texto vacio", func(t *testing.T) {
|
||||
c := &CDPConn{}
|
||||
_, _, err := CdpFindRefByText(c, "", FindByTextOpts{})
|
||||
if err == nil {
|
||||
t.Fatal("esperaba error con texto vacio")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "vacio") {
|
||||
t.Fatalf("mensaje %q no menciona vacio", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestParseBackendNodeID cubre el nucleo puro que convierte la respuesta de
|
||||
// DOM.describeNode en el backendNodeId entero. No requiere Chrome.
|
||||
func TestParseBackendNodeID(t *testing.T) {
|
||||
t.Run("golden: node con backendNodeId", func(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"node": map[string]any{"backendNodeId": 123.0, "nodeName": "BUTTON"},
|
||||
}
|
||||
id, err := parseBackendNodeID(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("error inesperado: %v", err)
|
||||
}
|
||||
if id != 123 {
|
||||
t.Fatalf("id = %d, esperaba 123", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: backendNodeId grande se trunca a int correctamente", func(t *testing.T) {
|
||||
resp := map[string]any{"node": map[string]any{"backendNodeId": 90001.0}}
|
||||
id, err := parseBackendNodeID(resp)
|
||||
if err != nil || id != 90001 {
|
||||
t.Fatalf("id=%d err=%v, esperaba 90001 sin error", id, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error: respuesta sin node", func(t *testing.T) {
|
||||
if _, err := parseBackendNodeID(map[string]any{}); err == nil {
|
||||
t.Error("esperaba error cuando falta node")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error: node sin backendNodeId", func(t *testing.T) {
|
||||
resp := map[string]any{"node": map[string]any{"nodeName": "DIV"}}
|
||||
if _, err := parseBackendNodeID(resp); err == nil {
|
||||
t.Error("esperaba error cuando falta backendNodeId")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error: backendNodeId tipo no numerico", func(t *testing.T) {
|
||||
resp := map[string]any{"node": map[string]any{"backendNodeId": "abc"}}
|
||||
if _, err := parseBackendNodeID(resp); err == nil {
|
||||
t.Error("esperaba error cuando backendNodeId no es numero")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// axoActionableRoles son los roles que el LLM puede referir con #ref. Misma
|
||||
// lista que _ACTIONABLE_ROLES de render_ax_outline.py.
|
||||
var axoActionableRoles = map[string]struct{}{
|
||||
"button": {},
|
||||
"link": {},
|
||||
"textbox": {},
|
||||
"searchbox": {},
|
||||
"checkbox": {},
|
||||
"radio": {},
|
||||
"combobox": {},
|
||||
"listbox": {},
|
||||
"menuitem": {},
|
||||
"menuitemcheckbox": {},
|
||||
"menuitemradio": {},
|
||||
"tab": {},
|
||||
"option": {},
|
||||
"switch": {},
|
||||
"slider": {},
|
||||
"spinbutton": {},
|
||||
"treeitem": {},
|
||||
"gridcell": {},
|
||||
}
|
||||
|
||||
// axoSkipRoles son roles sin valor semantico: se omiten y sus hijos se elevan al
|
||||
// nivel actual. Misma lista que _SKIP_ROLES de render_ax_outline.py.
|
||||
var axoSkipRoles = map[string]struct{}{
|
||||
"none": {},
|
||||
"presentation": {},
|
||||
"ignored": {},
|
||||
}
|
||||
|
||||
// axoMaxDepth limita la profundidad de render (guard anti-RecursionError de
|
||||
// arboles AX patologicos). Igual que _MAX_DEPTH del .py.
|
||||
const axoMaxDepth = 60
|
||||
|
||||
// axNode es la representacion interna de un AXNode CDP, ya extraida del
|
||||
// map[string]any de la respuesta. Los helpers de poda y render operan sobre
|
||||
// estos structs, lo que los hace puros y testeables sin Chrome.
|
||||
type axNode struct {
|
||||
nodeID string
|
||||
backendDOMNodeID string
|
||||
ignored bool
|
||||
role string
|
||||
name string
|
||||
value string
|
||||
childIDs []string
|
||||
parentID string
|
||||
}
|
||||
|
||||
// CdpGetAXOutline percibe la pagina (o un iframe concreto via frameID) como un
|
||||
// outline accesible indentado y accionable, reusando la conexion CDP viva del
|
||||
// pool — sin abrir un WebSocket nuevo ni levantar el venv de Python.
|
||||
//
|
||||
// Envia Accessibility.enable (idempotente) y Accessibility.getFullAXTree. Si
|
||||
// frameID != "", pasa {"frameId": frameID} para obtener el arbol DENTRO de ese
|
||||
// iframe; con frameID == "" obtiene el arbol completo de la pagina (depth -1).
|
||||
//
|
||||
// El resultado se poda (trim) y luego se renderiza replicando exactamente el
|
||||
// formato del pipeline Python cdp_get_ax_tree -> trim_ax_tree -> render_ax_outline:
|
||||
// indentacion de 2 espacios por nivel, `role "name"`, ` = 'value'` para inputs,
|
||||
// y marcador ` #ref=<backendDOMNodeId>` en roles accionables. maxChars > 0
|
||||
// trunca y añade "\n…[outline truncado]"; maxChars <= 0 = sin limite.
|
||||
func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error) {
|
||||
if c == nil {
|
||||
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 {
|
||||
return "", fmt.Errorf("cdp get ax outline: Accessibility.enable: %w", err)
|
||||
}
|
||||
|
||||
var params map[string]any
|
||||
if frameID != "" {
|
||||
params = map[string]any{"frameId": frameID}
|
||||
}
|
||||
|
||||
res, err := c.sendCDP("Accessibility.getFullAXTree", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp get ax outline: Accessibility.getFullAXTree: %w", err)
|
||||
}
|
||||
|
||||
nodes := axoParseNodes(res)
|
||||
trimmed := trimAXTree(nodes)
|
||||
return renderAXOutline(trimmed, maxChars), nil
|
||||
}
|
||||
|
||||
// axoParseNodes extrae la lista de axNode del result de getFullAXTree. Tras el
|
||||
// JSON unmarshal a map[string]any, los nodos vienen como []any de
|
||||
// map[string]any y los enteros (backendDOMNodeId, nodeId) como float64; nodeId y
|
||||
// childIds suelen llegar como strings. Normalizamos todo a string.
|
||||
func axoParseNodes(result map[string]any) []axNode {
|
||||
raw, ok := result["nodes"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]axNode, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n := axNode{
|
||||
nodeID: axoStr(m["nodeId"]),
|
||||
backendDOMNodeID: axoStr(m["backendDOMNodeId"]),
|
||||
ignored: axoBool(m["ignored"]),
|
||||
role: axoNested(m["role"]),
|
||||
name: axoNested(m["name"]),
|
||||
value: axoNested(m["value"]),
|
||||
childIDs: axoStrSlice(m["childIds"]),
|
||||
parentID: axoStr(m["parentId"]),
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// axoNested extrae el campo "value" de un objeto CDP del tipo {value: ...} (role,
|
||||
// name, value vienen asi). Devuelve "" si esta ausente o vacio.
|
||||
func axoNested(v any) string {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return axoStr(v)
|
||||
}
|
||||
return axoStr(m["value"])
|
||||
}
|
||||
|
||||
// axoStr normaliza cualquier escalar JSON a string. Los enteros CDP llegan como
|
||||
// float64 tras el unmarshal; los renderizamos sin decimales.
|
||||
func axoStr(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
// IDs CDP son enteros: evitar notacion 1.234e+06 / sufijo .0.
|
||||
return fmt.Sprintf("%d", int64(t))
|
||||
case bool:
|
||||
if t {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
|
||||
func axoBool(v any) bool {
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func axoStrSlice(v any) []string {
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
out = append(out, axoStr(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trimAXTree compacta la lista de axNode descartando nodos irrelevantes y
|
||||
// colapsando cadenas padre->hijo del mismo role. Puro: porta trim_ax_tree.py.
|
||||
//
|
||||
// Descarta: ignored=true; role 'generic'/'none' sin name ni childIds;
|
||||
// role 'StaticText' con name vacio. Colapsa: nodo con exactamente 1 hijo del
|
||||
// mismo role hereda los childIds del hijo (el hijo se descarta). Itera hasta
|
||||
// convergencia. Preserva el orden original de aparicion.
|
||||
func trimAXTree(nodes []axNode) []axNode {
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
shouldDiscard := func(n axNode) bool {
|
||||
if n.ignored {
|
||||
return true
|
||||
}
|
||||
if (n.role == "generic" || n.role == "none") && n.name == "" && len(n.childIDs) == 0 {
|
||||
return true
|
||||
}
|
||||
if n.role == "StaticText" && n.name == "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
byID := map[string]axNode{}
|
||||
for _, n := range nodes {
|
||||
if shouldDiscard(n) {
|
||||
continue
|
||||
}
|
||||
byID[n.nodeID] = n
|
||||
}
|
||||
|
||||
// Colapso iterativo hasta convergencia.
|
||||
for {
|
||||
changed := false
|
||||
removed := map[string]struct{}{}
|
||||
for _, node := range byID {
|
||||
if _, gone := removed[node.nodeID]; gone {
|
||||
continue
|
||||
}
|
||||
if len(node.childIDs) != 1 {
|
||||
continue
|
||||
}
|
||||
childID := node.childIDs[0]
|
||||
child, ok := byID[childID]
|
||||
if !ok || child.role != node.role {
|
||||
continue
|
||||
}
|
||||
// Fusionar: el padre hereda los childIds del hijo.
|
||||
merged := node
|
||||
merged.childIDs = child.childIDs
|
||||
byID[node.nodeID] = merged
|
||||
removed[childID] = struct{}{}
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
for id := range removed {
|
||||
delete(byID, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Preservar orden original.
|
||||
result := make([]axNode, 0, len(byID))
|
||||
seen := map[string]struct{}{}
|
||||
for _, n := range nodes {
|
||||
node, ok := byID[n.nodeID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[n.nodeID]; dup {
|
||||
continue
|
||||
}
|
||||
result = append(result, node)
|
||||
seen[n.nodeID] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// renderAXOutline convierte axNode en un outline indentado, legible y
|
||||
// accionable. Puro: porta render_ax_outline.py al caracter. La jerarquia se
|
||||
// reconstruye con childIDs; las raices son nodeIds que no aparecen como hijo de
|
||||
// nadie (fallback al primer nodo). maxChars > 0 trunca con sufijo.
|
||||
func renderAXOutline(nodes []axNode, maxChars int) string {
|
||||
if len(nodes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
byID := map[string]axNode{}
|
||||
for _, n := range nodes {
|
||||
if n.nodeID != "" {
|
||||
byID[n.nodeID] = n
|
||||
}
|
||||
}
|
||||
|
||||
allChildIDs := map[string]struct{}{}
|
||||
for _, n := range nodes {
|
||||
for _, cid := range n.childIDs {
|
||||
allChildIDs[cid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var roots []axNode
|
||||
for _, n := range nodes {
|
||||
if _, isChild := allChildIDs[n.nodeID]; !isChild {
|
||||
roots = append(roots, n)
|
||||
}
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
roots = []axNode{nodes[0]}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
visited := map[string]struct{}{} // guard de ciclo: un nodeId no se renderiza dos veces
|
||||
|
||||
var renderNode func(node axNode, depth int)
|
||||
renderNode = func(node axNode, depth int) {
|
||||
nid := node.nodeID
|
||||
if depth > axoMaxDepth {
|
||||
return
|
||||
}
|
||||
if nid != "" {
|
||||
if _, dup := visited[nid]; dup {
|
||||
return
|
||||
}
|
||||
visited[nid] = struct{}{}
|
||||
}
|
||||
|
||||
if node.ignored {
|
||||
return
|
||||
}
|
||||
|
||||
role := node.role
|
||||
if _, skip := axoSkipRoles[role]; role == "" || skip {
|
||||
// Nodos sin role util: elevar los hijos al nivel actual.
|
||||
for _, cid := range node.childIDs {
|
||||
if child, ok := byID[cid]; ok {
|
||||
renderNode(child, depth)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
indent := strings.Repeat(" ", depth)
|
||||
var base string
|
||||
if node.name != "" {
|
||||
base = fmt.Sprintf("%s%s %q", indent, role, node.name)
|
||||
} else {
|
||||
base = indent + role
|
||||
}
|
||||
|
||||
// Estado actual del campo (texto escrito, valor de slider/combobox).
|
||||
if node.value != "" {
|
||||
base += " = " + axoPyRepr(node.value)
|
||||
}
|
||||
|
||||
// Ref accionable, sin padding.
|
||||
if _, ok := axoActionableRoles[role]; ok {
|
||||
ref := axoRefID(node)
|
||||
if ref != "" {
|
||||
base += " #ref=" + ref
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, base)
|
||||
|
||||
for _, cid := range node.childIDs {
|
||||
if child, ok := byID[cid]; ok {
|
||||
renderNode(child, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, root := range roots {
|
||||
renderNode(root, 0)
|
||||
}
|
||||
|
||||
result := strings.Join(lines, "\n")
|
||||
|
||||
if maxChars > 0 && len(result) > maxChars {
|
||||
result = strings.TrimRight(result[:maxChars], " \t\n\r\v\f")
|
||||
result += "\n…[outline truncado]"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// axoRefID devuelve el ref estable del nodo: backendDOMNodeId (apunta al nodo DOM
|
||||
// real, estable mientras el nodo viva) con fallback al nodeId. Igual que
|
||||
// _ref_id() del .py.
|
||||
func axoRefID(n axNode) string {
|
||||
if n.backendDOMNodeID != "" {
|
||||
return n.backendDOMNodeID
|
||||
}
|
||||
return n.nodeID
|
||||
}
|
||||
|
||||
// axoPyRepr replica Python repr() para strings: comillas simples por defecto;
|
||||
// comillas dobles si la cadena contiene comilla simple pero no doble; escape de
|
||||
// backslash y de la comilla delimitadora. Reproduce el efecto de `{value!r}`
|
||||
// del render_ax_outline.py para que la salida coincida al caracter.
|
||||
func axoPyRepr(s string) string {
|
||||
hasSingle := strings.Contains(s, "'")
|
||||
hasDouble := strings.Contains(s, "\"")
|
||||
quote := byte('\'')
|
||||
if hasSingle && !hasDouble {
|
||||
quote = '"'
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteByte(quote)
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
switch ch {
|
||||
case '\\':
|
||||
b.WriteString("\\\\")
|
||||
case '\n':
|
||||
b.WriteString("\\n")
|
||||
case '\r':
|
||||
b.WriteString("\\r")
|
||||
case '\t':
|
||||
b.WriteString("\\t")
|
||||
case quote:
|
||||
b.WriteByte('\\')
|
||||
b.WriteByte(quote)
|
||||
default:
|
||||
b.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
b.WriteByte(quote)
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
id: cdp_get_ax_outline_go_browser
|
||||
name: cdp_get_ax_outline
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error)"
|
||||
description: "Percibe la pagina (o un iframe via frameID) como outline accesible indentado y accionable reusando la conexion CDP viva del pool. Envia Accessibility.enable + getFullAXTree, poda el arbol y lo renderiza con #ref=backendDOMNodeId en roles accionables. Replica al caracter el pipeline Python cdp_get_ax_tree -> trim_ax_tree -> render_ax_outline pero nativo en Go, sin subprocess ni venv."
|
||||
tags: [browser, cdp, ax, accessibility, perceive, iframe, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["TestRenderAXOutline_ActionableRoleCarriesRef", "TestRenderAXOutline_InputShowsValue", "TestRenderAXOutline_SkipRoleElevatesChildren", "TestRenderAXOutline_IndentationPerLevel", "TestRenderAXOutline_TruncationAddsSuffix", "TestTrimAXTree_DiscardsIgnored", "TestTrimAXTree_CollapsesSameRoleSingleChild", "TestAxoPyRepr", "TestAxoParseNodes"]
|
||||
test_file_path: "functions/browser/cdp_get_ax_outline_test.go"
|
||||
file_path: "functions/browser/cdp_get_ax_outline.go"
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexion CDP viva (*CDPConn) del pool, ya conectada al tab/target objetivo. No abre WebSocket nuevo: reusa la del pool. Nil devuelve error."
|
||||
- name: frameID
|
||||
desc: "frameId CDP del iframe a percibir. Cadena vacia ('') percibe el arbol completo de la pagina (depth -1). Con valor, obtiene el AX tree DENTRO de ese iframe."
|
||||
- name: maxChars
|
||||
desc: "Limite de caracteres del outline. >0 trunca y añade '\\n…[outline truncado]'. <=0 = sin limite."
|
||||
output: "Outline accesible multi-linea: 2 espacios de indentacion por nivel, 'role \"name\"' por nodo, ' = '\\''value'\\''' en inputs, y marcador ' #ref=<backendDOMNodeId>' en roles accionables. Cadena vacia si no hay nodos utiles."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// c es una *CDPConn viva del pool (la misma que usa el browser_mcp).
|
||||
// Percibir la pagina entera, truncando a 8000 chars:
|
||||
outline, err := CdpGetAXOutline(c, "", 8000)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(outline)
|
||||
// WebArea "Example Domain"
|
||||
// heading "Example Domain"
|
||||
// link "More information..." #ref=128
|
||||
|
||||
// Percibir DENTRO de un iframe concreto (frameId del frame tree):
|
||||
inner, err := CdpGetAXOutline(c, "F1A2B3C4D5E6", 0) // 0 = sin limite
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando necesites **percibir la pagina (o un iframe) como outline accionable** para que un LLM decida sobre `#ref` sin reventar el contexto.
|
||||
- **Reemplaza el subprocess Python** `fn run cdp_perceive_outline`: es nativo Go, reusa la conexion CDP viva del pool y no arranca el venv en cada percepcion (mas rapido y sin dependencia de runtime `fn`/venv).
|
||||
- Pasa `frameID` cuando el contenido objetivo vive dentro de un iframe; deja `frameID=""` para la pagina top-level.
|
||||
- El `#ref` que devuelve (backendDOMNodeId) se pasa luego a `cdp_click_ref` / `cdp_type_ref` / `cdp_hover_ref`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: requiere un Chrome vivo con CDP accesible y el dominio `Accessibility` disponible. `Accessibility.enable` se envia siempre (idempotente).
|
||||
- **Conexion nula** devuelve error inmediato; no intenta reconectar.
|
||||
- **OOPIF cross-origin**: un iframe de distinto origen corre en un target (proceso) separado. Si `Accessibility.getFullAXTree` con ese `frameId` no devuelve nodos, probablemente necesites una `*CDPConn` adjunta al target del frame, no el `frameId` desde el target padre.
|
||||
- **`#ref` = backendDOMNodeId**: estable mientras el nodo DOM viva, pero si la pagina re-renderiza ese subarbol el ref puede invalidarse. Percibe de nuevo tras una mutacion grande antes de actuar.
|
||||
- El outline omite roles `none`/`presentation`/`ignored` y nodos `ignored=true`, y eleva sus hijos al nivel actual; un arbol con todo ignorado devuelve cadena vacia.
|
||||
- Guard de profundidad 60 y guard de ciclo: arboles patologicos no cuelgan, pero pueden quedar recortados a partir de la profundidad 60.
|
||||
@@ -0,0 +1,279 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- renderAXOutline: casos clave portados de render_ax_outline.py ---
|
||||
|
||||
func TestRenderAXOutline_ActionableRoleCarriesRef(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "WebArea", name: "Page", childIDs: []string{"2"}},
|
||||
{nodeID: "2", backendDOMNodeID: "555", role: "button", name: "Submit"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
want := "WebArea \"Page\"\n button \"Submit\" #ref=555"
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_NonActionableHasNoRef(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", backendDOMNodeID: "9", role: "heading", name: "Title"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
if strings.Contains(got, "#ref") {
|
||||
t.Errorf("rol no accionable no debe llevar #ref: %q", got)
|
||||
}
|
||||
if got != "heading \"Title\"" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_InputShowsValue(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "form", childIDs: []string{"2"}},
|
||||
{nodeID: "2", backendDOMNodeID: "42", role: "textbox", name: "Email", value: "a@b.com"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
want := "form\n textbox \"Email\" = 'a@b.com' #ref=42"
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_ValueWithSingleQuoteUsesDoubleQuote(t *testing.T) {
|
||||
// Python repr: "it's" -> "it's" (comilla doble como delimitador).
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", backendDOMNodeID: "7", role: "textbox", value: "it's"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
want := "textbox = \"it's\" #ref=7"
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_SkipRoleElevatesChildren(t *testing.T) {
|
||||
// El nodo 'none' se omite; su hijo button sube al nivel del padre (depth 1,
|
||||
// no depth 2), porque el render del skip-node reusa el mismo depth.
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "WebArea", name: "Root", childIDs: []string{"2"}},
|
||||
{nodeID: "2", role: "none", childIDs: []string{"3"}},
|
||||
{nodeID: "3", backendDOMNodeID: "30", role: "button", name: "Go"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
want := "WebArea \"Root\"\n button \"Go\" #ref=30"
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_EmptyRoleElevatesChildren(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "", childIDs: []string{"2"}}, // sin role: se omite
|
||||
{nodeID: "2", backendDOMNodeID: "20", role: "link", name: "Home"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
// El nodo raiz sin role eleva su hijo a depth 0.
|
||||
want := "link \"Home\" #ref=20"
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_IndentationPerLevel(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "WebArea", name: "A", childIDs: []string{"2"}},
|
||||
{nodeID: "2", role: "group", name: "B", childIDs: []string{"3"}},
|
||||
{nodeID: "3", role: "group", name: "C"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
want := "WebArea \"A\"\n group \"B\"\n group \"C\""
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_TruncationAddsSuffix(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "WebArea", name: "AAAAAAAAAAAAAAAAAAAA"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 10)
|
||||
if !strings.HasSuffix(got, "\n…[outline truncado]") {
|
||||
t.Errorf("falta sufijo de truncado: %q", got)
|
||||
}
|
||||
// El cuerpo truncado (sin sufijo) no debe exceder los 10 chars.
|
||||
body := strings.TrimSuffix(got, "\n…[outline truncado]")
|
||||
if len([]byte(body)) > 10 {
|
||||
t.Errorf("cuerpo truncado mas largo que maxChars: %q (%d bytes)", body, len(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_NoTruncationWhenUnderLimit(t *testing.T) {
|
||||
nodes := []axNode{{nodeID: "1", role: "button", name: "X", backendDOMNodeID: "1"}}
|
||||
got := renderAXOutline(nodes, 1000)
|
||||
if strings.Contains(got, "truncado") {
|
||||
t.Errorf("no debe truncar bajo el limite: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_Empty(t *testing.T) {
|
||||
if got := renderAXOutline(nil, 0); got != "" {
|
||||
t.Errorf("nil -> %q, want vacio", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_RefFallsBackToNodeID(t *testing.T) {
|
||||
// Sin backendDOMNodeId, el #ref usa el nodeId.
|
||||
nodes := []axNode{
|
||||
{nodeID: "77", role: "button", name: "Fallback"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
want := "button \"Fallback\" #ref=77"
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_CycleGuard(t *testing.T) {
|
||||
// Ciclo 1 -> 2 -> 1: no debe colgar ni duplicar nodos.
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "group", name: "A", childIDs: []string{"2"}},
|
||||
{nodeID: "2", role: "group", name: "B", childIDs: []string{"1"}},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
if strings.Count(got, "group \"A\"") != 1 {
|
||||
t.Errorf("nodo A renderizado mas de una vez: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- trimAXTree: casos clave portados de trim_ax_tree.py ---
|
||||
|
||||
func TestTrimAXTree_DiscardsIgnored(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "button", name: "Keep"},
|
||||
{nodeID: "2", role: "button", name: "Drop", ignored: true},
|
||||
}
|
||||
got := trimAXTree(nodes)
|
||||
if len(got) != 1 || got[0].nodeID != "1" {
|
||||
t.Errorf("trim debe descartar ignored: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimAXTree_DiscardsEmptyGeneric(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "generic"}, // sin name ni childIds -> descartado
|
||||
{nodeID: "2", role: "none"}, // idem
|
||||
{nodeID: "3", role: "StaticText", name: ""}, // staticText vacio -> descartado
|
||||
{nodeID: "4", role: "StaticText", name: "Hola"},
|
||||
}
|
||||
got := trimAXTree(nodes)
|
||||
if len(got) != 1 || got[0].nodeID != "4" {
|
||||
t.Errorf("trim debe descartar generic/none/staticText vacios: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimAXTree_KeepsGenericWithChildren(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "generic", childIDs: []string{"2"}}, // tiene hijos -> se queda
|
||||
{nodeID: "2", role: "button", name: "X"},
|
||||
}
|
||||
got := trimAXTree(nodes)
|
||||
if len(got) != 2 {
|
||||
t.Errorf("generic con hijos debe conservarse: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimAXTree_CollapsesSameRoleSingleChild(t *testing.T) {
|
||||
// list -> list (1 hijo, mismo role): se fusiona, el padre hereda los childIds.
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "list", childIDs: []string{"2"}},
|
||||
{nodeID: "2", role: "list", childIDs: []string{"3"}},
|
||||
{nodeID: "3", role: "listitem", name: "item"},
|
||||
}
|
||||
got := trimAXTree(nodes)
|
||||
// Nodo 2 desaparece; nodo 1 debe apuntar ahora a 3.
|
||||
var saw1, saw2 bool
|
||||
var node1 axNode
|
||||
for _, n := range got {
|
||||
if n.nodeID == "1" {
|
||||
saw1 = true
|
||||
node1 = n
|
||||
}
|
||||
if n.nodeID == "2" {
|
||||
saw2 = true
|
||||
}
|
||||
}
|
||||
if !saw1 || saw2 {
|
||||
t.Fatalf("colapso fallido: saw1=%v saw2=%v got=%+v", saw1, saw2, got)
|
||||
}
|
||||
if len(node1.childIDs) != 1 || node1.childIDs[0] != "3" {
|
||||
t.Errorf("padre fusionado debe heredar childIds del hijo: %+v", node1.childIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimAXTree_PreservesOrder(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "3", role: "button", name: "C"},
|
||||
{nodeID: "1", role: "button", name: "A"},
|
||||
{nodeID: "2", role: "button", name: "B"},
|
||||
}
|
||||
got := trimAXTree(nodes)
|
||||
if len(got) != 3 || got[0].nodeID != "3" || got[1].nodeID != "1" || got[2].nodeID != "2" {
|
||||
t.Errorf("orden original no preservado: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimAXTree_Empty(t *testing.T) {
|
||||
if got := trimAXTree(nil); got != nil {
|
||||
t.Errorf("nil -> %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- axoPyRepr: paridad con Python repr() ---
|
||||
|
||||
func TestAxoPyRepr(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"hola", "'hola'"},
|
||||
{"it's", "\"it's\""}, // tiene ', no " -> delimitador "
|
||||
{"say \"hi\"", "'say \"hi\"'"}, // tiene " -> delimitador '
|
||||
{"both ' and \"", "'both \\' and \"'"}, // ambos -> ' con escape del '
|
||||
{"a\nb", "'a\\nb'"},
|
||||
{"back\\slash", "'back\\\\slash'"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := axoPyRepr(c.in); got != c.want {
|
||||
t.Errorf("axoPyRepr(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- axoParseNodes: extraccion del map CDP (numeros como float64) ---
|
||||
|
||||
func TestAxoParseNodes(t *testing.T) {
|
||||
result := map[string]any{
|
||||
"nodes": []any{
|
||||
map[string]any{
|
||||
"nodeId": "1",
|
||||
"backendDOMNodeId": float64(555), // CDP int llega como float64
|
||||
"ignored": false,
|
||||
"role": map[string]any{"value": "button"},
|
||||
"name": map[string]any{"value": "Go"},
|
||||
"value": map[string]any{"value": "x"},
|
||||
"childIds": []any{"2", "3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := axoParseNodes(result)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d nodos, want 1", len(got))
|
||||
}
|
||||
n := got[0]
|
||||
if n.nodeID != "1" || n.backendDOMNodeID != "555" || n.role != "button" ||
|
||||
n.name != "Go" || n.value != "x" || len(n.childIDs) != 2 {
|
||||
t.Errorf("parse incorrecto: %+v", n)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpGetHTML(c *CDPConn) (string, error)"
|
||||
description: "Retorna el HTML completo de la pagina actual (document.documentElement.outerHTML) via Runtime.evaluate. Captura el DOM vivo post-JavaScript, no el HTML fuente original."
|
||||
tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools]
|
||||
tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools, navegator]
|
||||
uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -35,6 +35,16 @@ html, err := CdpGetHTML(conn)
|
||||
// html contiene el DOM completo con todos los cambios JS aplicados
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites el HTML completo del DOM vivo (post-JavaScript) para parsear/extraer con un selector externo, guardar un snapshot fiel, o alimentar un parser HTML. Ideal para scraping de SPAs (React, Vue, Angular) donde el HTML fuente original está vacío.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Devuelve el HTML COMPLETO sin límite, a propósito**: no trunca ni resume. En páginas complejas pueden ser cientos de KB. Esto es deliberado: su trabajo es dar el DOM íntegro para parsing fiel, no un resumen.
|
||||
- **NO usar para alimentar un LLM directamente**: el HTML crudo quema tokens y trae ruido (scripts, estilos inline, atributos). Para contexto de modelo usa `cdp_get_text` (innerText, con `maxBytes` opcional) o `cdp_perceive_outline` (outline accesible con #refs accionables). Reserva `cdp_get_html` para parsing programático.
|
||||
- **Es el DOM actual, no el HTML fuente**: incluye los cambios que el JavaScript haya aplicado hasta el instante de la llamada. Si la página sigue hidratando, espera con `cdp_wait_idle` antes.
|
||||
|
||||
## Notas
|
||||
|
||||
A diferencia de `Page.getResourceContent`, esta funcion captura el estado actual del DOM incluyendo modificaciones hechas por JavaScript. Ideal para scraping de SPAs (React, Vue, Angular). El HTML retornado puede ser muy largo para paginas complejas.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// CdpGetTextInFrame retorna el texto visible (innerText) del documento de un
|
||||
// iframe especifico, componiendo sobre CdpEvalInFrame con un mundo aislado CDP.
|
||||
//
|
||||
// Lee document.body.innerText (cae a document.documentElement.innerText si no
|
||||
// hay body), evitando parsear HTML crudo. Replica la politica de truncado de
|
||||
// CdpGetText: si maxBytes > 0 trunca al limite dado con corte rune-safe y añade
|
||||
// un sufijo con el total original en bytes; si maxBytes <= 0 no hay limite.
|
||||
//
|
||||
// Propaga los errores de CdpEvalInFrame (frame inexistente, contexto caducado)
|
||||
// envueltos con %w.
|
||||
func CdpGetTextInFrame(c *CDPConn, frameID string, maxBytes int) (string, error) {
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("cdp get text in frame: conexion nula")
|
||||
}
|
||||
if frameID == "" {
|
||||
return "", fmt.Errorf("cdp get text in frame: frameID vacio")
|
||||
}
|
||||
|
||||
const expr = `(document.body ? document.body.innerText : document.documentElement.innerText) || ""`
|
||||
|
||||
text, err := CdpEvalInFrame(c, frameID, expr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp get text in frame: %w", err)
|
||||
}
|
||||
|
||||
if maxBytes > 0 && len(text) > maxBytes {
|
||||
total := len(text)
|
||||
// Corte rune-safe: retrocede hasta encontrar un rune valido completo.
|
||||
cut := maxBytes
|
||||
for cut > 0 && !utf8.RuneStart(text[cut]) {
|
||||
cut--
|
||||
}
|
||||
text = text[:cut] + fmt.Sprintf("\n…[truncado, total %d bytes]", total)
|
||||
}
|
||||
|
||||
return text, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
id: cdp_get_text_in_frame_go_browser
|
||||
name: cdp_get_text_in_frame
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
purity: impure
|
||||
version: 1.0.0
|
||||
tested: false
|
||||
description: "Devuelve el texto visible (innerText) del documento de un iframe concreto componiendo sobre CdpEvalInFrame en un mundo aislado CDP, sin parsear HTML crudo. Trunca a maxBytes con corte rune-safe igual que CdpGetText."
|
||||
tags: [browser, cdp, iframe, frame, text, navegator]
|
||||
signature: "func CdpGetTextInFrame(c *CDPConn, frameID string, maxBytes int) (string, error)"
|
||||
uses_functions: [cdp_eval_in_frame_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
file_path: "functions/browser/cdp_get_text_in_frame.go"
|
||||
example: |
|
||||
conn, _ := CdpConnect("localhost", 9222, "")
|
||||
frames, _ := CdpListFrames(conn)
|
||||
text, err := CdpGetTextInFrame(conn, frames[1].ID, 4096)
|
||||
fmt.Println(text) // texto visible del primer iframe, truncado a 4096 bytes
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa obtenida con CdpConnect."
|
||||
- name: frameID
|
||||
desc: "ID del frame cuyo texto visible se quiere leer; obtenido de CdpListFrames (campo CdpFrame.ID)."
|
||||
- name: maxBytes
|
||||
desc: "Límite de bytes del texto devuelto. Si maxBytes > 0 trunca con corte rune-safe y añade un sufijo con el total original; si maxBytes <= 0 no hay límite."
|
||||
output: "String con el innerText visible del documento del iframe (document.body.innerText, o document.documentElement.innerText si no hay body), opcionalmente truncado a maxBytes; error si la conexión es nula, el frameID está vacío o la evaluación CDP del frame falla."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, err := CdpConnect("localhost", 9222, "")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 1. Listar frames para localizar el iframe deseado
|
||||
frames, err := CdpListFrames(conn)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 2. Leer el texto visible de cada iframe (saltando el frame raíz)
|
||||
for _, f := range frames {
|
||||
if f.ParentID == "" { // frame raíz, no es un iframe
|
||||
continue
|
||||
}
|
||||
text, err := CdpGetTextInFrame(conn, f.ID, 4096)
|
||||
if err != nil {
|
||||
log.Printf("error en frame %s: %v", f.ID, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("=== iframe %s (%s) ===\n%s\n", f.ID, f.URL, text)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites leer los datos visibles dentro de un iframe sin parsear HTML crudo: extraer el contenido textual de un widget embebido, un panel de pago, un captcha de texto o cualquier documento dentro de un `<iframe>`. Flujo típico: `CdpListFrames` → elegir frame por URL → `CdpGetTextInFrame`. Para HTML estructural completo usa `CdpGetFrameHTML`; para texto visible usa esta.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: el frame debe existir y haber terminado de cargar. Un `frameID` obsoleto (frame recargado/navegado) o un frame aún sin cargar propaga el error de `CdpEvalInFrame`.
|
||||
- Cross-origin OOPIF (out-of-process iframe): el mundo aislado puede vivir en un contexto distinto; si el frame es de otro origen y aislado del proceso, la lectura puede fallar o requerir el `frameID` exacto del OOPIF.
|
||||
- `innerText` omite el texto oculto por CSS (`display:none`, `visibility:hidden`) y colapsa espacios; refleja lo *visible*, no el contenido literal del DOM. Si necesitas todo el texto del DOM usa `textContent` vía `CdpEvalInFrame`, o el HTML completo vía `CdpGetFrameHTML`.
|
||||
- El corte por `maxBytes` es rune-safe pero ciego al contenido: puede cortar a mitad de una palabra o de una línea.
|
||||
@@ -0,0 +1,21 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCdpGetTextInFrame_guards cubre las precondiciones sin necesitar Chrome vivo.
|
||||
// La lectura real del innerText de un iframe requiere una conexion CDP activa y
|
||||
// un frame cargado, igual que los demas tests del paquete que la dejan gated.
|
||||
func TestCdpGetTextInFrame_guards(t *testing.T) {
|
||||
t.Run("conexion nula", func(t *testing.T) {
|
||||
if _, err := CdpGetTextInFrame(nil, "f1", 0); err == nil {
|
||||
t.Fatal("esperaba error con conexion nula")
|
||||
}
|
||||
})
|
||||
t.Run("frameID vacio", func(t *testing.T) {
|
||||
if _, err := CdpGetTextInFrame(&CDPConn{}, "", 0); err == nil {
|
||||
t.Fatal("esperaba error con frameID vacio")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,35 +1,106 @@
|
||||
package browser
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DialogLog acumula lo que CdpHandleDialog auto-respondió. El worker lo rellena en
|
||||
// cada diálogo; el caller lo lee con Snapshot() de forma segura (mutex interno).
|
||||
// Los campos son públicos para inspección directa en tests controlados, pero en
|
||||
// concurrencia usa siempre Snapshot() para evitar data races.
|
||||
type DialogLog struct {
|
||||
mu sync.Mutex
|
||||
Count int // número de diálogos auto-respondidos
|
||||
LastType string // tipo del último diálogo: alert|confirm|prompt|beforeunload
|
||||
LastMessage string // mensaje del último diálogo
|
||||
}
|
||||
|
||||
// record registra un diálogo auto-respondido. Es el núcleo puro (no toca CDP).
|
||||
func (l *DialogLog) record(dialogType, message string) {
|
||||
l.mu.Lock()
|
||||
l.Count++
|
||||
l.LastType = dialogType
|
||||
l.LastMessage = message
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// Snapshot devuelve una copia consistente del estado actual del log.
|
||||
func (l *DialogLog) Snapshot() (count int, lastType, lastMessage string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.Count, l.LastType, l.LastMessage
|
||||
}
|
||||
|
||||
// dialogJobBuffer es el tamaño del canal que desacopla el readLoop del worker
|
||||
// que responde diálogos. Amplio para absorber ráfagas sin bloquear la lectura
|
||||
// del WebSocket.
|
||||
const dialogJobBuffer = 64
|
||||
|
||||
// CdpHandleDialog instala un auto-handler que responde automaticamente a todos
|
||||
// los dialogos JS (alert, confirm, prompt, beforeunload) hasta que se llame
|
||||
// la funcion cancel devuelta. Usa el evento Page.javascriptDialogOpening y
|
||||
// los dialogos JS (alert, confirm, prompt, beforeunload) hasta que se llame la
|
||||
// funcion cancel devuelta. Usa el evento Page.javascriptDialogOpening y
|
||||
// Page.handleJavaScriptDialog del protocolo CDP.
|
||||
//
|
||||
// IMPORTANTE: el handler interno despacha la respuesta en una goroutine nueva
|
||||
// para evitar deadlock — el evento llega en la goroutine de lectura del
|
||||
// WebSocket, y sendCDP bloquea esperando una respuesta que leeria esa misma
|
||||
// goroutine si se llamara de forma sincrona.
|
||||
func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), error) {
|
||||
// Devuelve, además del cancel, un *DialogLog que el handler rellena en cada
|
||||
// diálogo: así el caller sabe cuántos diálogos se auto-respondieron y cuál fue
|
||||
// el último (tipo + mensaje).
|
||||
//
|
||||
// Concurrencia: el handler de evento corre en la goroutine de lectura del
|
||||
// WebSocket y NO puede llamar sendCDP de forma síncrona (deadlock). En vez de
|
||||
// lanzar una goroutine nueva por diálogo (spawn ilimitado), encola el evento en
|
||||
// un canal con buffer que consume UN único worker; el worker serializa las
|
||||
// respuestas. cancel() detiene el worker y des-registra el handler; es
|
||||
// idempotente (seguro llamarlo varias veces).
|
||||
func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), *DialogLog, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("cdp handle dialog: conexion nula")
|
||||
return nil, nil, fmt.Errorf("cdp handle dialog: conexion nula")
|
||||
}
|
||||
|
||||
if _, err := c.sendCDP("Page.enable", nil); err != nil {
|
||||
return nil, fmt.Errorf("cdp handle dialog: %w", err)
|
||||
return nil, nil, fmt.Errorf("cdp handle dialog: %w", err)
|
||||
}
|
||||
|
||||
cancel := c.OnEvent("Page.javascriptDialogOpening", func(method string, params map[string]any) {
|
||||
p := map[string]any{"accept": accept}
|
||||
if promptText != "" {
|
||||
p["promptText"] = promptText
|
||||
dlog := &DialogLog{}
|
||||
jobs := make(chan map[string]any, dialogJobBuffer)
|
||||
done := make(chan struct{})
|
||||
|
||||
// Worker único: serializa las respuestas a diálogos. Una sola goroutine para
|
||||
// toda la vida del handler, no una por diálogo.
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case params := <-jobs:
|
||||
dtype, _ := params["type"].(string)
|
||||
msg, _ := params["message"].(string)
|
||||
dlog.record(dtype, msg)
|
||||
p := map[string]any{"accept": accept}
|
||||
if promptText != "" {
|
||||
p["promptText"] = promptText
|
||||
}
|
||||
_, _ = c.sendCDP("Page.handleJavaScriptDialog", p)
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cancelEvent := c.OnEvent("Page.javascriptDialogOpening", func(_ string, params map[string]any) {
|
||||
// Encolar sin bloquear el readLoop. Si el buffer está lleno (tormenta de
|
||||
// diálogos), descartamos ese evento para no colgar la conexión entera.
|
||||
select {
|
||||
case jobs <- params:
|
||||
default:
|
||||
}
|
||||
// go es OBLIGATORIO: el handler corre en la goroutine de lectura del
|
||||
// WebSocket. Llamar sendCDP aqui directamente provoca deadlock porque
|
||||
// sendCDP espera una respuesta que la misma goroutine deberia leer.
|
||||
go c.sendCDP("Page.handleJavaScriptDialog", p) //nolint:errcheck
|
||||
})
|
||||
|
||||
return cancel, nil
|
||||
var once sync.Once
|
||||
cancel := func() {
|
||||
once.Do(func() {
|
||||
cancelEvent()
|
||||
close(done)
|
||||
})
|
||||
}
|
||||
|
||||
return cancel, dlog, nil
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
purity: impure
|
||||
version: 1.0.0
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
description: "Instala un auto-handler que responde automaticamente a dialogos JS (alert/confirm/prompt/beforeunload) via Page.javascriptDialogOpening CDP hasta que se llame el cancel devuelto."
|
||||
version: 1.1.0
|
||||
tested: true
|
||||
tests: ["TestCdpHandleDialog_nilConn", "TestDialogLog"]
|
||||
test_file_path: "functions/browser/cdp_handle_dialog_test.go"
|
||||
description: "Instala un auto-handler que responde automaticamente a dialogos JS (alert/confirm/prompt/beforeunload) via Page.javascriptDialogOpening CDP hasta que se llame el cancel devuelto. Devuelve un *DialogLog con Count/LastType/LastMessage de lo auto-respondido. Un unico worker serializa las respuestas (no spawnea una goroutine por dialogo)."
|
||||
tags: [cdp, browser, dialog, input, navegator]
|
||||
signature: "func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), error)"
|
||||
signature: "func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), *DialogLog, error)"
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -32,7 +32,7 @@ params:
|
||||
desc: "true para aceptar/OK el dialogo; false para rechazar/Cancel. Para alert() el valor no importa (siempre se cierra), para confirm() determina el valor de retorno, para prompt() determina si se devuelve el texto o null."
|
||||
- name: promptText
|
||||
desc: "Texto a inyectar en dialogos prompt(). Vacio string para no inyectar texto. Ignorado en alert() y confirm()."
|
||||
output: "cancel func() para des-registrar el handler cuando ya no se necesite, y error si la conexion es nula o Page.enable falla. El cancel devuelto es seguro llamarlo multiples veces."
|
||||
output: "(cancel func(), *DialogLog, error): cancel des-registra el handler y detiene el worker (idempotente, seguro llamarlo varias veces); DialogLog acumula Count/LastType/LastMessage de lo auto-respondido (leer con Snapshot()); error si la conexion es nula o Page.enable falla."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
@@ -40,10 +40,10 @@ output: "cancel func() para des-registrar el handler cuando ya no se necesite, y
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
_ = CdpNavigate(conn, "https://example.com/admin")
|
||||
_ = CdpWaitLoad(conn, 3000)
|
||||
_ = CdpWaitLoad(conn, 3*time.Second)
|
||||
|
||||
// Instalar handler antes de la accion que dispara el dialogo
|
||||
cancel, err := CdpHandleDialog(conn, true, "")
|
||||
cancel, dlog, err := CdpHandleDialog(conn, true, "")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -52,13 +52,11 @@ defer cancel()
|
||||
// Este boton dispara confirm("¿Seguro que quieres borrar?")
|
||||
// El handler lo acepta automaticamente sin bloquear
|
||||
_ = CdpClick(conn, "#btn-delete-all")
|
||||
_ = CdpWaitIdle(conn, 2000)
|
||||
_ = CdpWaitIdle(conn, CdpWaitIdleOpts{})
|
||||
|
||||
// Ejemplo con prompt(): responder con texto especifico
|
||||
cancelPrompt, _ := CdpHandleDialog(conn, true, "mi-respuesta-secreta")
|
||||
defer cancelPrompt()
|
||||
_ = CdpClick(conn, "#btn-ask-password")
|
||||
_ = CdpWaitIdle(conn, 1000)
|
||||
// Saber qué se auto-respondió
|
||||
count, lastType, lastMsg := dlog.Snapshot()
|
||||
fmt.Printf("auto-respondidos: %d (último %s: %q)\n", count, lastType, lastMsg)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
@@ -67,8 +65,13 @@ Instalar antes de cualquier accion que pueda disparar `alert()`, `confirm()`, `p
|
||||
|
||||
## Gotchas
|
||||
|
||||
- DEADLOCK GARANTIZADO si se llama `sendCDP` de forma sincrona dentro del handler de evento. El handler corre en la goroutine de lectura del WebSocket; `sendCDP` espera una respuesta que esa misma goroutine deberia leer. La implementacion ya usa `go c.sendCDP(...)` para evitarlo — no modificar este patron.
|
||||
- El handler se instala de forma permanente hasta que se llame `cancel()`. Si la pagina dispara multiples dialogos, todos seran respondidos con los mismos parametros `accept` y `promptText`.
|
||||
- `Page.enable` es idempotente pero tiene coste de red; no llamar CdpHandleDialog en bucles tight.
|
||||
- Para `beforeunload` (cuando el usuario cierra/navega fuera), `accept: true` permite la navegacion y `accept: false` la bloquea.
|
||||
- Llamar `cancel()` no cierra dialogos ya abiertos; solo evita que los futuros sean respondidos automaticamente.
|
||||
- DEADLOCK GARANTIZADO si se llama `sendCDP` de forma sincrona dentro del handler de evento. El handler corre en la goroutine de lectura del WebSocket; `sendCDP` espera una respuesta que esa misma goroutine deberia leer. La implementacion encola el evento en un canal y lo responde desde UN worker aparte — no modificar este patron.
|
||||
- **Un único worker, no goroutine por diálogo**: el handler antiguo hacía `go c.sendCDP(...)` por cada diálogo (spawn ilimitado). Ahora encola en un canal con buffer (64) que consume un worker. Si la página dispara una tormenta de diálogos que llena el buffer, los excedentes se descartan (no se responden) para no colgar la conexión — caso patológico, raro en la práctica.
|
||||
- **Leer el log con `Snapshot()`**: `DialogLog` tiene mutex interno. En concurrencia, usa `dlog.Snapshot()` en vez de leer los campos públicos directamente (evita data race con el worker).
|
||||
- El handler responde todos los diálogos con los mismos `accept` y `promptText` hasta que se llame `cancel()`.
|
||||
- `cancel()` es idempotente (seguro llamarlo varias veces) y detiene el worker. No cierra diálogos ya abiertos; solo evita responder los futuros.
|
||||
- Para `beforeunload`, `accept: true` permite la navegacion y `accept: false` la bloquea.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-06) — devuelve `*DialogLog` (Count/LastType/LastMessage) para que el caller sepa qué se auto-respondió; reemplaza el spawn de una goroutine por diálogo por un worker único alimentado por canal con buffer; `cancel()` ahora idempotente vía sync.Once.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCdpHandleDialog_nilConn cubre la precondición sin Chrome.
|
||||
func TestCdpHandleDialog_nilConn(t *testing.T) {
|
||||
_, _, err := CdpHandleDialog(nil, true, "")
|
||||
if err == nil {
|
||||
t.Fatal("esperaba error con conexion nula")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialogLog cubre el núcleo puro del registro de diálogos: contar, recordar
|
||||
// el último, y la seguridad concurrente del mutex. No requiere Chrome.
|
||||
func TestDialogLog(t *testing.T) {
|
||||
t.Run("golden: cuenta y recuerda el ultimo", func(t *testing.T) {
|
||||
l := &DialogLog{}
|
||||
l.record("alert", "hola")
|
||||
l.record("confirm", "¿seguro?")
|
||||
count, lastType, lastMsg := l.Snapshot()
|
||||
if count != 2 {
|
||||
t.Errorf("count = %d, esperaba 2", count)
|
||||
}
|
||||
if lastType != "confirm" || lastMsg != "¿seguro?" {
|
||||
t.Errorf("last = (%q,%q), esperaba (confirm, ¿seguro?)", lastType, lastMsg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: log vacio", func(t *testing.T) {
|
||||
l := &DialogLog{}
|
||||
count, lastType, lastMsg := l.Snapshot()
|
||||
if count != 0 || lastType != "" || lastMsg != "" {
|
||||
t.Errorf("log vacio = (%d,%q,%q), esperaba (0,\"\",\"\")", count, lastType, lastMsg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrencia: 100 records desde N goroutines no pierde cuentas", func(t *testing.T) {
|
||||
l := &DialogLog{}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
l.record("alert", "x")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if count, _, _ := l.Snapshot(); count != 100 {
|
||||
t.Errorf("count = %d, esperaba 100 (sin perder por race)", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -9,12 +9,21 @@ import (
|
||||
|
||||
// MouseHumanOpts configura el movimiento humano del ratón.
|
||||
type MouseHumanOpts struct {
|
||||
// Steps es el número de puntos intermedios de la curva (default 25).
|
||||
// Mode es la política de velocidad: "human" (default, ""), "fast" o "instant".
|
||||
// Controla los defaults de Steps/DurationMs/JitterPx y la pausa press/release:
|
||||
// - 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.
|
||||
Mode string
|
||||
// Steps es el número de puntos intermedios de la curva (default según Mode).
|
||||
Steps int
|
||||
// DurationMs es la duración total aproximada del movimiento en milisegundos.
|
||||
// Si es 0, se elige aleatoriamente entre 350 y 800 ms.
|
||||
// Si es 0, se elige según Mode.
|
||||
DurationMs int
|
||||
// JitterPx es la desviación perpendicular máxima por punto en píxeles (default 2.0).
|
||||
// JitterPx es la desviación perpendicular máxima por punto en píxeles (default según Mode).
|
||||
JitterPx float64
|
||||
// FromX es la coordenada X de origen. Si < 0, se usa (0, 0) como origen.
|
||||
FromX float64
|
||||
@@ -22,16 +31,49 @@ type MouseHumanOpts struct {
|
||||
FromY float64
|
||||
}
|
||||
|
||||
// mouseHumanDefaults aplica valores por defecto a opts.
|
||||
// MouseProfileForMode construye las opciones de ratón para un modo de velocidad.
|
||||
// Es la fuente única que MCP, runner YAML y CLI usan para mapear un modo a opts,
|
||||
// sin duplicar números. El mapeo modo→valores concretos vive en mouseHumanDefaults.
|
||||
// Un modo desconocido se trata como "human" (el más seguro).
|
||||
func MouseProfileForMode(mode string) MouseHumanOpts {
|
||||
switch mode {
|
||||
case "fast", "instant", "human", "":
|
||||
return MouseHumanOpts{Mode: mode, FromX: -1, FromY: -1}
|
||||
default:
|
||||
return MouseHumanOpts{Mode: "human", FromX: -1, FromY: -1}
|
||||
}
|
||||
}
|
||||
|
||||
// mouseHumanDefaults aplica valores por defecto a opts según opts.Mode.
|
||||
func mouseHumanDefaults(opts MouseHumanOpts) MouseHumanOpts {
|
||||
if opts.Steps <= 0 {
|
||||
opts.Steps = 25
|
||||
}
|
||||
if opts.DurationMs <= 0 {
|
||||
opts.DurationMs = 350 + rand.Intn(451) // 350..800
|
||||
}
|
||||
if opts.JitterPx <= 0 {
|
||||
opts.JitterPx = 2.0
|
||||
switch opts.Mode {
|
||||
case "instant":
|
||||
// El movimiento se omite en CdpMoveMouseHuman; valores mínimos por si acaso.
|
||||
if opts.Steps <= 0 {
|
||||
opts.Steps = 1
|
||||
}
|
||||
if opts.DurationMs <= 0 {
|
||||
opts.DurationMs = 1
|
||||
}
|
||||
// JitterPx se queda en 0.
|
||||
case "fast":
|
||||
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).
|
||||
default: // "human" o ""
|
||||
if opts.Steps <= 0 {
|
||||
opts.Steps = 25
|
||||
}
|
||||
if opts.DurationMs <= 0 {
|
||||
opts.DurationMs = 350 + rand.Intn(451) // 350..800
|
||||
}
|
||||
if opts.JitterPx <= 0 {
|
||||
opts.JitterPx = 2.0
|
||||
}
|
||||
}
|
||||
if opts.FromX < 0 {
|
||||
opts.FromX = 0
|
||||
@@ -119,6 +161,12 @@ func CdpMoveMouseHuman(c *CDPConn, toX, toY float64, opts MouseHumanOpts) error
|
||||
}
|
||||
opts = mouseHumanDefaults(opts)
|
||||
|
||||
// Modo instant: sin movimiento de ratón (el click lo resuelve quien llama,
|
||||
// por coords directas o por element.click() JS).
|
||||
if opts.Mode == "instant" {
|
||||
return nil
|
||||
}
|
||||
|
||||
p0 := [2]float64{opts.FromX, opts.FromY}
|
||||
p3 := [2]float64{toX, toY}
|
||||
ctrl1, ctrl2 := randomControlPoints(p0, p3)
|
||||
|
||||
@@ -15,21 +15,45 @@ type CdpStorageState struct {
|
||||
SessionStorage map[string]string `json:"sessionStorage"`
|
||||
}
|
||||
|
||||
// readWebStorage lee window.<store> (localStorage|sessionStorage) como mapa. Si el
|
||||
// origen no permite acceso (about:blank, chrome://) devuelve un mapa vacío.
|
||||
func readWebStorage(c *CDPConn, store string) map[string]string {
|
||||
// isStorageAccessDenied reconoce el error de CdpEvaluate cuando el origen no
|
||||
// permite acceder a window.localStorage/sessionStorage (about:blank, chrome://,
|
||||
// data:, sandbox sin allow-same-origin): el navegador lanza SecurityError. Es
|
||||
// puro: decide a partir del texto del error. Distingue ese caso legítimo (no hay
|
||||
// storage que guardar -> {}) de un error real (conexión caída, JS roto) que SÍ
|
||||
// debe propagarse para no escribir una sesión incompleta en silencio.
|
||||
func isStorageAccessDenied(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "SecurityError") ||
|
||||
strings.Contains(s, "Access is denied") ||
|
||||
strings.Contains(s, "operation is insecure") ||
|
||||
strings.Contains(s, "denied for this document")
|
||||
}
|
||||
|
||||
// readWebStorage lee window.<store> (localStorage|sessionStorage) como mapa.
|
||||
// Distingue tres casos:
|
||||
// - storage accesible (con o sin datos) -> (mapa, nil)
|
||||
// - origen sin storage accesible (about:blank, chrome://) -> ({}, nil)
|
||||
// - error REAL de evaluación (conexión caída, JS roto, JSON inválido) -> (nil, error)
|
||||
func readWebStorage(c *CDPConn, store string) (map[string]string, error) {
|
||||
raw, err := CdpEvaluate(c, "JSON.stringify(Object.assign({}, window."+store+"))")
|
||||
if err != nil {
|
||||
return map[string]string{}
|
||||
if isStorageAccessDenied(err) {
|
||||
// Origen sin storage accesible: vacío legítimo, no error.
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("leer %s: %w", store, err)
|
||||
}
|
||||
if raw == "" || raw == "undefined" || raw == "null" {
|
||||
return map[string]string{}
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
return map[string]string{}
|
||||
return nil, fmt.Errorf("parsear %s: %w", store, err)
|
||||
}
|
||||
return m
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// cookieDomainMatchesHost indica si una cookie con `domain` aplica al `host` dado.
|
||||
@@ -100,11 +124,21 @@ func CdpSaveStorageState(c *CDPConn, outPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Capturar localStorage y sessionStorage del origen actualmente cargado.
|
||||
// Capturar localStorage y sessionStorage del origen actualmente cargado. Un
|
||||
// error real (no un origen sin storage) aborta el guardado: mejor fallar que
|
||||
// escribir una sesión incompleta que el caller creería válida.
|
||||
localStorage, err := readWebStorage(c, "localStorage")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp save storage state: %w", err)
|
||||
}
|
||||
sessionStorage, err := readWebStorage(c, "sessionStorage")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp save storage state: %w", err)
|
||||
}
|
||||
state := CdpStorageState{
|
||||
Cookies: cookies,
|
||||
LocalStorage: readWebStorage(c, "localStorage"),
|
||||
SessionStorage: readWebStorage(c, "sessionStorage"),
|
||||
LocalStorage: localStorage,
|
||||
SessionStorage: sessionStorage,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
|
||||
@@ -5,9 +5,11 @@ kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
purity: impure
|
||||
version: 1.0.0
|
||||
tested: false
|
||||
description: "Captura cookies y localStorage de la página activa y los serializa a un archivo JSON para restaurar la sesión sin repetir el login."
|
||||
version: 1.1.0
|
||||
tested: true
|
||||
tests: ["TestIsStorageAccessDenied", "TestCookieDomainMatchesHost"]
|
||||
test_file_path: "functions/browser/cdp_save_storage_state_test.go"
|
||||
description: "Captura cookies, localStorage y sessionStorage de la página activa y los serializa a un archivo JSON para restaurar la sesión sin repetir el login. Distingue 'origen sin storage accesible' (vacío legítimo) de un error real de evaluación, que aborta el guardado en vez de escribir una sesión incompleta en silencio."
|
||||
tags: [cdp, browser, storage, session, cookies, localStorage, auth, navegator]
|
||||
signature: "func CdpSaveStorageState(c *CDPConn, outPath string) error"
|
||||
uses_functions:
|
||||
@@ -58,5 +60,9 @@ Tras completar un login en el browser (manual o automatizado), antes de cerrar l
|
||||
|
||||
- **localStorage es por-origen**: solo captura el localStorage del origen actualmente cargado en la pestaña. Si necesitas preservar localStorage de múltiples dominios, guarda un estado por cada dominio navegado.
|
||||
- **Cookies globales del perfil**: `Network.getAllCookies` devuelve todas las cookies del perfil de Chrome, no solo las del origen activo. El JSON puede ser grande si el perfil tiene muchas cookies.
|
||||
- **Páginas especiales** (`about:blank`, `chrome://`, extensiones): `CdpEvaluate` sobre localStorage fallará; la función lo maneja devolviendo un mapa vacío de forma defensiva, así que no romperá — pero el localStorage quedará vacío en el JSON.
|
||||
- **Páginas especiales** (`about:blank`, `chrome://`, `data:`, extensiones): acceder a `window.localStorage` lanza `SecurityError`. La función lo detecta (`isStorageAccessDenied`) y devuelve `{}` legítimo, no error — el storage queda vacío en el JSON. **Pero** un error REAL (conexión caída, JS roto, JSON inválido) ahora SÍ se propaga y aborta el guardado: antes se tragaba en silencio y escribía una sesión incompleta que parecía válida.
|
||||
- **Permisos**: el archivo se escribe con `0644`; asegúrate de que el directorio de destino existe antes de llamar a la función.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-06) — `readWebStorage` distingue "origen sin storage accesible" (SecurityError → `{}`) de "error real de evaluación" (se propaga); `CdpSaveStorageState` aborta en error real en vez de guardar sesión incompleta en silencio; captura también sessionStorage; test del discriminador `isStorageAccessDenied` + del matcher `cookieDomainMatchesHost`.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsStorageAccessDenied cubre el discriminador puro que separa "origen sin
|
||||
// storage accesible" (vacío legítimo) de "error real" (que debe propagarse).
|
||||
func TestIsStorageAccessDenied(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil no es denied", nil, false},
|
||||
{"error de conexion (real) no es denied", errors.New("cdp evaluate: ws read: EOF"), false},
|
||||
{"SecurityError es denied", errors.New("cdp evaluate: excepcion JS: SecurityError: ..."), true},
|
||||
{"Access is denied es denied", errors.New("Access is denied for this document"), true},
|
||||
{"operation is insecure es denied", errors.New("The operation is insecure"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isStorageAccessDenied(tc.err); got != tc.want {
|
||||
t.Errorf("isStorageAccessDenied(%v) = %v, esperaba %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCookieDomainMatchesHost cubre el matcher puro de dominio de cookie vs host
|
||||
// (no tenía test previo).
|
||||
func TestCookieDomainMatchesHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
domain, host string
|
||||
want bool
|
||||
}{
|
||||
{"example.com", "example.com", true}, // exacto
|
||||
{".example.com", "example.com", true}, // punto inicial
|
||||
{".example.com", "app.example.com", true}, // subdominio
|
||||
{"example.com", "app.example.com", true}, // subdominio sin punto
|
||||
{"example.com", "notexample.com", false}, // sufijo engañoso
|
||||
{"example.com", "example.com.evil.com", false}, // no es subdominio real
|
||||
{"", "example.com", false}, // dominio vacío
|
||||
{"example.com", "", false}, // host vacío
|
||||
{"other.com", "example.com", false}, // distinto
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := cookieDomainMatchesHost(tc.domain, tc.host)
|
||||
if got != tc.want {
|
||||
t.Errorf("cookieDomainMatchesHost(%q, %q) = %v, esperaba %v", tc.domain, tc.host, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,57 @@ type CdpScreenshotOpts struct {
|
||||
Format string
|
||||
}
|
||||
|
||||
// CdpScreenshot captura un screenshot de la pagina actual y lo guarda en outputPath.
|
||||
// fullPageClip es el rectangulo de recorte (en CSS pixels) que cubre la pagina
|
||||
// completa. scale=1 mantiene la resolucion nativa.
|
||||
type fullPageClip struct {
|
||||
X, Y, Width, Height, Scale float64
|
||||
}
|
||||
|
||||
// buildFullPageClip construye el clip de pagina completa a partir de la respuesta
|
||||
// de Page.getLayoutMetrics. Es una funcion pura: no toca red, recibe el mapa ya
|
||||
// deserializado por CDP y decide el rectangulo.
|
||||
//
|
||||
// Prefiere cssContentSize (dimensiones en CSS pixels, ya divididas por el DPR),
|
||||
// que es lo que espera el campo "clip" de Page.captureScreenshot. Cae a
|
||||
// contentSize (device pixels, protocolo antiguo) si cssContentSize no esta
|
||||
// presente. Devuelve ok=false cuando no hay un tamano valido (>0 en ambos ejes),
|
||||
// para que el caller capture solo el viewport en vez de un clip degenerado.
|
||||
func buildFullPageClip(metrics map[string]any) (fullPageClip, bool) {
|
||||
asFloat := func(v any) float64 {
|
||||
f, _ := v.(float64)
|
||||
return f
|
||||
}
|
||||
for _, key := range []string{"cssContentSize", "contentSize"} {
|
||||
size, ok := metrics[key].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
w := asFloat(size["width"])
|
||||
h := asFloat(size["height"])
|
||||
if w > 0 && h > 0 {
|
||||
return fullPageClip{X: 0, Y: 0, Width: w, Height: h, Scale: 1}, true
|
||||
}
|
||||
}
|
||||
return fullPageClip{}, false
|
||||
}
|
||||
|
||||
// CdpScreenshotBytes captura un screenshot de la pagina actual y devuelve los
|
||||
// bytes de imagen ya decodificados junto con su mimeType, sin tocar el disco.
|
||||
// Usa Page.captureScreenshot del protocolo CDP.
|
||||
// outputPath debe tener extension .png o .jpg/.jpeg segun el formato elegido.
|
||||
func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error {
|
||||
//
|
||||
// El mimeType es "image/jpeg" cuando opts pide JPEG y "image/png" en cualquier
|
||||
// otro caso (incluido el default cuando opts.Format esta vacio).
|
||||
//
|
||||
// Si opts.FullPage es true, consulta Page.getLayoutMetrics para construir un clip
|
||||
// que cubra la altura completa del documento (no solo el viewport) y mantiene
|
||||
// captureBeyondViewport=true para que Chrome renderice mas alla del area visible.
|
||||
//
|
||||
// Es la primitiva reutilizable de captura: util para devolver la imagen al LLM
|
||||
// como image content (bytes) sin pasar por archivo. CdpScreenshot compone sobre
|
||||
// ella para persistir a disco.
|
||||
func CdpScreenshotBytes(c *CDPConn, opts CdpScreenshotOpts) ([]byte, string, error) {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp screenshot: conexion nula")
|
||||
return nil, "", fmt.Errorf("cdp screenshot: conexion nula")
|
||||
}
|
||||
|
||||
if opts.Format == "" {
|
||||
@@ -32,8 +77,13 @@ func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error
|
||||
opts.Quality = 80
|
||||
}
|
||||
|
||||
mimeType := "image/png"
|
||||
if opts.Format == "jpeg" {
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"format": opts.Format,
|
||||
"format": opts.Format,
|
||||
"captureBeyondViewport": opts.FullPage,
|
||||
}
|
||||
if opts.Format == "jpeg" {
|
||||
@@ -41,27 +91,52 @@ func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error
|
||||
}
|
||||
|
||||
if opts.FullPage {
|
||||
// Expandir clip para capturar toda la pagina
|
||||
scrollHeight, err := CdpEvaluate(c, "document.documentElement.scrollHeight")
|
||||
if err == nil {
|
||||
params["clip"] = nil // dejar que Chrome capture todo
|
||||
_ = scrollHeight
|
||||
// Page.getLayoutMetrics da el tamano real del documento. Construimos el
|
||||
// clip con la funcion pura buildFullPageClip. Si la consulta falla o no
|
||||
// hay dimensiones validas, omitimos el clip y caemos a captura normal
|
||||
// (con captureBeyondViewport=true Chrome aun captura algo razonable).
|
||||
if metrics, err := c.sendCDP("Page.getLayoutMetrics", nil); err == nil {
|
||||
if clip, ok := buildFullPageClip(metrics); ok {
|
||||
params["clip"] = map[string]any{
|
||||
"x": clip.X,
|
||||
"y": clip.Y,
|
||||
"width": clip.Width,
|
||||
"height": clip.Height,
|
||||
"scale": clip.Scale,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result, err := c.sendCDP("Page.captureScreenshot", params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp screenshot: %w", err)
|
||||
return nil, "", fmt.Errorf("cdp screenshot: %w", err)
|
||||
}
|
||||
|
||||
dataStr, ok := result["data"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("cdp screenshot: campo data ausente en respuesta")
|
||||
return nil, "", fmt.Errorf("cdp screenshot: campo data ausente en respuesta")
|
||||
}
|
||||
|
||||
imgData, err := base64.StdEncoding.DecodeString(dataStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp screenshot: decodificar base64: %w", err)
|
||||
return nil, "", fmt.Errorf("cdp screenshot: decodificar base64: %w", err)
|
||||
}
|
||||
|
||||
return imgData, mimeType, nil
|
||||
}
|
||||
|
||||
// CdpScreenshot captura un screenshot de la pagina actual y lo guarda en outputPath.
|
||||
// outputPath debe tener extension .png o .jpg/.jpeg segun el formato elegido.
|
||||
//
|
||||
// Compone sobre CdpScreenshotBytes para obtener los bytes de imagen y luego crea
|
||||
// el directorio destino si no existe y escribe el archivo. Mismo comportamiento
|
||||
// observable que antes: mismos parametros, mismos efectos en disco, mismos
|
||||
// errores de captura.
|
||||
func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error {
|
||||
imgData, _, err := CdpScreenshotBytes(c, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Crear directorio si no existe
|
||||
|
||||
@@ -3,12 +3,12 @@ name: cdp_screenshot
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
version: "1.2.0"
|
||||
purity: impure
|
||||
signature: "func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error"
|
||||
description: "Captura un screenshot de la pagina actual via Page.captureScreenshot y lo guarda en el archivo indicado. Soporta PNG y JPEG, viewport o pagina completa. Crea el directorio destino si no existe."
|
||||
tags: [chrome, cdp, browser, automation, screenshot, devtools, png]
|
||||
uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
|
||||
description: "Captura un screenshot de la pagina actual via Page.captureScreenshot y lo guarda en el archivo indicado. Soporta PNG y JPEG, viewport o pagina completa. En modo FullPage usa Page.getLayoutMetrics (cssContentSize) para construir un clip que cubre la altura real del documento. Crea el directorio destino si no existe. Compone sobre CdpScreenshotBytes para la captura a memoria."
|
||||
tags: [chrome, cdp, browser, automation, screenshot, devtools, png, navegator]
|
||||
uses_functions: [cdp_screenshot_bytes_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -23,8 +23,8 @@ params:
|
||||
desc: "opciones de captura (FullPage, Quality, Format)"
|
||||
output: "error si falla la captura o la escritura del archivo"
|
||||
tested: true
|
||||
tests: ["TestCdpScreenshot"]
|
||||
test_file_path: "functions/browser/chrome_launch_test.go"
|
||||
tests: ["TestBuildFullPageClip", "TestCdpScreenshot"]
|
||||
test_file_path: "functions/browser/cdp_screenshot_test.go"
|
||||
file_path: "functions/browser/cdp_screenshot.go"
|
||||
---
|
||||
|
||||
@@ -40,6 +40,22 @@ err := CdpScreenshot(conn, "/tmp/page.png", CdpScreenshotOpts{
|
||||
})
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Para guardar evidencia visual de una página tras navegar o ejecutar acciones. Usa `FullPage: true` cuando necesites toda la altura del documento (capturas de auditoría, scraping visual de páginas largas); `false` (default) para capturar solo el viewport visible, más rápido.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **FullPage usa el tamaño real del documento**: consulta `Page.getLayoutMetrics` y construye el clip desde `cssContentSize` (CSS pixels). Si Chrome no devuelve dimensiones válidas, cae a captura normal con `captureBeyondViewport=true` en vez de fallar.
|
||||
- **Páginas con lazy-loading**: el `cssContentSize` refleja el DOM en el instante de la captura. Si la página carga contenido al hacer scroll, haz scroll + `CdpWaitIdle` antes para que la altura sea la final.
|
||||
- **Formato según extensión**: la función no infiere el formato de la extensión del `outputPath`; pásalo explícito en `opts.Format` ("png" o "jpeg"). El default es "png".
|
||||
- **JPEG quality**: solo aplica si `Format == "jpeg"`; el default es 80.
|
||||
|
||||
## Notas
|
||||
|
||||
El struct `CdpScreenshotOpts` tiene campos: `FullPage bool`, `Quality int` (JPEG), `Format string` ("png" o "jpeg"). Chrome retorna la imagen como base64 que se decodifica y escribe al disco.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.2.0 (2026-06-06) — refactor a composición: toda la lógica de captura (enable/clip FullPage/captureScreenshot/decode base64) se extrae a `CdpScreenshotBytes` (`cdp_screenshot_bytes_go_browser`), que devuelve bytes + mimeType en memoria. `CdpScreenshot` ahora compone sobre ella + crea el directorio + escribe el archivo. Firma pública y comportamiento observable intactos.
|
||||
- v1.1.0 (2026-06-06) — FullPage implementado de verdad: clip desde Page.getLayoutMetrics (cssContentSize) vía función pura `buildFullPageClip`, en vez del código muerto que calculaba scrollHeight y lo descartaba.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: cdp_screenshot_bytes
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpScreenshotBytes(c *CDPConn, opts CdpScreenshotOpts) ([]byte, string, error)"
|
||||
description: "Captura un screenshot de la pagina actual via Page.captureScreenshot y devuelve los bytes de imagen ya decodificados junto con su mimeType, sin tocar el disco. mimeType es image/jpeg si opts pide JPEG, si no image/png. Soporta viewport o pagina completa: en modo FullPage usa Page.getLayoutMetrics (cssContentSize) para construir un clip que cubre la altura real del documento. Primitiva reutilizable para devolver la imagen al LLM como image content."
|
||||
tags: [chrome, cdp, browser, automation, screenshot, devtools, png, image, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [encoding/base64, fmt]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexión CDP activa"
|
||||
- name: opts
|
||||
desc: "opciones de captura (FullPage, Quality, Format)"
|
||||
output: "bytes de imagen decodificados + mimeType (image/png o image/jpeg), o error si falla la captura"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_screenshot.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://example.com")
|
||||
|
||||
imgData, mimeType, err := CdpScreenshotBytes(conn, CdpScreenshotOpts{
|
||||
FullPage: true,
|
||||
Format: "png",
|
||||
})
|
||||
// imgData: bytes PNG listos para enviar al LLM como image content
|
||||
// mimeType: "image/png"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas la imagen capturada en memoria, no en disco: típicamente para devolverla al LLM como image content (bytes + mimeType) en un MCP o tool, sin pasar por un archivo temporal. Es la primitiva de captura sobre la que compone `CdpScreenshot` (que persiste a disco). Úsala directamente cuando el destino no es el filesystem.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: requiere Chrome vivo**: necesita una conexión CDP activa (`*CDPConn`) contra una instancia de Chrome con el target abierto. No funciona sin navegador.
|
||||
- **FullPage usa el tamaño real del documento**: consulta `Page.getLayoutMetrics` y construye el clip desde `cssContentSize` (CSS pixels). Si Chrome no devuelve dimensiones válidas, cae a captura normal con `captureBeyondViewport=true` en vez de fallar.
|
||||
- **mimeType según opts, no según extensión**: devuelve `"image/jpeg"` solo cuando `opts.Format == "jpeg"`; en cualquier otro caso (incluido el default con `Format` vacío) devuelve `"image/png"`. No hay archivo, así que no infiere nada de una extensión.
|
||||
- **JPEG quality**: solo aplica si `Format == "jpeg"`; el default es 80.
|
||||
- **Páginas con lazy-loading**: el `cssContentSize` refleja el DOM en el instante de la captura. Si la página carga contenido al hacer scroll, haz scroll + `CdpWaitIdle` antes para que la altura sea la final.
|
||||
|
||||
## Notas
|
||||
|
||||
Adición de `cdp_screenshot` (estilo ADR 0003): el `.go` vive junto a `cdp_screenshot.go` en el mismo paquete `browser`. El struct `CdpScreenshotOpts` (campos `FullPage bool`, `Quality int`, `Format string`) es compartido con `CdpScreenshot`. Chrome retorna la imagen como base64; esta función la decodifica a `[]byte` y la devuelve sin escribir a disco. `CdpScreenshot` compone sobre esta primitiva añadiendo creación de directorio + escritura del archivo.
|
||||
@@ -0,0 +1,76 @@
|
||||
package browser
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestBuildFullPageClip cubre el nucleo puro del modo FullPage: dado el mapa de
|
||||
// Page.getLayoutMetrics, construir el clip que cubre el documento entero. No
|
||||
// requiere Chrome.
|
||||
func TestBuildFullPageClip(t *testing.T) {
|
||||
t.Run("golden: pagina larga via cssContentSize", func(t *testing.T) {
|
||||
metrics := map[string]any{
|
||||
"cssContentSize": map[string]any{
|
||||
"x": 0.0, "y": 0.0, "width": 1280.0, "height": 8000.0,
|
||||
},
|
||||
}
|
||||
clip, ok := buildFullPageClip(metrics)
|
||||
if !ok {
|
||||
t.Fatal("esperaba ok=true para cssContentSize valido")
|
||||
}
|
||||
if clip.Width != 1280 || clip.Height != 8000 {
|
||||
t.Errorf("clip = %+v, esperaba width=1280 height=8000", clip)
|
||||
}
|
||||
if clip.X != 0 || clip.Y != 0 || clip.Scale != 1 {
|
||||
t.Errorf("clip = %+v, esperaba x=0 y=0 scale=1", clip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: viewport pequeno (pagina corta) sigue produciendo clip valido", func(t *testing.T) {
|
||||
metrics := map[string]any{
|
||||
"cssContentSize": map[string]any{"width": 320.0, "height": 480.0},
|
||||
}
|
||||
clip, ok := buildFullPageClip(metrics)
|
||||
if !ok {
|
||||
t.Fatal("esperaba ok=true para pagina corta")
|
||||
}
|
||||
if clip.Width != 320 || clip.Height != 480 {
|
||||
t.Errorf("clip = %+v, esperaba 320x480", clip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: fallback a contentSize cuando falta cssContentSize", func(t *testing.T) {
|
||||
metrics := map[string]any{
|
||||
"contentSize": map[string]any{"width": 1024.0, "height": 2048.0},
|
||||
}
|
||||
clip, ok := buildFullPageClip(metrics)
|
||||
if !ok {
|
||||
t.Fatal("esperaba ok=true via contentSize")
|
||||
}
|
||||
if clip.Width != 1024 || clip.Height != 2048 {
|
||||
t.Errorf("clip = %+v, esperaba 1024x2048", clip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error: dimensiones cero -> ok=false (captura solo viewport)", func(t *testing.T) {
|
||||
metrics := map[string]any{
|
||||
"cssContentSize": map[string]any{"width": 0.0, "height": 0.0},
|
||||
}
|
||||
if _, ok := buildFullPageClip(metrics); ok {
|
||||
t.Error("esperaba ok=false para dimensiones cero")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error: pagina vacia (metrics sin tamano) -> ok=false", func(t *testing.T) {
|
||||
if _, ok := buildFullPageClip(map[string]any{}); ok {
|
||||
t.Error("esperaba ok=false para metrics vacio")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error: width valido pero height cero -> ok=false", func(t *testing.T) {
|
||||
metrics := map[string]any{
|
||||
"cssContentSize": map[string]any{"width": 800.0, "height": 0.0},
|
||||
}
|
||||
if _, ok := buildFullPageClip(metrics); ok {
|
||||
t.Error("esperaba ok=false cuando un eje es cero")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,17 +10,84 @@ import (
|
||||
type CdpWaitIdleOpts struct {
|
||||
QuietMs int // ms que inflight debe permanecer <= MaxInflight (default 500)
|
||||
Timeout time.Duration // maximo total a esperar (default 8s)
|
||||
MaxInflight int // requests en vuelo tolerados para considerar idle (default 0)
|
||||
MaxInflight int // requests en vuelo tolerados para considerar idle (default 2)
|
||||
PollMs int // intervalo de chequeo en ms (default 100)
|
||||
}
|
||||
|
||||
// isPersistentResourceType indica si un Network resourceType corresponde a una
|
||||
// conexion de larga duracion que NO emite loadingFinished/loadingFailed y por
|
||||
// tanto colgaria el contador inflight para siempre. La pagina abre estas
|
||||
// conexiones (analytics en vivo, push, hot-reload) y nunca "terminan".
|
||||
func isPersistentResourceType(resourceType string) bool {
|
||||
switch resourceType {
|
||||
case "WebSocket", "EventSource":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// InflightTracker cuenta requests de red en vuelo de forma pura y testeable: no
|
||||
// toca red ni CDP, solo recibe eventos ya parseados (requestId + resourceType) y
|
||||
// mantiene el conjunto de requests activos. Trackea por requestId para que el
|
||||
// loadingFinished/loadingFailed de un request que nunca contamos (una conexion
|
||||
// persistente) sea un no-op en vez de un decremento espurio.
|
||||
//
|
||||
// Las conexiones persistentes (WebSocket, EventSource) se excluyen del conteo
|
||||
// porque no emiten un evento de finalizacion: contarlas haria que la red nunca
|
||||
// pareciera idle.
|
||||
type InflightTracker struct {
|
||||
mu sync.Mutex
|
||||
tracked map[string]bool
|
||||
}
|
||||
|
||||
// NewInflightTracker crea un tracker vacio listo para recibir eventos.
|
||||
func NewInflightTracker() *InflightTracker {
|
||||
return &InflightTracker{tracked: map[string]bool{}}
|
||||
}
|
||||
|
||||
// OnRequest registra el inicio de un request (Network.requestWillBeSent). Ignora
|
||||
// las conexiones persistentes para no contaminar el conteo.
|
||||
func (t *InflightTracker) OnRequest(requestID, resourceType string) {
|
||||
if isPersistentResourceType(resourceType) {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.tracked[requestID] = true
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// OnFinish marca un request como completado (Network.loadingFinished).
|
||||
func (t *InflightTracker) OnFinish(requestID string) { t.complete(requestID) }
|
||||
|
||||
// OnFail marca un request como fallido (Network.loadingFailed). A efectos de
|
||||
// inflight, fallar y terminar son lo mismo: el request ya no esta en vuelo.
|
||||
func (t *InflightTracker) OnFail(requestID string) { t.complete(requestID) }
|
||||
|
||||
func (t *InflightTracker) complete(requestID string) {
|
||||
t.mu.Lock()
|
||||
delete(t.tracked, requestID)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// Inflight retorna el numero de requests actualmente en vuelo.
|
||||
func (t *InflightTracker) Inflight() int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return len(t.tracked)
|
||||
}
|
||||
|
||||
// IsIdle indica si el numero de requests en vuelo esta dentro del umbral dado.
|
||||
func (t *InflightTracker) IsIdle(maxInflight int) bool {
|
||||
return t.Inflight() <= maxInflight
|
||||
}
|
||||
|
||||
// CdpWaitIdle espera a que la actividad de red de la pagina llegue a idle.
|
||||
// Suscribe eventos Network.requestWillBeSent / Network.loadingFinished /
|
||||
// Network.loadingFailed via el mecanismo OnEvent del CDPConn para mantener
|
||||
// un contador de requests en vuelo (inflight). Cuando inflight <= MaxInflight
|
||||
// de forma continuada durante QuietMs milisegundos, la funcion retorna nil.
|
||||
// Si se alcanza Timeout sin lograr esa ventana quieta, retorna error con el
|
||||
// inflight actual en el mensaje.
|
||||
// Network.loadingFailed via el mecanismo OnEvent del CDPConn y delega el conteo
|
||||
// en un InflightTracker. Cuando inflight <= MaxInflight de forma continuada
|
||||
// durante QuietMs milisegundos, la funcion retorna nil. Si se alcanza Timeout
|
||||
// sin lograr esa ventana quieta, retorna error con el inflight actual.
|
||||
//
|
||||
// Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones
|
||||
// JS, ya que la señal es red, no DOM.
|
||||
@@ -36,41 +103,36 @@ func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = 8 * time.Second
|
||||
}
|
||||
// MaxInflight 0 es el default semantico: queremos red completamente idle.
|
||||
// MaxInflight default 2: la web moderna mantiene 1-2 beacons/analytics de
|
||||
// fondo que casi nunca dejan inflight en 0; exigir 0 cuelga hasta el timeout.
|
||||
if opts.MaxInflight <= 0 {
|
||||
opts.MaxInflight = 2
|
||||
}
|
||||
if opts.PollMs <= 0 {
|
||||
opts.PollMs = 100
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
inflight int
|
||||
)
|
||||
tracker := NewInflightTracker()
|
||||
|
||||
// Suscribir eventos Network usando el mismo mecanismo que cdp_har_record:
|
||||
// c.OnEvent retorna una funcion cancel que des-registra el handler.
|
||||
// Multiples consumidores del mismo metodo son soportados (slice de handlers).
|
||||
cancel1 := c.OnEvent("Network.requestWillBeSent", func(_ string, p map[string]any) {
|
||||
mu.Lock()
|
||||
inflight++
|
||||
mu.Unlock()
|
||||
id, _ := p["requestId"].(string)
|
||||
typ, _ := p["type"].(string)
|
||||
tracker.OnRequest(id, typ)
|
||||
})
|
||||
defer cancel1()
|
||||
|
||||
cancel2 := c.OnEvent("Network.loadingFinished", func(_ string, p map[string]any) {
|
||||
mu.Lock()
|
||||
if inflight > 0 {
|
||||
inflight--
|
||||
}
|
||||
mu.Unlock()
|
||||
id, _ := p["requestId"].(string)
|
||||
tracker.OnFinish(id)
|
||||
})
|
||||
defer cancel2()
|
||||
|
||||
cancel3 := c.OnEvent("Network.loadingFailed", func(_ string, p map[string]any) {
|
||||
mu.Lock()
|
||||
if inflight > 0 {
|
||||
inflight--
|
||||
}
|
||||
mu.Unlock()
|
||||
id, _ := p["requestId"].(string)
|
||||
tracker.OnFail(id)
|
||||
})
|
||||
defer cancel3()
|
||||
|
||||
@@ -89,11 +151,7 @@ func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
|
||||
mu.Lock()
|
||||
current := inflight
|
||||
mu.Unlock()
|
||||
|
||||
if current <= opts.MaxInflight {
|
||||
if tracker.IsIdle(opts.MaxInflight) {
|
||||
// Red idle: iniciar o mantener la ventana de quietud.
|
||||
if quietSince.IsZero() {
|
||||
quietSince = time.Now()
|
||||
@@ -107,8 +165,5 @@ func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
current := inflight
|
||||
mu.Unlock()
|
||||
return fmt.Errorf("cdp wait idle: red no alcanzo idle despues de %s (inflight=%d)", opts.Timeout, current)
|
||||
return fmt.Errorf("cdp wait idle: red no alcanzo idle despues de %s (inflight=%d)", opts.Timeout, tracker.Inflight())
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ name: cdp_wait_idle
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
version: "1.2.0"
|
||||
purity: impure
|
||||
signature: "func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error"
|
||||
description: "Espera a que la actividad de red de la pagina llegue a idle usando eventos CDP Network.*. Lleva un contador de requests en vuelo (inflight): +1 en requestWillBeSent, -1 en loadingFinished/loadingFailed. Cuando inflight <= MaxInflight de forma continuada durante QuietMs ms, retorna nil. Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones JS. Si se alcanza Timeout sin lograr la ventana quieta, retorna error con el inflight actual."
|
||||
description: "Espera a que la actividad de red de la pagina llegue a idle usando eventos CDP Network.*. Lleva un contador de requests en vuelo (inflight) via InflightTracker: trackea por requestId, excluye conexiones persistentes (WebSocket, EventSource) que nunca terminan. Cuando inflight <= MaxInflight (default 2) de forma continuada durante QuietMs ms, retorna nil. Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones JS. Si se alcanza Timeout sin lograr la ventana quieta, retorna error con el inflight actual."
|
||||
tags: [cdp, chrome, browser, wait, spa, network, idle, polling, hydration, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
@@ -18,14 +18,12 @@ params:
|
||||
- name: c
|
||||
desc: "conexion CDP activa (obtenida con CdpConnect)"
|
||||
- name: opts
|
||||
desc: "opciones de espera: QuietMs ms de red quieta (default 500), Timeout maximo total (default 8s), MaxInflight requests en vuelo tolerados para considerar idle (default 0), PollMs intervalo de chequeo (default 100). Campos a 0 usan el default."
|
||||
desc: "opciones de espera: QuietMs ms de red quieta (default 500), Timeout maximo total (default 8s), MaxInflight requests en vuelo tolerados para considerar idle (default 2), PollMs intervalo de chequeo (default 100). Campos a 0 usan el default."
|
||||
output: "nil si la red llega a idle dentro del timeout; error descriptivo con inflight actual si se agota el tiempo o la conexion falla"
|
||||
tested: true
|
||||
tests:
|
||||
- "conexion nula retorna error inmediato"
|
||||
- "opts con ceros aplica defaults antes de usar"
|
||||
- "error de conexion nula contiene texto descriptivo"
|
||||
- "mensaje de error nil-conn menciona cdp wait idle"
|
||||
- "TestCdpWaitIdleDefaults"
|
||||
- "TestInflightTracker"
|
||||
test_file_path: "functions/browser/cdp_wait_idle_test.go"
|
||||
file_path: "functions/browser/cdp_wait_idle.go"
|
||||
---
|
||||
@@ -64,12 +62,14 @@ La funcion suscribe `Network.requestWillBeSent`, `Network.loadingFinished` y `Ne
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Paginas con polling persistente o WebSockets**: si la pagina lanza un request periodico (ej. SSE, long-poll cada 30 s), inflight puede no llegar a 0 durante `QuietMs`. Solucionar con `MaxInflight: 1` para tolerar ese request de fondo, o reducir `QuietMs` (ej. 200 ms) para capturar la ventana entre polls.
|
||||
- **Timeout corto por defecto (8 s)**: es deliberado. Para paginas de polling persistente donde inflight nunca llega a 0, un timeout largo solo bloquea. Preferir `MaxInflight > 0` o `Timeout` mas largo explicitamente.
|
||||
- **Error incluye inflight actual**: el mensaje de timeout incluye `inflight=N` para facilitar diagnostico (saber cuantos requests quedaron colgados).
|
||||
- **Network.enable/disable**: la funcion habilita el dominio Network al entrar y lo deshabilita al salir via defer. Si otra funcion en la misma conexion (ej. `cdp_har_record`) ya lo tiene habilitado, el disable al salir lo desactivara para todos. Usar `MaxInflight` y `Timeout` razonables y no interleave con `cdp_har_record` en la misma conexion salvo que el orden de cierre sea controlado.
|
||||
- **Test e2e real**: los tests del paquete no requieren Chrome. Para pruebas reales, lanzar Chrome con `--remote-debugging-port=9222`, navegar a la pagina objetivo y llamar esta funcion tras `CdpWaitLoad`.
|
||||
- **MaxInflight default = 2**: la web moderna mantiene 1-2 beacons/analytics de fondo que rara vez dejan inflight en 0. El zero-value de `MaxInflight` (0) se reescribe a 2 para no colgar hasta el timeout. Para exigir idle absoluto en una página simple, no hay valor de "0 explícito" (0 == default); usa una página sin analytics o asume el umbral 2.
|
||||
- **WebSocket / EventSource excluidos del conteo**: estas conexiones persistentes no emiten `loadingFinished`, así que contarlas dejaría inflight clavado para siempre. El `InflightTracker` las ignora en `requestWillBeSent` (por `params.type`). Un stream WS/SSE abierto ya NO impide llegar a idle.
|
||||
- **Polling/long-poll periódico**: si la página lanza un XHR cada N segundos, inflight oscila; con `MaxInflight: 2` (default) suele tolerarse. Si no, reduce `QuietMs` (ej. 200 ms) para capturar la ventana entre polls.
|
||||
- **Error incluye inflight actual**: el mensaje de timeout incluye `inflight=N` para diagnóstico.
|
||||
- **Network.enable/disable**: la función habilita Network al entrar y lo deshabilita al salir via defer. No interleave con `cdp_har_record` en la misma conexión salvo orden de cierre controlado.
|
||||
- **Tests sin Chrome**: el núcleo (`InflightTracker`) se testea con secuencias de eventos sintéticas. El bucle de polling con timeout real requiere Chrome y no está simulado.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.2.0 (2026-06-06) — refactor a `InflightTracker` puro (testeable sin red); default MaxInflight 0→2 (analytics ya no cuelga); excluye WebSocket/EventSource del conteo (no terminan); tracking por requestId (finish de request no contado = no-op).
|
||||
- v1.1.0 (2026-06-05) — cambia señal DOM-length → network-idle via eventos CDP Network.*; añade MaxInflight configurable; defaults mas ajustados (QuietMs 800→500, Timeout 15s→8s, PollMs 200→100).
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestCdpWaitIdleDefaults(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error de conexion nula contiene texto descriptivo", func(t *testing.T) {
|
||||
t.Run("mensaje de error nil-conn menciona cdp wait idle", func(t *testing.T) {
|
||||
err := CdpWaitIdle(nil, CdpWaitIdleOpts{
|
||||
QuietMs: 100,
|
||||
Timeout: 500 * time.Millisecond,
|
||||
@@ -34,19 +34,89 @@ func TestCdpWaitIdleDefaults(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("esperaba error, got nil")
|
||||
}
|
||||
msg := err.Error()
|
||||
if len(msg) == 0 {
|
||||
t.Error("el mensaje de error no debe estar vacio")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mensaje de error nil-conn menciona cdp wait idle", func(t *testing.T) {
|
||||
err := CdpWaitIdle(nil, CdpWaitIdleOpts{})
|
||||
if err == nil {
|
||||
t.Fatal("esperaba error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cdp wait idle") {
|
||||
t.Errorf("mensaje de error %q no contiene 'cdp wait idle'", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestInflightTracker cubre el nucleo puro del contador de red. No requiere Chrome:
|
||||
// alimenta secuencias de eventos {requestId, resourceType} y verifica el conteo.
|
||||
func TestInflightTracker(t *testing.T) {
|
||||
t.Run("golden: carga normal llega a idle", func(t *testing.T) {
|
||||
tr := NewInflightTracker()
|
||||
tr.OnRequest("r1", "Document")
|
||||
tr.OnRequest("r2", "Script")
|
||||
tr.OnRequest("r3", "Image")
|
||||
if got := tr.Inflight(); got != 3 {
|
||||
t.Fatalf("inflight tras 3 requests = %d, esperaba 3", got)
|
||||
}
|
||||
tr.OnFinish("r1")
|
||||
tr.OnFinish("r2")
|
||||
tr.OnFail("r3") // un recurso que falla tambien deja de estar en vuelo
|
||||
if got := tr.Inflight(); got != 0 {
|
||||
t.Fatalf("inflight tras completar todo = %d, esperaba 0", got)
|
||||
}
|
||||
if !tr.IsIdle(0) {
|
||||
t.Error("esperaba IsIdle(0)=true con inflight=0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: analytics residual idle ok con MaxInflight=2", func(t *testing.T) {
|
||||
tr := NewInflightTracker()
|
||||
// La pagina cargo, pero 2 beacons de analytics quedan sin finalizar.
|
||||
tr.OnRequest("doc", "Document")
|
||||
tr.OnFinish("doc")
|
||||
tr.OnRequest("beacon1", "Ping")
|
||||
tr.OnRequest("beacon2", "XHR")
|
||||
if got := tr.Inflight(); got != 2 {
|
||||
t.Fatalf("inflight = %d, esperaba 2 (beacons residuales)", got)
|
||||
}
|
||||
if tr.IsIdle(0) {
|
||||
t.Error("con MaxInflight=0 NO deberia ser idle (2 beacons en vuelo)")
|
||||
}
|
||||
if !tr.IsIdle(2) {
|
||||
t.Error("con MaxInflight=2 (default) SI deberia ser idle")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error/regresion: WebSocket abierto NO impide idle", func(t *testing.T) {
|
||||
tr := NewInflightTracker()
|
||||
tr.OnRequest("doc", "Document")
|
||||
tr.OnFinish("doc")
|
||||
// Un stream WebSocket se abre y nunca emite loadingFinished.
|
||||
tr.OnRequest("ws1", "WebSocket")
|
||||
// Un EventSource (SSE) tampoco termina.
|
||||
tr.OnRequest("sse1", "EventSource")
|
||||
if got := tr.Inflight(); got != 0 {
|
||||
t.Fatalf("inflight = %d, esperaba 0 (WS/SSE excluidos)", got)
|
||||
}
|
||||
if !tr.IsIdle(0) {
|
||||
t.Error("con WS+SSE abiertos pero excluidos, deberia ser idle absoluto")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: finish de request no trackeado es no-op (no va negativo)", func(t *testing.T) {
|
||||
tr := NewInflightTracker()
|
||||
// loadingFinished de un requestId que nunca contamos (p.ej. el handshake
|
||||
// de un WebSocket excluido) no debe romper el conteo.
|
||||
tr.OnFinish("desconocido")
|
||||
tr.OnFail("ws-handshake")
|
||||
if got := tr.Inflight(); got != 0 {
|
||||
t.Fatalf("inflight = %d, esperaba 0 (no negativo)", got)
|
||||
}
|
||||
tr.OnRequest("r1", "Fetch")
|
||||
if got := tr.Inflight(); got != 1 {
|
||||
t.Fatalf("inflight tras un request real = %d, esperaba 1", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: requestId duplicado no infla el conteo", func(t *testing.T) {
|
||||
tr := NewInflightTracker()
|
||||
tr.OnRequest("r1", "Fetch")
|
||||
tr.OnRequest("r1", "Fetch") // mismo id (redirect re-emite)
|
||||
if got := tr.Inflight(); got != 1 {
|
||||
t.Fatalf("inflight = %d, esperaba 1 (id deduplicado)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user