From add09c2faace5af75987290031e47f0bed762e73 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 29 Mar 2026 17:30:56 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20funciones=20Chrome=20CDP=20para=20autom?= =?UTF-8?q?atizaci=C3=B3n=20de=20navegador?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- functions/infra/cdp_click.go | 94 ++++++++ functions/infra/cdp_click.md | 42 ++++ functions/infra/cdp_close.go | 37 ++++ functions/infra/cdp_close.md | 38 ++++ functions/infra/cdp_conn.go | 302 ++++++++++++++++++++++++++ functions/infra/cdp_connect.go | 105 +++++++++ functions/infra/cdp_connect.md | 39 ++++ functions/infra/cdp_evaluate.go | 48 ++++ functions/infra/cdp_evaluate.md | 36 +++ functions/infra/cdp_get_html.go | 20 ++ functions/infra/cdp_get_html.md | 36 +++ functions/infra/cdp_navigate.go | 30 +++ functions/infra/cdp_navigate.md | 36 +++ functions/infra/cdp_screenshot.go | 77 +++++++ functions/infra/cdp_screenshot.md | 37 ++++ functions/infra/cdp_type_text.go | 54 +++++ functions/infra/cdp_type_text.md | 36 +++ functions/infra/cdp_wait_element.go | 38 ++++ functions/infra/cdp_wait_element.md | 37 ++++ functions/infra/chrome_launch.go | 121 +++++++++++ functions/infra/chrome_launch.md | 47 ++++ functions/infra/chrome_launch_test.go | 231 ++++++++++++++++++++ 22 files changed, 1541 insertions(+) create mode 100644 functions/infra/cdp_click.go create mode 100644 functions/infra/cdp_click.md create mode 100644 functions/infra/cdp_close.go create mode 100644 functions/infra/cdp_close.md create mode 100644 functions/infra/cdp_conn.go create mode 100644 functions/infra/cdp_connect.go create mode 100644 functions/infra/cdp_connect.md create mode 100644 functions/infra/cdp_evaluate.go create mode 100644 functions/infra/cdp_evaluate.md create mode 100644 functions/infra/cdp_get_html.go create mode 100644 functions/infra/cdp_get_html.md create mode 100644 functions/infra/cdp_navigate.go create mode 100644 functions/infra/cdp_navigate.md create mode 100644 functions/infra/cdp_screenshot.go create mode 100644 functions/infra/cdp_screenshot.md create mode 100644 functions/infra/cdp_type_text.go create mode 100644 functions/infra/cdp_type_text.md create mode 100644 functions/infra/cdp_wait_element.go create mode 100644 functions/infra/cdp_wait_element.md create mode 100644 functions/infra/chrome_launch.go create mode 100644 functions/infra/chrome_launch.md create mode 100644 functions/infra/chrome_launch_test.go diff --git a/functions/infra/cdp_click.go b/functions/infra/cdp_click.go new file mode 100644 index 00000000..109dfb4c --- /dev/null +++ b/functions/infra/cdp_click.go @@ -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 +} diff --git a/functions/infra/cdp_click.md b/functions/infra/cdp_click.md new file mode 100644 index 00000000..30b13ebb --- /dev/null +++ b/functions/infra/cdp_click.md @@ -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. diff --git a/functions/infra/cdp_close.go b/functions/infra/cdp_close.go new file mode 100644 index 00000000..b571d01b --- /dev/null +++ b/functions/infra/cdp_close.go @@ -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 +} diff --git a/functions/infra/cdp_close.md b/functions/infra/cdp_close.md new file mode 100644 index 00000000..67b99683 --- /dev/null +++ b/functions/infra/cdp_close.md @@ -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. diff --git a/functions/infra/cdp_conn.go b/functions/infra/cdp_conn.go new file mode 100644 index 00000000..cab7aa2e --- /dev/null +++ b/functions/infra/cdp_conn.go @@ -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 +} diff --git a/functions/infra/cdp_connect.go b/functions/infra/cdp_connect.go new file mode 100644 index 00000000..58e01dbb --- /dev/null +++ b/functions/infra/cdp_connect.go @@ -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 +} diff --git a/functions/infra/cdp_connect.md b/functions/infra/cdp_connect.md new file mode 100644 index 00000000..b9fbdc88 --- /dev/null +++ b/functions/infra/cdp_connect.md @@ -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). diff --git a/functions/infra/cdp_evaluate.go b/functions/infra/cdp_evaluate.go new file mode 100644 index 00000000..76d5215c --- /dev/null +++ b/functions/infra/cdp_evaluate.go @@ -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 +} diff --git a/functions/infra/cdp_evaluate.md b/functions/infra/cdp_evaluate.md new file mode 100644 index 00000000..0a48e15a --- /dev/null +++ b/functions/infra/cdp_evaluate.md @@ -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. diff --git a/functions/infra/cdp_get_html.go b/functions/infra/cdp_get_html.go new file mode 100644 index 00000000..a676f30a --- /dev/null +++ b/functions/infra/cdp_get_html.go @@ -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 +} diff --git a/functions/infra/cdp_get_html.md b/functions/infra/cdp_get_html.md new file mode 100644 index 00000000..3877ba07 --- /dev/null +++ b/functions/infra/cdp_get_html.md @@ -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. diff --git a/functions/infra/cdp_navigate.go b/functions/infra/cdp_navigate.go new file mode 100644 index 00000000..9fa7277d --- /dev/null +++ b/functions/infra/cdp_navigate.go @@ -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 +} diff --git a/functions/infra/cdp_navigate.md b/functions/infra/cdp_navigate.md new file mode 100644 index 00000000..e275fdb8 --- /dev/null +++ b/functions/infra/cdp_navigate.md @@ -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`. diff --git a/functions/infra/cdp_screenshot.go b/functions/infra/cdp_screenshot.go new file mode 100644 index 00000000..1c161211 --- /dev/null +++ b/functions/infra/cdp_screenshot.go @@ -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 +} diff --git a/functions/infra/cdp_screenshot.md b/functions/infra/cdp_screenshot.md new file mode 100644 index 00000000..909f7d4f --- /dev/null +++ b/functions/infra/cdp_screenshot.md @@ -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. diff --git a/functions/infra/cdp_type_text.go b/functions/infra/cdp_type_text.go new file mode 100644 index 00000000..a371cae2 --- /dev/null +++ b/functions/infra/cdp_type_text.go @@ -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 +} diff --git a/functions/infra/cdp_type_text.md b/functions/infra/cdp_type_text.md new file mode 100644 index 00000000..4a0df8df --- /dev/null +++ b/functions/infra/cdp_type_text.md @@ -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`. diff --git a/functions/infra/cdp_wait_element.go b/functions/infra/cdp_wait_element.go new file mode 100644 index 00000000..0d3538ca --- /dev/null +++ b/functions/infra/cdp_wait_element.go @@ -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) +} diff --git a/functions/infra/cdp_wait_element.md b/functions/infra/cdp_wait_element.md new file mode 100644 index 00000000..eea36571 --- /dev/null +++ b/functions/infra/cdp_wait_element.md @@ -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. diff --git a/functions/infra/chrome_launch.go b/functions/infra/chrome_launch.go new file mode 100644 index 00000000..ef266694 --- /dev/null +++ b/functions/infra/chrome_launch.go @@ -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 +} diff --git a/functions/infra/chrome_launch.md b/functions/infra/chrome_launch.md new file mode 100644 index 00000000..fb0240f9 --- /dev/null +++ b/functions/infra/chrome_launch.md @@ -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. diff --git a/functions/infra/chrome_launch_test.go b/functions/infra/chrome_launch_test.go new file mode 100644 index 00000000..0f2ff51b --- /dev/null +++ b/functions/infra/chrome_launch_test.go @@ -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, ": %q", html[:min(200, len(html))]) + } + if !strings.Contains(lower, ": %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()) + }) +}