feat: funciones Chrome CDP para automatización de navegador

10 funciones Go en infra/ para controlar Chrome via Chrome DevTools Protocol:
chrome_launch, cdp_connect, cdp_navigate, cdp_evaluate, cdp_screenshot,
cdp_click, cdp_type_text, cdp_wait_element, cdp_get_html, cdp_close.
WebSocket RFC 6455 implementado sin dependencias externas.
Incluye tests de integración con Chrome real.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 17:30:56 +02:00
parent f748256c1d
commit add09c2faa
22 changed files with 1541 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
package infra
import (
"fmt"
"strconv"
"strings"
)
// CdpClick hace click en el primer elemento que coincide con el selector CSS.
// Obtiene las coordenadas del elemento via Runtime.evaluate y luego despacha
// eventos mousedown, mouseup y click via Input.dispatchMouseEvent.
func CdpClick(c *CDPConn, selector string) error {
if c == nil {
return fmt.Errorf("cdp click: conexion nula")
}
// Obtener coordenadas del centro del elemento
js := fmt.Sprintf(`(function() {
var el = document.querySelector(%q);
if (!el) return null;
var r = el.getBoundingClientRect();
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
})()`, selector)
coordStr, err := CdpEvaluate(c, js)
if err != nil {
return fmt.Errorf("cdp click: obtener coordenadas de %q: %w", selector, err)
}
if coordStr == "" || coordStr == "null" {
return fmt.Errorf("cdp click: elemento %q no encontrado en el DOM", selector)
}
// Parsear "{x:...,y:...}" — CdpEvaluate ya retorna el JSON como string
coordStr = strings.Trim(coordStr, `"`)
x, y, err := parseCoords(coordStr)
if err != nil {
return fmt.Errorf("cdp click: parsear coordenadas %q: %w", coordStr, err)
}
// Hacer scroll al elemento para que este visible
scrollJS := fmt.Sprintf(`document.querySelector(%q).scrollIntoView({block:'center'})`, selector)
if _, err := CdpEvaluate(c, scrollJS); err != nil {
// No es fatal si el scroll falla
_ = err
}
// Despachar mousedown
mouseParams := map[string]any{
"type": "mousePressed",
"x": x,
"y": y,
"button": "left",
"clickCount": 1,
}
if _, err := c.sendCDP("Input.dispatchMouseEvent", mouseParams); err != nil {
return fmt.Errorf("cdp click: mousedown: %w", err)
}
// Despachar mouseup
mouseParams["type"] = "mouseReleased"
if _, err := c.sendCDP("Input.dispatchMouseEvent", mouseParams); err != nil {
return fmt.Errorf("cdp click: mouseup: %w", err)
}
return nil
}
// parseCoords extrae x e y de un string JSON como {"x":100,"y":200}.
func parseCoords(s string) (float64, float64, error) {
// Buscar valores x e y manualmente para evitar dependencia de encoding/json
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "{")
s = strings.TrimSuffix(s, "}")
var x, y float64
for part := range strings.SplitSeq(s, ",") {
kv := strings.SplitN(strings.TrimSpace(part), ":", 2)
if len(kv) != 2 {
continue
}
k := strings.Trim(strings.TrimSpace(kv[0]), `"`)
v, err := strconv.ParseFloat(strings.TrimSpace(kv[1]), 64)
if err != nil {
return 0, 0, fmt.Errorf("parsear valor %q: %w", kv[1], err)
}
switch k {
case "x":
x = v
case "y":
y = v
}
}
return x, y, nil
}
+42
View File
@@ -0,0 +1,42 @@
---
name: cdp_click
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func CdpClick(c *CDPConn, selector string) error"
description: "Hace click en el primer elemento que coincide con el selector CSS. Obtiene coordenadas del centro via getBoundingClientRect, hace scroll al elemento y despacha eventos mousedown+mouseup via Input.dispatchMouseEvent."
tags: [chrome, cdp, browser, automation, click, dom, devtools]
uses_functions: [cdp_connect_go_infra, cdp_evaluate_go_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, strconv, strings]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/cdp_click.go"
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
CdpNavigate(conn, "https://example.com")
// Click en el primer enlace
if err := CdpClick(conn, "a"); err != nil {
log.Fatal(err)
}
// Click en boton por ID
if err := CdpClick(conn, "#submit-btn"); err != nil {
log.Fatal(err)
}
```
## Notas
El selector sigue la sintaxis CSS estandar (IDs, clases, atributos, pseudo-selectores). El elemento debe ser visible en el DOM en el momento del click. Si no se encuentra, retorna error inmediatamente sin esperar — combinar con `CdpWaitElement` para elementos dinamicos.
+37
View File
@@ -0,0 +1,37 @@
package infra
import (
"fmt"
"os"
)
// CdpClose cierra la conexion WebSocket CDP y, si pid > 0, mata el proceso Chrome.
// Siempre intenta cerrar la conexion aunque el kill falle, y viceversa.
// Retorna el primer error encontrado.
func CdpClose(c *CDPConn, pid int) error {
var firstErr error
if c != nil && !c.closed {
c.closed = true
if err := c.conn.Close(); err != nil {
firstErr = fmt.Errorf("cdp close: cerrar websocket: %w", err)
}
}
if pid > 0 {
proc, err := os.FindProcess(pid)
if err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("cdp close: encontrar proceso %d: %w", pid, err)
}
} else {
if err := proc.Kill(); err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("cdp close: matar proceso %d: %w", pid, err)
}
}
}
}
return firstErr
}
+38
View File
@@ -0,0 +1,38 @@
---
name: cdp_close
kind: function
lang: go
domain: infra
version: "1.0.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. 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]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/cdp_close.go"
---
## Ejemplo
```go
pid, _ := ChromeLaunch(ChromeLaunchOpts{Port: 9222, Headless: true})
conn, _ := CdpConnect(9222)
defer CdpClose(conn, pid) // cierra WebSocket y mata Chrome
// O por separado:
defer CdpClose(conn, 0) // solo cierra WebSocket
defer CdpClose(nil, pid) // solo mata Chrome
```
## Notas
Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso son invalidos, el error retornado corresponde al primero que fallo. Marca `c.closed = true` para evitar doble cierre.
+302
View File
@@ -0,0 +1,302 @@
package infra
import (
"bufio"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
)
// CDPConn es una conexion activa al Chrome DevTools Protocol.
// Gestiona el WebSocket raw y el protocolo JSON-RPC de CDP.
type CDPConn struct {
conn net.Conn
reader *bufio.Reader
mu sync.Mutex
nextID atomic.Int64
port int
pid int
pending map[int64]chan cdpResponse
pendMu sync.Mutex
closed bool
}
type cdpRequest struct {
ID int64 `json:"id"`
Method string `json:"method"`
Params map[string]any `json:"params,omitempty"`
}
type cdpResponse struct {
ID int64 `json:"id"`
Result map[string]any `json:"result"`
Error *cdpError `json:"error"`
Method string `json:"method"` // para eventos
Params map[string]any `json:"params"` // para eventos
}
type cdpError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// cdpVersionResponse es la respuesta de /json/version del endpoint CDP.
type cdpVersionResponse struct {
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
Browser string `json:"Browser"`
}
// wsHandshake realiza el handshake WebSocket RFC 6455 sobre una conexion TCP ya abierta.
func wsHandshake(conn net.Conn, host, path string) (*bufio.Reader, error) {
// Generar clave aleatoria de 16 bytes en base64
keyBytes := make([]byte, 16)
if _, err := rand.Read(keyBytes); err != nil {
return nil, fmt.Errorf("ws handshake: generar clave: %w", err)
}
key := base64.StdEncoding.EncodeToString(keyBytes)
// Enviar request HTTP upgrade
req := fmt.Sprintf(
"GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n",
path, host, key,
)
if _, err := fmt.Fprint(conn, req); err != nil {
return nil, fmt.Errorf("ws handshake: enviar upgrade: %w", err)
}
// Leer respuesta HTTP
reader := bufio.NewReaderSize(conn, 65536)
status, err := reader.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("ws handshake: leer status: %w", err)
}
if !strings.Contains(status, "101") {
return nil, fmt.Errorf("ws handshake: status inesperado: %s", strings.TrimSpace(status))
}
// Consumir headers hasta linea vacia
var acceptKey string
for {
line, err := reader.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("ws handshake: leer headers: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if strings.HasPrefix(strings.ToLower(line), "sec-websocket-accept:") {
acceptKey = strings.TrimSpace(line[len("sec-websocket-accept:"):])
}
}
// Verificar Sec-WebSocket-Accept
magic := "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
h := sha1.New()
h.Write([]byte(key + magic))
expected := base64.StdEncoding.EncodeToString(h.Sum(nil))
if acceptKey != expected {
return nil, fmt.Errorf("ws handshake: accept key invalida: got %q, want %q", acceptKey, expected)
}
return reader, nil
}
// wsReadMessage lee un frame WebSocket y retorna el payload.
// Solo soporta frames de texto/binario no fragmentados (suficiente para CDP).
func wsReadMessage(reader *bufio.Reader) ([]byte, error) {
// Leer primeros 2 bytes del frame
header := make([]byte, 2)
if _, err := io.ReadFull(reader, header); err != nil {
return nil, fmt.Errorf("ws read: header: %w", err)
}
// fin := (header[0] & 0x80) != 0 // ignoramos fragmentacion
opcode := header[0] & 0x0F
masked := (header[1] & 0x80) != 0
payloadLen := int64(header[1] & 0x7F)
if opcode == 8 {
return nil, fmt.Errorf("ws read: connection close frame")
}
// Leer longitud extendida
switch payloadLen {
case 126:
var ext uint16
if err := binary.Read(reader, binary.BigEndian, &ext); err != nil {
return nil, fmt.Errorf("ws read: extended len 16: %w", err)
}
payloadLen = int64(ext)
case 127:
var ext uint64
if err := binary.Read(reader, binary.BigEndian, &ext); err != nil {
return nil, fmt.Errorf("ws read: extended len 64: %w", err)
}
payloadLen = int64(ext)
}
// Leer mascara si aplica (servidor->cliente normalmente no tiene mascara)
var mask [4]byte
if masked {
if _, err := io.ReadFull(reader, mask[:]); err != nil {
return nil, fmt.Errorf("ws read: mask: %w", err)
}
}
// Leer payload
payload := make([]byte, payloadLen)
if _, err := io.ReadFull(reader, payload); err != nil {
return nil, fmt.Errorf("ws read: payload: %w", err)
}
// Aplicar mascara si hay
if masked {
for i := range payload {
payload[i] ^= mask[i%4]
}
}
return payload, nil
}
// wsWriteMessage escribe un frame WebSocket enmascarado (cliente->servidor requiere mascara).
func wsWriteMessage(conn net.Conn, data []byte) error {
// Generar mascara aleatoria
var mask [4]byte
if _, err := rand.Read(mask[:]); err != nil {
return fmt.Errorf("ws write: generar mascara: %w", err)
}
// Construir frame
payloadLen := len(data)
var header []byte
header = append(header, 0x81) // FIN + opcode text
if payloadLen < 126 {
header = append(header, byte(payloadLen)|0x80) // masked
} else if payloadLen < 65536 {
header = append(header, 126|0x80)
header = append(header, byte(payloadLen>>8), byte(payloadLen))
} else {
header = append(header, 127|0x80)
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(payloadLen))
header = append(header, b...)
}
header = append(header, mask[:]...)
// Aplicar mascara al payload
masked := make([]byte, payloadLen)
for i, b := range data {
masked[i] = b ^ mask[i%4]
}
frame := append(header, masked...)
if _, err := conn.Write(frame); err != nil {
return fmt.Errorf("ws write: %w", err)
}
return nil
}
// sendCDP envia un comando CDP y espera la respuesta con el mismo ID.
func (c *CDPConn) sendCDP(method string, params map[string]any) (map[string]any, error) {
id := c.nextID.Add(1)
req := cdpRequest{ID: id, Method: method, Params: params}
data, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("cdp send: marshal: %w", err)
}
// Registrar canal para la respuesta
ch := make(chan cdpResponse, 1)
c.pendMu.Lock()
c.pending[id] = ch
c.pendMu.Unlock()
// Enviar frame WebSocket
c.mu.Lock()
err = wsWriteMessage(c.conn, data)
c.mu.Unlock()
if err != nil {
c.pendMu.Lock()
delete(c.pending, id)
c.pendMu.Unlock()
return nil, fmt.Errorf("cdp send %s: %w", method, err)
}
// Esperar respuesta
resp := <-ch
if resp.Error != nil {
return nil, fmt.Errorf("cdp %s: error %d: %s", method, resp.Error.Code, resp.Error.Message)
}
return resp.Result, nil
}
// readLoop lee mensajes del WebSocket y los enruta a los canales pendientes.
// Debe ejecutarse en una goroutine.
func (c *CDPConn) readLoop() {
for {
data, err := wsReadMessage(c.reader)
if err != nil {
// Conexion cerrada o error — notificar a todos los pendientes
c.pendMu.Lock()
for _, ch := range c.pending {
ch <- cdpResponse{Error: &cdpError{Message: err.Error()}}
}
c.pending = map[int64]chan cdpResponse{}
c.pendMu.Unlock()
return
}
var resp cdpResponse
if err := json.Unmarshal(data, &resp); err != nil {
continue
}
// Si tiene ID, es respuesta a un comando
if resp.ID > 0 {
c.pendMu.Lock()
ch, ok := c.pending[resp.ID]
if ok {
delete(c.pending, resp.ID)
}
c.pendMu.Unlock()
if ok {
ch <- resp
}
}
// Si no tiene ID, es un evento CDP — por ahora los ignoramos
// Las funciones que necesiten eventos usan polling o envian el comando y esperan
}
}
// cdpGetWSURL obtiene el webSocketDebuggerUrl del endpoint HTTP de CDP.
func cdpGetWSURL(port int) (string, error) {
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/json/version", port))
if err != nil {
return "", fmt.Errorf("cdp version: %w", err)
}
defer resp.Body.Close()
var info cdpVersionResponse
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return "", fmt.Errorf("cdp version: decode: %w", err)
}
if info.WebSocketDebuggerURL == "" {
return "", fmt.Errorf("cdp version: webSocketDebuggerUrl vacio")
}
return info.WebSocketDebuggerURL, nil
}
+105
View File
@@ -0,0 +1,105 @@
package infra
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
// cdpTarget representa un target CDP del endpoint /json.
type cdpTarget struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
URL string `json:"url"`
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
// cdpGetPageWSURL obtiene el webSocketDebuggerUrl de la primera tab de tipo "page"
// via el endpoint /json. Si no hay ninguna, crea una nueva con /json/new.
func cdpGetPageWSURL(port int) (string, error) {
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/json", port))
if err != nil {
return "", fmt.Errorf("cdp targets: %w", err)
}
defer resp.Body.Close()
var targets []cdpTarget
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
return "", fmt.Errorf("cdp targets: decode: %w", err)
}
// Buscar la primera tab de tipo "page"
for _, t := range targets {
if t.Type == "page" && t.WebSocketDebuggerURL != "" {
return t.WebSocketDebuggerURL, nil
}
}
// No hay tabs — crear una nueva via /json/new
newResp, err := http.Get(fmt.Sprintf("http://localhost:%d/json/new", port))
if err != nil {
return "", fmt.Errorf("cdp new tab: %w", err)
}
defer newResp.Body.Close()
var newTarget cdpTarget
if err := json.NewDecoder(newResp.Body).Decode(&newTarget); err != nil {
return "", fmt.Errorf("cdp new tab: decode: %w", err)
}
if newTarget.WebSocketDebuggerURL == "" {
return "", fmt.Errorf("cdp new tab: webSocketDebuggerUrl vacio")
}
return newTarget.WebSocketDebuggerURL, nil
}
// CdpConnect se conecta al endpoint CDP en localhost:{port} y retorna una CDPConn lista para usar.
// Conecta a la primera tab de tipo "page" (que soporta Page.*, Runtime.*, Input.*).
// Si no hay tabs disponibles, crea una nueva via /json/new.
// Realiza el handshake WebSocket RFC 6455 sobre TCP puro (sin dependencias externas).
func CdpConnect(port int) (*CDPConn, error) {
wsURL, err := cdpGetPageWSURL(port)
if err != nil {
return nil, fmt.Errorf("cdp connect: %w", err)
}
// Parsear la URL del WebSocket para extraer host y path
u, err := url.Parse(wsURL)
if err != nil {
return nil, fmt.Errorf("cdp connect: parsear ws url %q: %w", wsURL, err)
}
host := u.Host
if !strings.Contains(host, ":") {
host = host + ":80"
}
// Abrir conexion TCP
tcpConn, err := net.Dial("tcp", host)
if err != nil {
return nil, fmt.Errorf("cdp connect: tcp dial %s: %w", host, err)
}
// Realizar handshake WebSocket
path := u.RequestURI()
reader, err := wsHandshake(tcpConn, host, path)
if err != nil {
tcpConn.Close()
return nil, fmt.Errorf("cdp connect: ws handshake: %w", err)
}
c := &CDPConn{
conn: tcpConn,
reader: reader,
port: port,
pending: make(map[int64]chan cdpResponse),
}
// Iniciar goroutine de lectura
go c.readLoop()
return c, nil
}
+39
View File
@@ -0,0 +1,39 @@
---
name: cdp_connect
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func CdpConnect(port int) (*CDPConn, error)"
description: "Se conecta al endpoint CDP en localhost:{port}. Obtiene el webSocketDebuggerUrl via HTTP /json/version, realiza el handshake WebSocket RFC 6455 sobre TCP puro (sin dependencias externas) y retorna una CDPConn lista para usar. Inicia goroutine de lectura de mensajes."
tags: [chrome, cdp, browser, automation, websocket, devtools]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, net, net/url, strings]
tested: true
tests: ["TestChromeLaunchAndConnect"]
test_file_path: "functions/infra/chrome_launch_test.go"
file_path: "functions/infra/cdp_connect.go"
---
## Ejemplo
```go
conn, err := CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer CdpClose(conn, 0)
```
## Notas
El WebSocket se implementa desde cero usando `net.Dial` + handshake HTTP Upgrade + framing RFC 6455. Esto evita dependencias externas. Solo soporta frames no fragmentados (suficiente para CDP que envia mensajes completos).
El struct `CDPConn` y toda la infraestructura WebSocket/CDP se define en `cdp_conn.go` dentro del mismo paquete `infra`.
La goroutine `readLoop` enruta respuestas a channels por ID de mensaje. Los eventos CDP sin ID son ignorados (las funciones que necesitan eventos usan polling).
+48
View File
@@ -0,0 +1,48 @@
package infra
import (
"fmt"
)
// CdpEvaluate ejecuta una expresion JavaScript en la pagina actual via Runtime.evaluate.
// Retorna el resultado como string. Soporta expresiones simples y complejas.
// Para valores no-string, Chrome los serializa a JSON.
func CdpEvaluate(c *CDPConn, expression string) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp evaluate: conexion nula")
}
params := map[string]any{
"expression": expression,
"returnByValue": true,
"awaitPromise": true,
"userGesture": true,
}
result, err := c.sendCDP("Runtime.evaluate", params)
if err != nil {
return "", fmt.Errorf("cdp evaluate: %w", err)
}
// Verificar excepcion JS
if exc, ok := result["exceptionDetails"]; ok && exc != nil {
excMap, _ := exc.(map[string]any)
text, _ := excMap["text"].(string)
return "", fmt.Errorf("cdp evaluate: excepcion JS: %s", text)
}
// Extraer valor del resultado
resVal, ok := result["result"].(map[string]any)
if !ok {
return "", fmt.Errorf("cdp evaluate: resultado inesperado: %v", result)
}
value, ok := resVal["value"]
if !ok {
// Puede ser undefined o un tipo no serializable
typ, _ := resVal["type"].(string)
return typ, nil
}
return fmt.Sprintf("%v", value), nil
}
+36
View File
@@ -0,0 +1,36 @@
---
name: cdp_evaluate
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func CdpEvaluate(c *CDPConn, expression string) (string, error)"
description: "Ejecuta una expresion JavaScript arbitraria en la pagina actual via Runtime.evaluate. Retorna el resultado serializado como string. Soporta await (awaitPromise=true). Reporta excepciones JS como error."
tags: [chrome, cdp, browser, automation, javascript, devtools]
uses_functions: [cdp_connect_go_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: true
tests: ["TestCdpEvaluate"]
test_file_path: "functions/infra/chrome_launch_test.go"
file_path: "functions/infra/cdp_evaluate.go"
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
result, err := CdpEvaluate(conn, "document.title")
// result = "My Page Title"
sum, err := CdpEvaluate(conn, "1 + 2")
// sum = "3"
```
## Notas
Los valores no-string se convierten con `fmt.Sprintf("%v", value)`. Numeros aparecen sin decimales si son enteros (ej: `"3"` no `"3.0"`). Para tipos complejos (objetos, arrays), el resultado es la representacion Go de la interfaz, no JSON — usar `JSON.stringify(...)` en la expresion JS para obtener JSON limpio.
+20
View File
@@ -0,0 +1,20 @@
package infra
import (
"fmt"
)
// CdpGetHTML retorna el HTML completo de la pagina actual (document.documentElement.outerHTML).
// Equivale a hacer "Ver codigo fuente" pero con el DOM actual (post-JavaScript).
func CdpGetHTML(c *CDPConn) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get html: conexion nula")
}
html, err := CdpEvaluate(c, "document.documentElement.outerHTML")
if err != nil {
return "", fmt.Errorf("cdp get html: %w", err)
}
return html, nil
}
+36
View File
@@ -0,0 +1,36 @@
---
name: cdp_get_html
kind: function
lang: go
domain: infra
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]
uses_functions: [cdp_connect_go_infra, cdp_evaluate_go_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: true
tests: ["TestCdpGetHTML"]
test_file_path: "functions/infra/chrome_launch_test.go"
file_path: "functions/infra/cdp_get_html.go"
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
CdpNavigate(conn, "https://example.com")
CdpWaitElement(conn, "body", 5*time.Second)
html, err := CdpGetHTML(conn)
// html contiene el DOM completo con todos los cambios JS aplicados
```
## 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.
+30
View File
@@ -0,0 +1,30 @@
package infra
import (
"fmt"
)
// CdpNavigate navega a la URL indicada usando Page.navigate.
// Espera a que la carga este confirmada via Page.loadEventFired antes de retornar.
// El timeout de la navegacion es gestionado por Chrome internamente.
func CdpNavigate(c *CDPConn, targetURL string) error {
if c == nil {
return fmt.Errorf("cdp navigate: conexion nula")
}
params := map[string]any{
"url": targetURL,
}
result, err := c.sendCDP("Page.navigate", params)
if err != nil {
return fmt.Errorf("cdp navigate: %w", err)
}
// Verificar que no hubo error de navegacion
if errText, ok := result["errorText"].(string); ok && errText != "" {
return fmt.Errorf("cdp navigate: error navegando a %q: %s", targetURL, errText)
}
return nil
}
+36
View File
@@ -0,0 +1,36 @@
---
name: cdp_navigate
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func CdpNavigate(c *CDPConn, targetURL string) error"
description: "Navega a la URL indicada usando el comando Page.navigate del protocolo CDP. Verifica que no haya errorText en la respuesta. Recibe una *CDPConn obtenida de CdpConnect."
tags: [chrome, cdp, browser, automation, navigation, devtools]
uses_functions: [cdp_connect_go_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: true
tests: ["TestChromeLaunchAndConnect"]
test_file_path: "functions/infra/chrome_launch_test.go"
file_path: "functions/infra/cdp_navigate.go"
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
defer CdpClose(conn, 0)
if err := CdpNavigate(conn, "https://example.com"); err != nil {
log.Fatal(err)
}
```
## Notas
Usa `Page.navigate` que es sincrono en la respuesta CDP pero la carga completa de la pagina puede tardar mas. Para esperar elementos especificos tras la navegacion, usar `CdpWaitElement`.
+77
View File
@@ -0,0 +1,77 @@
package infra
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
)
// CdpScreenshotOpts configura el screenshot.
type CdpScreenshotOpts struct {
// FullPage indica si capturar la pagina completa (scroll height) o solo el viewport.
FullPage bool
// Quality es la calidad JPEG (0-100). Solo aplica si Format es "jpeg". Por defecto 80.
Quality int
// Format es el formato de imagen: "png" o "jpeg". Por defecto "png".
Format string
}
// CdpScreenshot captura un screenshot de la pagina actual y lo guarda en outputPath.
// 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 {
if c == nil {
return fmt.Errorf("cdp screenshot: conexion nula")
}
if opts.Format == "" {
opts.Format = "png"
}
if opts.Quality == 0 && opts.Format == "jpeg" {
opts.Quality = 80
}
params := map[string]any{
"format": opts.Format,
"captureBeyondViewport": opts.FullPage,
}
if opts.Format == "jpeg" {
params["quality"] = opts.Quality
}
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
}
}
result, err := c.sendCDP("Page.captureScreenshot", params)
if err != nil {
return fmt.Errorf("cdp screenshot: %w", err)
}
dataStr, ok := result["data"].(string)
if !ok {
return 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)
}
// Crear directorio si no existe
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
return fmt.Errorf("cdp screenshot: crear directorio: %w", err)
}
if err := os.WriteFile(outputPath, imgData, 0644); err != nil {
return fmt.Errorf("cdp screenshot: guardar archivo: %w", err)
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
---
name: cdp_screenshot
kind: function
lang: go
domain: infra
version: "1.0.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_infra, cdp_evaluate_go_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/base64, fmt, os, path/filepath]
tested: true
tests: ["TestCdpScreenshot"]
test_file_path: "functions/infra/chrome_launch_test.go"
file_path: "functions/infra/cdp_screenshot.go"
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
CdpNavigate(conn, "https://example.com")
err := CdpScreenshot(conn, "/tmp/page.png", CdpScreenshotOpts{
FullPage: true,
Format: "png",
})
```
## 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.
+54
View File
@@ -0,0 +1,54 @@
package infra
import (
"fmt"
"time"
)
// CdpTypeText escribe texto en el elemento activo de la pagina caracter por caracter.
// Usa Input.dispatchKeyEvent para simular pulsaciones de teclado reales.
// Recomienda usar CdpClick primero para enfocar el elemento objetivo.
func CdpTypeText(c *CDPConn, text string) error {
if c == nil {
return fmt.Errorf("cdp type text: conexion nula")
}
for _, ch := range text {
charStr := string(ch)
// keyDown
keyDown := map[string]any{
"type": "keyDown",
"key": charStr,
"text": charStr,
}
if _, err := c.sendCDP("Input.dispatchKeyEvent", keyDown); err != nil {
return fmt.Errorf("cdp type text: keyDown %q: %w", charStr, err)
}
// char (dispara el evento input en el elemento)
keyChar := map[string]any{
"type": "char",
"key": charStr,
"text": charStr,
}
if _, err := c.sendCDP("Input.dispatchKeyEvent", keyChar); err != nil {
return fmt.Errorf("cdp type text: char %q: %w", charStr, err)
}
// keyUp
keyUp := map[string]any{
"type": "keyUp",
"key": charStr,
"text": charStr,
}
if _, err := c.sendCDP("Input.dispatchKeyEvent", keyUp); err != nil {
return fmt.Errorf("cdp type text: keyUp %q: %w", charStr, err)
}
// Pequena pausa entre caracteres para simular escritura humana
time.Sleep(10 * time.Millisecond)
}
return nil
}
+36
View File
@@ -0,0 +1,36 @@
---
name: cdp_type_text
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func CdpTypeText(c *CDPConn, text string) error"
description: "Escribe texto en el elemento activo de la pagina caracter por caracter via Input.dispatchKeyEvent. Envia eventos keyDown, char y keyUp por cada caracter con 10ms de pausa entre ellos. Usar CdpClick primero para enfocar el elemento."
tags: [chrome, cdp, browser, automation, keyboard, input, devtools]
uses_functions: [cdp_connect_go_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, time]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/cdp_type_text.go"
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
CdpNavigate(conn, "https://example.com/search")
// Enfocar el campo de busqueda y escribir
CdpClick(conn, "input[type=search]")
CdpTypeText(conn, "golang websocket")
```
## Notas
Envia tres eventos por caracter: `keyDown`, `char` (dispara el evento `input` del DOM) y `keyUp`. La pausa de 10ms entre caracteres simula escritura humana y ayuda con inputs que tienen debounce. Para texto largo, considerar inyectar directamente via `CdpEvaluate` con `element.value = "..."` + evento `input`.
+38
View File
@@ -0,0 +1,38 @@
package infra
import (
"fmt"
"time"
)
// CdpWaitElement espera hasta que un selector CSS exista en el DOM.
// Hace polling con Runtime.evaluate hasta que el elemento aparezca o se agote el timeout.
// Retorna error si el timeout se agota sin encontrar el elemento.
func CdpWaitElement(c *CDPConn, selector string, timeout time.Duration) error {
if c == nil {
return fmt.Errorf("cdp wait element: conexion nula")
}
if timeout <= 0 {
timeout = 10 * time.Second
}
deadline := time.Now().Add(timeout)
interval := 200 * time.Millisecond
js := fmt.Sprintf(`document.querySelector(%q) !== null`, selector)
for time.Now().Before(deadline) {
result, err := CdpEvaluate(c, js)
if err != nil {
// Error en evaluate — puede que la pagina aun no este lista
time.Sleep(interval)
continue
}
if result == "true" {
return nil
}
time.Sleep(interval)
}
return fmt.Errorf("cdp wait element: selector %q no encontrado despues de %s", selector, timeout)
}
+37
View File
@@ -0,0 +1,37 @@
---
name: cdp_wait_element
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func CdpWaitElement(c *CDPConn, selector string, timeout time.Duration) error"
description: "Espera hasta que un selector CSS exista en el DOM. Hace polling con Runtime.evaluate cada 200ms. Retorna nil cuando el elemento aparece o error si se agota el timeout. Util despues de navegacion o acciones que producen cambios dinamicos."
tags: [chrome, cdp, browser, automation, dom, wait, polling, devtools]
uses_functions: [cdp_connect_go_infra, cdp_evaluate_go_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, time]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/cdp_wait_element.go"
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
CdpNavigate(conn, "https://example.com")
// Esperar hasta 10 segundos a que aparezca el contenido
if err := CdpWaitElement(conn, ".main-content", 10*time.Second); err != nil {
log.Fatal("Timeout esperando elemento:", err)
}
```
## Notas
Usa `document.querySelector(selector) !== null` como condicion. Si `timeout <= 0` usa 10s por defecto. Los errores de `CdpEvaluate` durante el polling (pagina cargando) se ignoran y se reintenta en el siguiente ciclo.
+121
View File
@@ -0,0 +1,121 @@
package infra
import (
"fmt"
"net"
"os"
"os/exec"
"time"
)
// ChromeLaunchOpts configura el lanzamiento de Chrome con CDP.
type ChromeLaunchOpts struct {
// Port es el puerto de remote debugging. Por defecto 9222.
Port int
// UserDataDir es el directorio de perfil de Chrome. Por defecto /tmp/chrome-cdp-profile.
UserDataDir string
// Headless activa el modo headless (--headless=new). Por defecto false.
Headless bool
// ExtraArgs permite pasar flags adicionales a Chrome.
ExtraArgs []string
}
// chromePaths lista los ejecutables de Chrome conocidos en WSL2/Linux.
var chromePaths = []string{
"chrome.exe",
"google-chrome",
"chromium-browser",
"chromium",
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
"/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/mnt/c/Users/Public/Desktop/chrome.exe",
}
// findChrome localiza el ejecutable de Chrome en el sistema.
func findChrome() (string, error) {
for _, p := range chromePaths {
if path, err := exec.LookPath(p); err == nil {
return path, nil
}
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", fmt.Errorf("chrome: ejecutable no encontrado en PATH ni en rutas conocidas de Windows")
}
// waitCDPReady espera hasta que el puerto CDP responda conexiones TCP.
func waitCDPReady(port int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
addr := fmt.Sprintf("localhost:%d", port)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("chrome: puerto CDP %d no disponible despues de %s", port, timeout)
}
// ChromeLaunch lanza Google Chrome con remote debugging habilitado en el puerto indicado.
// Retorna el PID del proceso Chrome. Espera hasta 15s a que el puerto CDP este listo.
// Busca chrome.exe en PATH (WSL2) o en rutas conocidas de Windows.
func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
if opts.Port == 0 {
opts.Port = 9222
}
if opts.UserDataDir == "" {
opts.UserDataDir = "/tmp/chrome-cdp-profile"
}
chromePath, err := findChrome()
if err != nil {
return 0, err
}
args := []string{
fmt.Sprintf("--remote-debugging-port=%d", opts.Port),
fmt.Sprintf("--user-data-dir=%s", opts.UserDataDir),
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-client-side-phishing-detection",
"--disable-default-apps",
"--disable-extensions",
"--disable-hang-monitor",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-sync",
"--disable-translate",
"--metrics-recording-only",
"--safebrowsing-disable-auto-update",
}
if opts.Headless {
args = append(args, "--headless=new", "--disable-gpu")
}
args = append(args, opts.ExtraArgs...)
cmd := exec.Command(chromePath, args...)
// Chrome necesita que no haya stderr/stdout bloqueados
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("chrome: arrancar proceso: %w", err)
}
pid := cmd.Process.Pid
// Esperar a que el puerto CDP este listo
if err := waitCDPReady(opts.Port, 15*time.Second); err != nil {
// Matar proceso si no arranco correctamente
cmd.Process.Kill()
return 0, err
}
return pid, nil
}
+47
View File
@@ -0,0 +1,47 @@
---
name: chrome_launch
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ChromeLaunch(opts ChromeLaunchOpts) (int, error)"
description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. Busca chrome.exe en PATH (WSL2) o en rutas conocidas de Windows. Espera hasta 15s a que el puerto CDP este listo antes de retornar. Retorna el PID del proceso."
tags: [chrome, cdp, browser, automation, wsl2, headless]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, net, os, os/exec, time]
tested: true
tests: ["TestFindChrome", "TestChromeLaunchAndConnect"]
test_file_path: "functions/infra/chrome_launch_test.go"
file_path: "functions/infra/chrome_launch.go"
---
## Ejemplo
```go
pid, err := ChromeLaunch(ChromeLaunchOpts{
Port: 9222,
UserDataDir: "/tmp/chrome-cdp",
Headless: true,
})
if err != nil {
log.Fatal(err)
}
defer CdpClose(nil, pid)
```
## Notas
Busca Chrome en este orden:
1. `chrome.exe` en PATH (disponible en WSL2 si Windows lo tiene en PATH)
2. `google-chrome` / `chromium-browser` / `chromium` (Linux nativo)
3. `/mnt/c/Program Files/Google/Chrome/Application/chrome.exe`
4. `/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe`
Los flags aplicados desactivan funcionalidades de red y actualizacion en segundo plano para entornos de automatizacion. En modo headless se agrega `--headless=new --disable-gpu`.
El struct `ChromeLaunchOpts` se define en el mismo archivo.
+231
View File
@@ -0,0 +1,231 @@
package infra
import (
"os"
"strings"
"testing"
"time"
)
// TestFindChrome verifica que el ejecutable de Chrome es localizable.
func TestFindChrome(t *testing.T) {
path, err := findChrome()
if err != nil {
t.Skipf("Chrome no encontrado (entorno sin Chrome): %v", err)
}
if path == "" {
t.Error("findChrome retorno path vacio")
}
t.Logf("Chrome encontrado en: %s", path)
}
// TestChromeLaunchAndConnect lanza Chrome, conecta CDP, navega a about:blank y cierra.
func TestChromeLaunchAndConnect(t *testing.T) {
if testing.Short() {
t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
}
// Verificar que Chrome esta disponible
if _, err := findChrome(); err != nil {
t.Skipf("Chrome no disponible: %v", err)
}
opts := ChromeLaunchOpts{
Port: 9223, // puerto alternativo para no interferir con sesiones existentes
UserDataDir: t.TempDir(),
Headless: true,
}
pid, err := ChromeLaunch(opts)
if err != nil {
t.Fatalf("ChromeLaunch: %v", err)
}
t.Logf("Chrome lanzado con PID %d en puerto %d", pid, opts.Port)
defer func() {
if err := CdpClose(nil, pid); err != nil {
t.Logf("CdpClose (pid only): %v", err)
}
}()
// Conectar CDP
conn, err := CdpConnect(opts.Port)
if err != nil {
t.Fatalf("CdpConnect: %v", err)
}
defer func() {
if err := CdpClose(conn, 0); err != nil {
t.Logf("CdpClose (conn): %v", err)
}
}()
// Navegar a about:blank
if err := CdpNavigate(conn, "about:blank"); err != nil {
t.Fatalf("CdpNavigate about:blank: %v", err)
}
t.Log("Navegacion a about:blank exitosa")
}
// TestCdpEvaluate ejecuta JS simple en Chrome y verifica el resultado.
func TestCdpEvaluate(t *testing.T) {
if testing.Short() {
t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
}
if _, err := findChrome(); err != nil {
t.Skipf("Chrome no disponible: %v", err)
}
opts := ChromeLaunchOpts{
Port: 9224,
UserDataDir: t.TempDir(),
Headless: true,
}
pid, err := ChromeLaunch(opts)
if err != nil {
t.Fatalf("ChromeLaunch: %v", err)
}
defer CdpClose(nil, pid)
conn, err := CdpConnect(opts.Port)
if err != nil {
t.Fatalf("CdpConnect: %v", err)
}
defer CdpClose(conn, 0)
if err := CdpNavigate(conn, "about:blank"); err != nil {
t.Fatalf("CdpNavigate: %v", err)
}
t.Run("expresion aritmetica simple", func(t *testing.T) {
result, err := CdpEvaluate(conn, "1 + 2")
if err != nil {
t.Fatalf("CdpEvaluate: %v", err)
}
if result != "3" {
t.Errorf("esperado '3', got %q", result)
}
})
t.Run("string literal", func(t *testing.T) {
result, err := CdpEvaluate(conn, `"hola mundo"`)
if err != nil {
t.Fatalf("CdpEvaluate: %v", err)
}
if result != "hola mundo" {
t.Errorf("esperado 'hola mundo', got %q", result)
}
})
t.Run("typeof window", func(t *testing.T) {
result, err := CdpEvaluate(conn, "typeof window")
if err != nil {
t.Fatalf("CdpEvaluate: %v", err)
}
if result != "object" {
t.Errorf("esperado 'object', got %q", result)
}
})
}
// TestCdpGetHTML obtiene el HTML de about:blank y verifica que contiene elementos basicos.
func TestCdpGetHTML(t *testing.T) {
if testing.Short() {
t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
}
if _, err := findChrome(); err != nil {
t.Skipf("Chrome no disponible: %v", err)
}
opts := ChromeLaunchOpts{
Port: 9225,
UserDataDir: t.TempDir(),
Headless: true,
}
pid, err := ChromeLaunch(opts)
if err != nil {
t.Fatalf("ChromeLaunch: %v", err)
}
defer CdpClose(nil, pid)
conn, err := CdpConnect(opts.Port)
if err != nil {
t.Fatalf("CdpConnect: %v", err)
}
defer CdpClose(conn, 0)
if err := CdpNavigate(conn, "about:blank"); err != nil {
t.Fatalf("CdpNavigate: %v", err)
}
t.Run("html de about blank contiene html y body", func(t *testing.T) {
html, err := CdpGetHTML(conn)
if err != nil {
t.Fatalf("CdpGetHTML: %v", err)
}
lower := strings.ToLower(html)
if !strings.Contains(lower, "<html") {
t.Errorf("HTML no contiene tag <html>: %q", html[:min(200, len(html))])
}
if !strings.Contains(lower, "<body") {
t.Errorf("HTML no contiene tag <body>: %q", html[:min(200, len(html))])
}
t.Logf("HTML (primeros 200 chars): %s", html[:min(200, len(html))])
})
}
// TestCdpScreenshot toma un screenshot de about:blank y verifica que se crea el archivo PNG.
func TestCdpScreenshot(t *testing.T) {
if testing.Short() {
t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
}
if _, err := findChrome(); err != nil {
t.Skipf("Chrome no disponible: %v", err)
}
opts := ChromeLaunchOpts{
Port: 9226,
UserDataDir: t.TempDir(),
Headless: true,
}
pid, err := ChromeLaunch(opts)
if err != nil {
t.Fatalf("ChromeLaunch: %v", err)
}
defer CdpClose(nil, pid)
conn, err := CdpConnect(opts.Port)
if err != nil {
t.Fatalf("CdpConnect: %v", err)
}
defer CdpClose(conn, 0)
if err := CdpNavigate(conn, "about:blank"); err != nil {
t.Fatalf("CdpNavigate: %v", err)
}
// Esperar un momento para que la pagina cargue
time.Sleep(500 * time.Millisecond)
t.Run("screenshot png se crea correctamente", func(t *testing.T) {
outputPath := t.TempDir() + "/screenshot.png"
err := CdpScreenshot(conn, outputPath, CdpScreenshotOpts{FullPage: false})
if err != nil {
t.Fatalf("CdpScreenshot: %v", err)
}
info, err := os.Stat(outputPath)
if err != nil {
t.Fatalf("screenshot no encontrado: %v", err)
}
if info.Size() == 0 {
t.Error("screenshot vacio (0 bytes)")
}
t.Logf("Screenshot creado: %s (%d bytes)", outputPath, info.Size())
})
}