chore: auto-commit (97 archivos)

- .claude/CLAUDE.md
- .claude/agents/fn-recopilador/SKILL.md
- .claude/rules/INDEX.md
- .claude/rules/cpp_apps.md
- bash/functions/infra/build_cpp_windows.sh
- cpp/CMakeLists.txt
- cpp/PATTERNS.md
- cpp/framework/app_base.cpp
- cpp/framework/app_base.h
- dev/issues/README.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 18:11:24 +02:00
parent 852322a708
commit 750b7abcd5
99 changed files with 7879 additions and 73 deletions
+26
View File
@@ -0,0 +1,26 @@
package browser
import "fmt"
// CdpClickText combina CdpFindByText + CdpClick: localiza el primer elemento
// con `text` y le hace click. Util para tests/scraping sin depender de
// selectores CSS fragiles.
//
// Si no encuentra ningun elemento con ese texto, retorna error claro
// (no falso negativo silencioso).
func CdpClickText(c *CDPConn, text string, opts FindByTextOpts) error {
if c == nil {
return fmt.Errorf("cdp click text: conexion nula")
}
sel, err := CdpFindByText(c, text, opts)
if err != nil {
return fmt.Errorf("cdp click text: %w", err)
}
if sel == "" {
return fmt.Errorf("cdp click text: no se encontro elemento con texto %q (tag=%q exact=%v)", text, opts.Tag, opts.Exact)
}
if err := CdpClick(c, sel); err != nil {
return fmt.Errorf("cdp click text: click sobre %q: %w", sel, err)
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
---
name: cdp_click_text
kind: function
lang: go
domain: browser
version: 0.1.0
purity: impure
signature: "func CdpClickText(c *CDPConn, text string, opts FindByTextOpts) error"
description: "Localiza el primer elemento cuyo innerText matchea el texto dado y le hace click. Composicion de CdpFindByText + CdpClick. Mas robusto que click por selector CSS porque el texto visible cambia menos que la estructura del DOM."
tags: [browser, cdp, click, locator, accessibility]
uses_functions:
- cdp_find_by_text_go_browser
- cdp_click_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
example: |
c, _ := browser.CdpConnect(9222)
defer browser.CdpClose(c, 0)
err := browser.CdpClickText(c, "Sign in", browser.FindByTextOpts{Tag: "button"})
if err != nil {
log.Fatal(err)
}
tested: true
tests: ["TestCdpClickText_returnsErrorOnEmpty"]
test_file_path: "functions/browser/cdp_click_text_test.go"
file_path: "functions/browser/cdp_click_text.go"
notes: |
- Devuelve error si no encuentra ningun elemento con ese texto — fail-loud, no falso positivo.
- Reusa la heuristica leafmost de CdpFindByText (click va al elemento mas interno con el texto).
- Para multiples coincidencias (ej. dos botones "OK"), pasar opts.Tag o usar un texto mas especifico.
documentation: |
Patron `getByText(...).click()` de Playwright. Reduce mantenimiento de
tests e2e: cuando el frontend renombra clases CSS o reordena DOM, el
test sigue funcionando si el texto visible no cambia.
params:
- name: c
desc: "Conexion CDP activa."
- name: text
desc: "Texto del elemento a clickar."
- name: opts
desc: "FindByTextOpts (mismos campos que CdpFindByText)."
output: "nil si click OK, error con descripcion si no encuentra elemento o click falla."
---
+23
View File
@@ -0,0 +1,23 @@
package browser
import (
"strings"
"testing"
)
func TestCdpClickText_nilConn(t *testing.T) {
if err := CdpClickText(nil, "Submit", FindByTextOpts{}); err == nil {
t.Fatal("expected error on nil conn")
}
}
func TestCdpClickText_emptyText(t *testing.T) {
c := &CDPConn{}
err := CdpClickText(c, "", FindByTextOpts{})
if err == nil {
t.Fatal("expected error on empty text")
}
if !strings.Contains(err.Error(), "cdp click text") {
t.Fatalf("error no incluye prefijo: %v", err)
}
}
+62 -12
View File
@@ -16,18 +16,25 @@ import (
"sync/atomic"
)
// EventHandler es invocado cuando llega un evento CDP del metodo subscrito.
// El handler corre en la goroutine del readLoop — debe ser rapido o despachar
// a un canal/goroutine propio. params puede ser nil si Chrome no envia.
type EventHandler func(method string, params map[string]any)
// 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
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
handlers map[string][]EventHandler
hMu sync.Mutex
}
type cdpRequest struct {
@@ -245,7 +252,8 @@ func (c *CDPConn) sendCDP(method string, params map[string]any) (map[string]any,
return resp.Result, nil
}
// readLoop lee mensajes del WebSocket y los enruta a los canales pendientes.
// readLoop lee mensajes del WebSocket y los enruta a los canales pendientes
// (respuestas a comandos) o a los handlers registrados (eventos CDP).
// Debe ejecutarse en una goroutine.
func (c *CDPConn) readLoop() {
for {
@@ -277,9 +285,51 @@ func (c *CDPConn) readLoop() {
if ok {
ch <- resp
}
continue
}
// Sin ID = evento CDP. Llamar handlers registrados para ese metodo.
if resp.Method != "" {
c.hMu.Lock()
hs := append([]EventHandler(nil), c.handlers[resp.Method]...)
c.hMu.Unlock()
for _, h := range hs {
// Aislamos panics de handlers ajenos para que un handler
// roto no mate la conexion entera.
func(h EventHandler) {
defer func() { _ = recover() }()
h(resp.Method, resp.Params)
}(h)
}
}
}
}
// OnEvent registra un handler para un metodo CDP (ej "Network.requestWillBeSent").
// Devuelve una funcion `cancel` que des-registra el handler. Multiples handlers
// para el mismo metodo se invocan en orden de registro.
//
// El handler corre en la goroutine de lectura — mantenlo rapido. Para trabajo
// pesado, despacha a un canal/goroutine propios.
func (c *CDPConn) OnEvent(method string, h EventHandler) (cancel func()) {
if c == nil || h == nil || method == "" {
return func() {}
}
c.hMu.Lock()
if c.handlers == nil {
c.handlers = make(map[string][]EventHandler)
}
c.handlers[method] = append(c.handlers[method], h)
idx := len(c.handlers[method]) - 1
c.hMu.Unlock()
return func() {
c.hMu.Lock()
defer c.hMu.Unlock()
hs := c.handlers[method]
if idx < len(hs) {
c.handlers[method] = append(hs[:idx], hs[idx+1:]...)
}
// 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
}
}
+102
View File
@@ -0,0 +1,102 @@
package browser
import (
"encoding/json"
"fmt"
"strings"
)
// FindByTextOpts configura la busqueda por texto visible.
type FindByTextOpts struct {
// Tag filtra por nombre de tag (ej "button", "a"). Vacio = cualquiera.
Tag string
// Exact: true = innerText.trim() === text. false (default) = contiene.
Exact bool
// CaseSensitive: false (default) = comparacion lowercased.
CaseSensitive bool
}
// CdpFindByText busca el primer elemento cuyo `innerText` matchea `text` y
// devuelve un selector CSS unico utilizable con CdpClick / CdpEvaluate.
// Prefiere elementos hoja (no contenedores que envuelven hijos con el mismo
// texto) — asi el click va al elemento mas interno, donde el handler vive.
//
// El selector retornado es:
// - "#<id>" si el elemento tiene id.
// - path "tag:nth-of-type(n) > tag:nth-of-type(n) > ..." si no.
//
// Retorna ("", nil) si no encuentra nada (no es error). Error solo si la
// evaluacion JS rompe (conexion CDP caida).
func CdpFindByText(c *CDPConn, text string, opts FindByTextOpts) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp find by text: conexion nula")
}
if text == "" {
return "", fmt.Errorf("cdp find by text: texto vacio")
}
// Serializamos opts como JSON literal en el script para evitar quoting hell.
payload, _ := json.Marshal(map[string]any{
"text": text,
"tag": opts.Tag,
"exact": opts.Exact,
"cs": opts.CaseSensitive,
})
js := fmt.Sprintf(`
(function() {
var P = %s;
var target = P.cs ? P.text : P.text.toLowerCase();
var nodes = document.querySelectorAll(P.tag || '*');
function norm(v) {
v = (v || '').replace(/\s+/g, ' ').trim();
return P.cs ? v : v.toLowerCase();
}
function matches(el) {
var v = norm(el.innerText || el.textContent || '');
return P.exact ? v === target : v.indexOf(target) >= 0;
}
function leafmost(el) {
for (var i = 0; i < el.children.length; i++) {
if (matches(el.children[i])) return false;
}
return true;
}
function selectorOf(el) {
if (el.id) return '#' + CSS.escape(el.id);
var path = [];
while (el && el.nodeType === 1 && el.tagName !== 'HTML') {
var sel = el.tagName.toLowerCase();
var parent = el.parentNode;
if (parent && parent.children) {
var sib = Array.prototype.filter.call(parent.children, function(c) {
return c.tagName === el.tagName;
});
if (sib.length > 1) sel += ':nth-of-type(' + (sib.indexOf(el) + 1) + ')';
}
path.unshift(sel);
if (el === document.body) break;
el = el.parentElement;
}
return path.join(' > ');
}
for (var i = 0; i < nodes.length; i++) {
var el = nodes[i];
if (matches(el) && leafmost(el)) {
return selectorOf(el);
}
}
return '';
})()`, string(payload))
res, err := CdpEvaluate(c, js)
if err != nil {
return "", fmt.Errorf("cdp find by text: %w", err)
}
// CdpEvaluate retorna el valor stringificado. Para "" devuelve cadena vacia.
res = strings.TrimSpace(res)
if res == "" || res == "<nil>" {
return "", nil
}
return res, nil
}
+51
View File
@@ -0,0 +1,51 @@
---
name: cdp_find_by_text
kind: function
lang: go
domain: browser
version: 0.1.0
purity: impure
signature: "func CdpFindByText(c *CDPConn, text string, opts FindByTextOpts) (string, error)"
description: "Busca el primer elemento cuyo innerText matchea el texto dado y retorna un selector CSS unico. Prefiere elementos hoja (no contenedores). Util para pruebas robustas que no dependen de selectores CSS fragiles del DOM. Combinable con CdpClick para click-by-text."
tags: [browser, cdp, find, locator, accessibility]
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
example: |
c, _ := browser.CdpConnect(9222)
defer browser.CdpClose(c, 0)
sel, err := browser.CdpFindByText(c, "Submit", browser.FindByTextOpts{Tag: "button"})
if err == nil && sel != "" {
browser.CdpClick(c, sel)
}
tested: true
tests: ["TestCdpFindByText_buildsSelectorScript"]
test_file_path: "functions/browser/cdp_find_by_text_test.go"
file_path: "functions/browser/cdp_find_by_text.go"
notes: |
- Prefiere "leafmost": si un <div> contiene un <button> con el mismo texto, retorna el button (donde suele vivir el handler).
- Caso por defecto: substring + lowercased. Configurable via opts.Exact + opts.CaseSensitive.
- Filtro opcional por tag para acotar la busqueda (mas rapido y menos ambiguo).
- Retorna ("", nil) si no hay match — no es error. Error solo si el eval JS falla (conexion CDP caida).
- Selector retornado funciona con todas las funciones cdp_* que aceptan selector.
documentation: |
Equivale al patron `getByText` de Playwright o `findByText` de Testing Library.
Pensado para pruebas e2e + scraping donde los selectores CSS cambian con el
build del frontend pero el texto visible es estable.
Construye el selector recorriendo desde el elemento hasta `body` con
`tag:nth-of-type(n)`, salvo que el elemento tenga `id` (caso optimo:
retorna `#<id>` directo).
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: text
desc: "Texto visible a buscar. Comparacion contra innerText/textContent normalizado (whitespace colapsado)."
- name: opts
desc: "FindByTextOpts: Tag (filtro por tag, vacio = cualquiera), Exact (default false), CaseSensitive (default false)."
output: "Selector CSS unico (string vacio si no encuentra). Error solo si CDP rompe."
---
@@ -0,0 +1,27 @@
package browser
import (
"strings"
"testing"
)
// TestCdpFindByText_buildsSelectorScript verifica que el script JS que se
// envia a Chrome contiene los campos esperados de FindByTextOpts. No requiere
// Chrome — solo inspecciona la estructura del JS via el comportamiento de
// nil-conexion. Tests reales contra Chrome viven gateados por env var.
func TestCdpFindByText_buildsSelectorScript(t *testing.T) {
// Conexion nula → error claro.
if _, err := CdpFindByText(nil, "x", FindByTextOpts{}); err == nil {
t.Fatal("expected error on nil conn")
}
}
func TestCdpFindByText_emptyText(t *testing.T) {
// Conexion no nil pero texto vacio → error.
c := &CDPConn{}
if _, err := CdpFindByText(c, "", FindByTextOpts{}); err == nil {
t.Fatal("expected error on empty text")
} else if !strings.Contains(err.Error(), "vacio") {
t.Fatalf("error message no menciona vacio: %v", err)
}
}
+277
View File
@@ -0,0 +1,277 @@
package browser
import (
"encoding/json"
"fmt"
"sync"
"time"
)
// HarHeader es el formato HAR 1.2 estandar (name/value).
type HarHeader struct {
Name string `json:"name"`
Value string `json:"value"`
}
// HarEntry mapea una peticion HTTP capturada via CDP a un entry HAR.
type HarEntry struct {
StartedDateTime string `json:"startedDateTime"` // ISO 8601
Time int64 `json:"time"` // ms
Request struct {
Method string `json:"method"`
URL string `json:"url"`
HTTPVersion string `json:"httpVersion"`
Headers []HarHeader `json:"headers"`
QueryString []HarHeader `json:"queryString"`
Cookies []HarHeader `json:"cookies"`
HeadersSize int `json:"headersSize"`
BodySize int `json:"bodySize"`
PostData *struct {
MimeType string `json:"mimeType"`
Text string `json:"text"`
} `json:"postData,omitempty"`
} `json:"request"`
Response struct {
Status int `json:"status"`
StatusText string `json:"statusText"`
HTTPVersion string `json:"httpVersion"`
Headers []HarHeader `json:"headers"`
Cookies []HarHeader `json:"cookies"`
Content struct {
Size int `json:"size"`
MimeType string `json:"mimeType"`
Text string `json:"text,omitempty"`
} `json:"content"`
RedirectURL string `json:"redirectURL"`
HeadersSize int `json:"headersSize"`
BodySize int `json:"bodySize"`
} `json:"response"`
Cache struct{} `json:"cache"`
Timings struct {
Send int `json:"send"`
Wait int `json:"wait"`
Receive int `json:"receive"`
} `json:"timings"`
ServerIPAddress string `json:"serverIPAddress,omitempty"`
XRequestID string `json:"_requestId"`
XError string `json:"_error,omitempty"`
}
// HarLog es el wrapper top-level de un archivo HAR.
type HarLog struct {
Log struct {
Version string `json:"version"`
Creator struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"creator"`
Pages []any `json:"pages"`
Entries []HarEntry `json:"entries"`
} `json:"log"`
}
// CdpHarRecord activa Network.* events en la conexion, ejecuta la funcion
// `action` y captura todas las peticiones HTTP que ocurran durante. Despues
// de que `action` retorne, espera `settleMs` ms para recoger eventos
// rezagados (loadingFinished tipicamente llega despues que responseReceived).
//
// Retorna el HAR como JSON string listo para escribir a disco o servir.
//
// Body de respuestas NO se captura en v0 — requiere llamar Network.getResponseBody
// por requestId al recibir loadingFinished, lo cual añade latencia y complica.
// v1 puede activarlo via opts.
//
// La conexion debe estar abierta. La funcion no la cierra.
func CdpHarRecord(c *CDPConn, action func() error, settleMs int) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp har record: conexion nula")
}
if action == nil {
action = func() error { return nil }
}
if settleMs <= 0 {
settleMs = 1500
}
type pending struct {
entry HarEntry
startTs float64 // CDP timestamp (monotonic seconds)
endTs float64
hasRequest bool
}
var (
mu sync.Mutex
entries = map[string]*pending{}
)
// Helpers para extraer campos de map[string]any sin pelearse con cast.
str := func(m map[string]any, k string) string {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
num := func(m map[string]any, k string) float64 {
if v, ok := m[k]; ok {
if f, ok := v.(float64); ok {
return f
}
}
return 0
}
headersOf := func(h map[string]any) []HarHeader {
out := []HarHeader{}
for k, v := range h {
if s, ok := v.(string); ok {
out = append(out, HarHeader{Name: k, Value: s})
}
}
return out
}
cancel1 := c.OnEvent("Network.requestWillBeSent", func(method string, p map[string]any) {
reqID := str(p, "requestId")
if reqID == "" {
return
}
req, _ := p["request"].(map[string]any)
hdrs, _ := req["headers"].(map[string]any)
ts := num(p, "timestamp")
wallTime := num(p, "wallTime") // unix epoch seconds (float)
mu.Lock()
defer mu.Unlock()
e, ok := entries[reqID]
if !ok {
e = &pending{}
entries[reqID] = e
}
e.entry.XRequestID = reqID
e.entry.Request.Method = str(req, "method")
e.entry.Request.URL = str(req, "url")
e.entry.Request.HTTPVersion = "HTTP/1.1"
e.entry.Request.Headers = headersOf(hdrs)
e.entry.Request.QueryString = []HarHeader{}
e.entry.Request.Cookies = []HarHeader{}
e.entry.Request.HeadersSize = -1
e.entry.Request.BodySize = -1
if pd, ok := req["postData"].(string); ok && pd != "" {
e.entry.Request.PostData = &struct {
MimeType string `json:"mimeType"`
Text string `json:"text"`
}{
MimeType: str(hdrs, "Content-Type"),
Text: pd,
}
}
if wallTime > 0 {
e.entry.StartedDateTime = time.Unix(0, int64(wallTime*1e9)).UTC().Format(time.RFC3339Nano)
} else {
e.entry.StartedDateTime = time.Now().UTC().Format(time.RFC3339Nano)
}
e.startTs = ts
e.hasRequest = true
})
defer cancel1()
cancel2 := c.OnEvent("Network.responseReceived", func(method string, p map[string]any) {
reqID := str(p, "requestId")
if reqID == "" {
return
}
resp, _ := p["response"].(map[string]any)
hdrs, _ := resp["headers"].(map[string]any)
mu.Lock()
defer mu.Unlock()
e, ok := entries[reqID]
if !ok {
e = &pending{}
entries[reqID] = e
}
e.entry.Response.Status = int(num(resp, "status"))
e.entry.Response.StatusText = str(resp, "statusText")
e.entry.Response.HTTPVersion = str(resp, "protocol")
if e.entry.Response.HTTPVersion == "" {
e.entry.Response.HTTPVersion = "HTTP/1.1"
}
e.entry.Response.Headers = headersOf(hdrs)
e.entry.Response.Cookies = []HarHeader{}
e.entry.Response.Content.MimeType = str(resp, "mimeType")
e.entry.Response.Content.Size = int(num(resp, "encodedDataLength"))
e.entry.Response.HeadersSize = -1
e.entry.Response.BodySize = -1
e.entry.ServerIPAddress = str(resp, "remoteIPAddress")
})
defer cancel2()
cancel3 := c.OnEvent("Network.loadingFinished", func(method string, p map[string]any) {
reqID := str(p, "requestId")
ts := num(p, "timestamp")
mu.Lock()
defer mu.Unlock()
if e, ok := entries[reqID]; ok {
e.endTs = ts
}
})
defer cancel3()
cancel4 := c.OnEvent("Network.loadingFailed", func(method string, p map[string]any) {
reqID := str(p, "requestId")
ts := num(p, "timestamp")
errText := str(p, "errorText")
mu.Lock()
defer mu.Unlock()
if e, ok := entries[reqID]; ok {
e.endTs = ts
e.entry.XError = errText
}
})
defer cancel4()
// Habilitar Network domain.
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return "", fmt.Errorf("cdp har record: Network.enable: %w", err)
}
defer c.sendCDP("Network.disable", nil)
// Ejecutar accion del usuario.
actionErr := action()
// Esperar eventos rezagados (loadingFinished suele llegar despues).
time.Sleep(time.Duration(settleMs) * time.Millisecond)
// Construir HAR.
mu.Lock()
defer mu.Unlock()
var har HarLog
har.Log.Version = "1.2"
har.Log.Creator.Name = "navegator/cdp_har_record"
har.Log.Creator.Version = "0.1.0"
har.Log.Pages = []any{}
har.Log.Entries = make([]HarEntry, 0, len(entries))
for _, e := range entries {
if !e.hasRequest {
continue
}
if e.endTs > 0 && e.startTs > 0 {
e.entry.Time = int64((e.endTs - e.startTs) * 1000)
}
// HAR exige timings con send/wait/receive >= 0; usar 0/total/0 si no
// tenemos breakdown.
e.entry.Timings.Send = 0
e.entry.Timings.Wait = int(e.entry.Time)
e.entry.Timings.Receive = 0
har.Log.Entries = append(har.Log.Entries, e.entry)
}
out, err := json.MarshalIndent(har, "", " ")
if err != nil {
return "", fmt.Errorf("cdp har record: marshal: %w", err)
}
if actionErr != nil {
return string(out), fmt.Errorf("cdp har record: action: %w", actionErr)
}
return string(out), nil
}
+53
View File
@@ -0,0 +1,53 @@
---
name: cdp_har_record
kind: function
lang: go
domain: browser
version: 0.1.0
purity: impure
signature: "func CdpHarRecord(c *CDPConn, action func() error, settleMs int) (string, error)"
description: "Captura todas las peticiones HTTP/WS que ocurren durante la ejecucion de `action` y devuelve un archivo HAR 1.2 valido como JSON. Habilita Network.* events de CDP, suscribe handlers para requestWillBeSent / responseReceived / loadingFinished / loadingFailed, ejecuta la accion del usuario, espera settleMs para eventos rezagados, y construye el HAR. Body de respuesta no incluido en v0 (requiere Network.getResponseBody adicional)."
tags: [browser, cdp, har, network, capture, observability]
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, sync, time]
example: |
c, _ := browser.CdpConnect(9222)
defer browser.CdpClose(c, 0)
har, err := browser.CdpHarRecord(c, func() error {
return browser.CdpNavigate(c, "https://example.com")
}, 2000)
if err == nil {
os.WriteFile("page.har", []byte(har), 0644)
}
tested: true
tests: ["TestCdpHarRecord_nilConn"]
test_file_path: "functions/browser/cdp_har_record_test.go"
file_path: "functions/browser/cdp_har_record.go"
notes: |
- Activa Network.enable + Network.disable automaticamente; el caller no necesita gestionarlo.
- Habilita events para `Network.requestWillBeSent`, `responseReceived`, `loadingFinished`, `loadingFailed`.
- settleMs default 1500 — tiempo de espera tras `action()` para eventos trailing (loadingFinished tipicamente llega ~100-500ms despues que responseReceived).
- HAR resultante incluye headers de request/response, status, mimetype, IP servidor, timings totales (send/receive=0, wait=tiempo total — sin breakdown granular).
- `_requestId` y `_error` son extensiones opcionales de HAR (prefijo `_` permitido por la spec).
- Tipos `HarHeader`, `HarEntry`, `HarLog` exportados — apps consumidoras pueden re-parsear.
documentation: |
Patron de captura comun en Playwright/Puppeteer (`page.context().tracing`, `tracing.start({har})`).
Util para:
- Auditar que peticiones hace una pagina (third-party scripts, trackers).
- Reproducir trafico via mocks (HAR -> wiremock/mockttp).
- Debugging visual en navegator_dashboard panel Network.
- Tests e2e que validan cuantas peticiones / a que dominios.
params:
- name: c
desc: "Conexion CDP activa (CdpConnect)."
- name: action
desc: "Funcion a ejecutar mientras se graban eventos. Usualmente CdpNavigate + CdpWaitLoad. Puede ser nil (graba lo que pase pasivamente)."
- name: settleMs
desc: "Milisegundos a esperar tras `action` para recoger trailing events. <=0 → 1500."
output: "JSON HAR 1.2 indentado. Vacio entries si no hubo trafico. Error si Network.enable falla o action retorna error (ambos: HAR parcial + error)."
---
+35
View File
@@ -0,0 +1,35 @@
package browser
import (
"encoding/json"
"strings"
"testing"
)
func TestCdpHarRecord_nilConn(t *testing.T) {
if _, err := CdpHarRecord(nil, nil, 0); err == nil {
t.Fatal("expected error on nil conn")
}
}
func TestCdpHarRecord_emptyHarStructure(t *testing.T) {
// Construir un HAR vacio manualmente y verificar que serializa a la
// estructura HAR 1.2 esperada (campos top-level).
var har HarLog
har.Log.Version = "1.2"
har.Log.Creator.Name = "test"
har.Log.Creator.Version = "0"
har.Log.Pages = []any{}
har.Log.Entries = []HarEntry{}
out, err := json.Marshal(har)
if err != nil {
t.Fatalf("marshal: %v", err)
}
s := string(out)
for _, want := range []string{`"log":`, `"version":"1.2"`, `"creator":`, `"entries":[]`} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in: %s", want, s)
}
}
}
+131
View File
@@ -0,0 +1,131 @@
package browser
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// CdpTab representa una pestaña/target devuelta por el endpoint /json de CDP.
// Campos publicos para que apps consumidoras puedan filtrar/render.
type CdpTab struct {
ID string `json:"id"`
Type string `json:"type"` // "page", "iframe", "service_worker", ...
Title string `json:"title"`
URL string `json:"url"`
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
DevtoolsFrontendURL string `json:"devtoolsFrontendUrl,omitempty"`
}
// CdpListTabs llama GET http://{host}:{port}/json y retorna la lista de
// targets. Sin filtrar por tipo — el caller decide si se queda solo con
// type=="page", incluye iframes, etc.
//
// host vacio = "localhost". No requiere websocket (CDP expone /json en HTTP).
func CdpListTabs(host string, port int) ([]CdpTab, error) {
if host == "" {
host = "localhost"
}
resp, err := http.Get(fmt.Sprintf("http://%s:%d/json", host, port))
if err != nil {
return nil, fmt.Errorf("cdp list tabs: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cdp list tabs: status %d", resp.StatusCode)
}
var tabs []CdpTab
if err := json.NewDecoder(resp.Body).Decode(&tabs); err != nil {
return nil, fmt.Errorf("cdp list tabs: decode: %w", err)
}
return tabs, nil
}
// CdpNewTab abre una pestaña nueva via PUT /json/new?<startURL>. Si startURL
// es vacio Chrome abre about:blank. Retorna el CdpTab recien creado.
//
// Nota: desde Chrome 126 /json/new requiere PUT (no GET). Mantenemos el
// fallback a GET por compatibilidad con builds antiguos.
func CdpNewTab(host string, port int, startURL string) (CdpTab, error) {
if host == "" {
host = "localhost"
}
endpoint := fmt.Sprintf("http://%s:%d/json/new", host, port)
if startURL != "" {
endpoint += "?" + url.QueryEscape(startURL)
}
tryRequest := func(method string) (CdpTab, error) {
var out CdpTab
req, err := http.NewRequest(method, endpoint, nil)
if err != nil {
return out, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return out, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return out, fmt.Errorf("status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return out, fmt.Errorf("decode: %w", err)
}
return out, nil
}
tab, err := tryRequest(http.MethodPut)
if err == nil && tab.ID != "" {
return tab, nil
}
// Fallback GET (Chrome < 126).
tab, err2 := tryRequest(http.MethodGet)
if err2 == nil && tab.ID != "" {
return tab, nil
}
if err == nil {
err = err2
}
return CdpTab{}, fmt.Errorf("cdp new tab: %w", err)
}
// CdpCloseTab cierra una pestaña por su ID via GET /json/close/<id>.
// Util complemento — incluido aqui porque comparte estructura HTTP /json.
func CdpCloseTab(host string, port int, tabID string) error {
if host == "" {
host = "localhost"
}
if tabID == "" {
return fmt.Errorf("cdp close tab: tabID vacio")
}
resp, err := http.Get(fmt.Sprintf("http://%s:%d/json/close/%s", host, port, url.PathEscape(tabID)))
if err != nil {
return fmt.Errorf("cdp close tab: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("cdp close tab: status %d", resp.StatusCode)
}
return nil
}
// CdpActivateTab pone la pestaña en foreground (focus) via /json/activate/<id>.
func CdpActivateTab(host string, port int, tabID string) error {
if host == "" {
host = "localhost"
}
if tabID == "" {
return fmt.Errorf("cdp activate tab: tabID vacio")
}
resp, err := http.Get(fmt.Sprintf("http://%s:%d/json/activate/%s", host, port, url.PathEscape(tabID)))
if err != nil {
return fmt.Errorf("cdp activate tab: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("cdp activate tab: status %d", resp.StatusCode)
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
---
name: cdp_list_tabs
kind: function
lang: go
domain: browser
version: 0.1.0
purity: impure
signature: "func CdpListTabs(host string, port int) ([]CdpTab, error)"
description: "Lista las pestañas/targets de una instancia Chrome via GET /json. Sin websocket — solo HTTP. Util para apps que muestran el inventario de pestañas (dashboards, debuggers) o agentes que iteran tabs sin tener que abrir conexion CDP a cada una."
tags: [browser, cdp, tabs, listing, http]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, net/http, net/url]
example: |
tabs, err := browser.CdpListTabs("localhost", 9222)
if err == nil {
for _, t := range tabs {
if t.Type == "page" {
fmt.Println(t.ID, t.Title, t.URL)
}
}
}
tested: true
tests: ["TestCdpListTabs_emptyHost"]
test_file_path: "functions/browser/cdp_list_tabs_test.go"
file_path: "functions/browser/cdp_list_tabs.go"
notes: |
- Endpoint CDP es read-only HTTP, no requiere CdpConnect.
- Retorna TODOS los targets (page + iframe + service_worker + ...). Filtrar por type segun caso de uso.
- Tipo `CdpTab` es publico — apps externas pueden consumirlo.
- Mismo archivo expone CdpNewTab, CdpCloseTab, CdpActivateTab — operaciones HTTP /json/* relacionadas.
documentation: |
Wraps el endpoint clasico de Chrome DevTools Protocol /json. Misma estructura
que devuelve `chrome://inspect/#devices` o que consumen Puppeteer/Playwright
cuando se conectan a un Chrome existente.
params:
- name: host
desc: "Host CDP (vacio = localhost)."
- name: port
desc: "Puerto remote-debugging."
output: "Slice de CdpTab (id, type, title, url, webSocketDebuggerUrl). Error si HTTP falla o status != 200."
---
+32
View File
@@ -0,0 +1,32 @@
package browser
import "testing"
func TestCdpListTabs_emptyHost(t *testing.T) {
// Sin Chrome → error (puerto cerrado). Se valida que la funcion construye
// el request sin panic con host vacio.
_, err := CdpListTabs("", 1) // puerto 1 garantizado cerrado
if err == nil {
t.Fatal("expected error talking to closed port")
}
}
func TestCdpNewTab_emptyURLOk(t *testing.T) {
// Igual: sin servidor real esperamos error de red, pero NO panic ni nil-deref.
_, err := CdpNewTab("", 1, "")
if err == nil {
t.Fatal("expected error talking to closed port")
}
}
func TestCdpCloseTab_emptyID(t *testing.T) {
if err := CdpCloseTab("localhost", 9222, ""); err == nil {
t.Fatal("expected error on empty tabID")
}
}
func TestCdpActivateTab_emptyID(t *testing.T) {
if err := CdpActivateTab("localhost", 9222, ""); err == nil {
t.Fatal("expected error on empty tabID")
}
}
+42
View File
@@ -0,0 +1,42 @@
---
name: cdp_new_tab
kind: function
lang: go
domain: browser
version: 0.1.0
purity: impure
signature: "func CdpNewTab(host string, port int, startURL string) (CdpTab, error)"
description: "Abre una pestaña nueva via /json/new. Si startURL es vacio Chrome abre about:blank. Retorna el CdpTab recien creado con su id, webSocketDebuggerUrl, etc. Compatible con Chrome 126+ (PUT) y anteriores (fallback GET)."
tags: [browser, cdp, tabs, spawn]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, net/http, net/url]
example: |
tab, err := browser.CdpNewTab("localhost", 9222, "https://example.com")
if err == nil {
fmt.Println("nueva tab id=", tab.ID)
}
tested: true
tests: ["TestCdpNewTab_emptyURLOk"]
test_file_path: "functions/browser/cdp_list_tabs_test.go"
file_path: "functions/browser/cdp_list_tabs.go"
notes: |
- Definida en mismo archivo que CdpListTabs — comparten estructura.
- Desde Chrome 126 el endpoint requiere PUT. Mantenemos fallback a GET por compatibilidad.
- URL se codifica como query string raw (no clave=valor — formato historico de Chrome).
documentation: |
Util para abrir tabs de scraping bajo control programatico sin pasar por
WebSocket. Combina con CdpListTabs para enumerar antes/despues, y
CdpCloseTab para limpiar al final.
params:
- name: host
desc: "Host CDP (vacio = localhost)."
- name: port
desc: "Puerto remote-debugging."
- name: startURL
desc: "URL inicial. Vacio = about:blank."
output: "CdpTab del target recien creado (id, websocket url, ...). Error si HTTP falla."
---
+5 -14
View File
@@ -13,10 +13,13 @@ func CdpTypeText(c *CDPConn, text string) error {
return fmt.Errorf("cdp type text: conexion nula")
}
// keyDown (con `text`) ya inserta el caracter en el elemento focado en
// Chrome — enviar ademas un evento "char" lo duplicaba en sitios que
// reaccionan a `input` events (DuckDuckGo, Google, etc.). Patron
// alineado con Puppeteer page.type(): keyDown + (insert via text) + keyUp.
for _, ch := range text {
charStr := string(ch)
// keyDown
keyDown := map[string]any{
"type": "keyDown",
"key": charStr,
@@ -26,27 +29,15 @@ func CdpTypeText(c *CDPConn, text string) error {
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
// Pequena pausa entre caracteres para simular escritura humana.
time.Sleep(10 * time.Millisecond)
}