feat(browser): auto-commit con 60 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// findByTextCoreJS es el preludio JS compartido por las dos evaluaciones de
|
||||
// CdpFindRefByText: define norm/matches/leafmost y la lista de nodos candidatos.
|
||||
// Mismo algoritmo "leafmost" que CdpFindByText: prefiere el elemento más interno
|
||||
// que matchea (donde suele vivir el handler), no el contenedor que lo envuelve.
|
||||
const findByTextCoreJS = `
|
||||
var P = %s;
|
||||
var target = P.cs ? P.text : P.text.toLowerCase();
|
||||
var nodes = document.querySelectorAll(P.tag || '*');
|
||||
function norm(v) {
|
||||
v = (v || '').replace(/\s+/g, ' ').trim();
|
||||
return P.cs ? v : v.toLowerCase();
|
||||
}
|
||||
function matches(el) {
|
||||
var v = norm(el.innerText || el.textContent || '');
|
||||
return P.exact ? v === target : v.indexOf(target) >= 0;
|
||||
}
|
||||
function leafmost(el) {
|
||||
for (var i = 0; i < el.children.length; i++) {
|
||||
if (matches(el.children[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}`
|
||||
|
||||
// parseBackendNodeID extrae node.backendNodeId de la respuesta de DOM.describeNode.
|
||||
// Es puro: recibe el mapa ya deserializado por CDP y devuelve el id entero, o un
|
||||
// error claro si la estructura no es la esperada (nodo destruido, respuesta vacía).
|
||||
func parseBackendNodeID(resp map[string]any) (int, error) {
|
||||
node, ok := resp["node"].(map[string]any)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("describeNode: respuesta sin campo node")
|
||||
}
|
||||
raw, ok := node["backendNodeId"]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("describeNode: node sin backendNodeId")
|
||||
}
|
||||
f, ok := raw.(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("describeNode: backendNodeId tipo inesperado %T", raw)
|
||||
}
|
||||
return int(f), nil
|
||||
}
|
||||
|
||||
// CdpFindRefByText busca el primer elemento cuyo innerText matchea `text` y
|
||||
// devuelve su backendDOMNodeId — el mismo identificador estable (#ref) que
|
||||
// produce el outline de page_perceive y que consume CdpClickRef. Así se puede
|
||||
// hacer click-by-text sin pasar por un selector CSS frágil (nth-of-type).
|
||||
//
|
||||
// Retorna (backendNodeID, count, error):
|
||||
// - backendNodeID: ref del primer match, listo para CdpClickRef/CdpHoverRef.
|
||||
// - count: número total de elementos que matchean (tras el filtro leafmost).
|
||||
// count > 1 indica ambigüedad: el caller decide si refinar la búsqueda.
|
||||
// - error: si la conexión es nula, el texto vacío, el eval JS falla o no hay
|
||||
// ningún match (count == 0).
|
||||
//
|
||||
// Identidad unificada con el puente backendDOMNodeId: resuelve el nodo JS a un
|
||||
// RemoteObject (Runtime.evaluate returnByValue=false) y de ahí al nodo DOM
|
||||
// (DOM.describeNode), evitando el round-trip por selector CSS.
|
||||
func CdpFindRefByText(c *CDPConn, text string, opts FindByTextOpts) (int, int, error) {
|
||||
if c == nil {
|
||||
return 0, 0, fmt.Errorf("cdp find ref by text: conexion nula")
|
||||
}
|
||||
if text == "" {
|
||||
return 0, 0, fmt.Errorf("cdp find ref by text: texto vacio")
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"text": text,
|
||||
"tag": opts.Tag,
|
||||
"exact": opts.Exact,
|
||||
"cs": opts.CaseSensitive,
|
||||
})
|
||||
core := fmt.Sprintf(findByTextCoreJS, string(payload))
|
||||
|
||||
// 1. Contar matches (returnByValue=true vía CdpEvaluate).
|
||||
countJS := "(function(){" + core + `
|
||||
var n = 0;
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
if (matches(nodes[i]) && leafmost(nodes[i])) n++;
|
||||
}
|
||||
return n;
|
||||
})()`
|
||||
countStr, err := CdpEvaluate(c, countJS)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("cdp find ref by text: contar matches: %w", err)
|
||||
}
|
||||
count, _ := strconv.Atoi(strings.TrimSpace(countStr))
|
||||
if count == 0 {
|
||||
return 0, 0, fmt.Errorf("cdp find ref by text: no se encontro elemento con texto %q", text)
|
||||
}
|
||||
|
||||
// 2. Resolver el primer match a un RemoteObject (returnByValue=false para
|
||||
// obtener un objectId que apunta al nodo DOM vivo).
|
||||
elJS := "(function(){" + core + `
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
if (matches(nodes[i]) && leafmost(nodes[i])) return nodes[i];
|
||||
}
|
||||
return null;
|
||||
})()`
|
||||
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
|
||||
"expression": elJS,
|
||||
"returnByValue": false,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: evaluate elemento: %w", err)
|
||||
}
|
||||
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
|
||||
excMap, _ := exc.(map[string]any)
|
||||
txt, _ := excMap["text"].(string)
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: excepcion JS: %s", txt)
|
||||
}
|
||||
remote, ok := evRes["result"].(map[string]any)
|
||||
if !ok {
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: respuesta evaluate sin result")
|
||||
}
|
||||
objID, _ := remote["objectId"].(string)
|
||||
if objID == "" {
|
||||
// El conteo dio >0 pero el elemento desapareció entre ambos evals (DOM
|
||||
// mutó): tratamos como no encontrado para no devolver un ref inválido.
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: elemento volátil, sin objectId (el DOM cambió entre conteo y resolución)")
|
||||
}
|
||||
|
||||
// 3. Del RemoteObject al nodo DOM: backendNodeId.
|
||||
dn, err := c.sendCDP("DOM.describeNode", map[string]any{"objectId": objID})
|
||||
if err != nil {
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: describeNode: %w", err)
|
||||
}
|
||||
backendNodeID, err := parseBackendNodeID(dn)
|
||||
if err != nil {
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: %w", err)
|
||||
}
|
||||
return backendNodeID, count, nil
|
||||
}
|
||||
Reference in New Issue
Block a user