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:
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
---
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)."
|
||||
---
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
---
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
---
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user