feat(browser): actionability + dropdowns + fill + role locator (estilo Playwright)
Tras estudiar el código de Playwright (sources/playwright), 4 primitivas nuevas y
1 endurecida para que la interacción web sea fiable:
- cdp_wait_actionable: visible + stable (2 rAF) + enabled + hit-test (elementFromPoint
cruzando shadow DOM) + retry backoff + scroll cycling. Devuelve el punto validado.
Réplica de _retryAction/_checkElementIsStable/expectHitTarget de Playwright.
- cdp_select_dropdown: desplegables custom (combobox/MUI/select2/headlessui): click real
en trigger -> espera apertura (aria-expanded/[role=option] visible) -> click real en
la opción. Resuelve el fallo nº1: clicar antes de que monte el listbox.
- cdp_select_option (endurecida v1.1.0): valida <select> real, match value/label
normalizado/índice, option.selected para multiple, eventos input{composed}+change.
- cdp_fill: escribir fiable en inputs React/Vue: focus -> select-all -> Input.insertText
(sin native value setter, como Playwright); native setter solo para inputs especiales.
- cdp_find_by_role: localizar por rol ARIA + accessible name (estilo getByRole),
reutilizando el AX tree de cdp_get_ax_outline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,298 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fillNodeInfo es el diagnostico que devuelve fillPrepare tras inspeccionar y
|
||||||
|
// preparar el nodo en el contexto JS de la pagina. Replica la logica de
|
||||||
|
// InjectedScript.fill de Playwright sin usar el "native value setter": para los
|
||||||
|
// campos de texto/contenteditable selecciona el contenido previo y deja que el
|
||||||
|
// motor inserte el valor con eventos confiables (ruta needsinput); para los
|
||||||
|
// inputs especiales fija el valor y dispara los eventos (ruta setvalue).
|
||||||
|
type fillNodeInfo struct {
|
||||||
|
// Route es "needsinput" (hay que insertar el valor via Input.insertText),
|
||||||
|
// "setvalue" (ya se fijo el valor + eventos, nada mas que hacer) o "" si hubo error.
|
||||||
|
Route string `json:"route"`
|
||||||
|
// Error describe por que el nodo no se puede rellenar (no editable, readonly,
|
||||||
|
// disabled, oculto, tipo no soportado). Vacio si todo OK.
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveObjectID resuelve un backendDOMNodeId a un Runtime objectId, para poder
|
||||||
|
// ejecutar JS con `this` apuntando a ese nodo concreto via Runtime.callFunctionOn.
|
||||||
|
func resolveObjectID(c *CDPConn, backendNodeID int) (string, error) {
|
||||||
|
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolveNode ref %d: %w", backendNodeID, err)
|
||||||
|
}
|
||||||
|
obj, _ := res["object"].(map[string]any)
|
||||||
|
objID, _ := obj["objectId"].(string)
|
||||||
|
if objID == "" {
|
||||||
|
return "", fmt.Errorf("sin objectId para ref %d", backendNodeID)
|
||||||
|
}
|
||||||
|
return objID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callFunctionOnJSON ejecuta functionDeclaration con `this` = objectId, pasando
|
||||||
|
// args como argumentos posicionales, y deserializa el valor de retorno (por valor)
|
||||||
|
// en out. La funcion JS debe devolver un objeto serializable.
|
||||||
|
func callFunctionOnJSON(c *CDPConn, objectID, functionDeclaration string, args []any, out any) error {
|
||||||
|
callArgs := make([]any, len(args))
|
||||||
|
for i, a := range args {
|
||||||
|
callArgs[i] = map[string]any{"value": a}
|
||||||
|
}
|
||||||
|
res, err := c.sendCDP("Runtime.callFunctionOn", map[string]any{
|
||||||
|
"objectId": objectID,
|
||||||
|
"functionDeclaration": functionDeclaration,
|
||||||
|
"arguments": callArgs,
|
||||||
|
"returnByValue": true,
|
||||||
|
"awaitPromise": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exc, ok := res["exceptionDetails"]; ok && exc != nil {
|
||||||
|
excMap, _ := exc.(map[string]any)
|
||||||
|
text, _ := excMap["text"].(string)
|
||||||
|
return fmt.Errorf("excepcion JS: %s", text)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resVal, ok := res["result"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("resultado inesperado: %v", res)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(resVal["value"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal valor de retorno: %w", err)
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillPrepareJS es la funcion JS (con `this` = elemento) que valida editabilidad,
|
||||||
|
// detecta el tipo y prepara el nodo. Replica InjectedScript.fill de Playwright:
|
||||||
|
// NO usa el native value setter para text/textarea/contenteditable (selecciona el
|
||||||
|
// valor previo y devuelve "needsinput" para que Input.insertText, con eventos
|
||||||
|
// confiables del motor, haga que React/Vue reconcilien solos). Para inputs
|
||||||
|
// especiales fija el valor y dispara input/change con {bubbles, composed}.
|
||||||
|
//
|
||||||
|
// arg[0] = value (string).
|
||||||
|
const fillPrepareJS = `function(value){
|
||||||
|
var el = this;
|
||||||
|
if (!el || el.nodeType !== 1) return {route:"", error:"el #ref no es un elemento"};
|
||||||
|
// Visibilidad: rect con area + no display:none/visibility:hidden.
|
||||||
|
var rect = el.getBoundingClientRect();
|
||||||
|
var style = el.ownerDocument.defaultView.getComputedStyle(el);
|
||||||
|
if (style.visibility === "hidden" || style.display === "none" || (rect.width === 0 && rect.height === 0))
|
||||||
|
return {route:"", error:"elemento no visible"};
|
||||||
|
var tag = el.nodeName.toLowerCase();
|
||||||
|
if (tag === "input") {
|
||||||
|
var type = (el.type || "text").toLowerCase();
|
||||||
|
if (el.disabled) return {route:"", error:"input deshabilitado"};
|
||||||
|
if (el.readOnly) return {route:"", error:"input es readonly"};
|
||||||
|
var kSetValue = {color:1, date:1, time:1, "datetime-local":1, month:1, range:1, week:1};
|
||||||
|
var kTypeInto = {"":1, email:1, number:1, password:1, search:1, tel:1, text:1, url:1};
|
||||||
|
if (!kTypeInto[type] && !kSetValue[type])
|
||||||
|
return {route:"", error:"input de tipo '"+type+"' no se puede rellenar"};
|
||||||
|
if (type === "number") {
|
||||||
|
value = value.trim();
|
||||||
|
if (value !== "" && isNaN(Number(value)))
|
||||||
|
return {route:"", error:"no se puede escribir texto en input[type=number]"};
|
||||||
|
}
|
||||||
|
if (type === "color") value = value.toLowerCase();
|
||||||
|
if (kSetValue[type]) {
|
||||||
|
value = value.trim();
|
||||||
|
el.focus();
|
||||||
|
el.value = value;
|
||||||
|
if (el.value !== value) return {route:"", error:"valor malformado para input[type="+type+"]"};
|
||||||
|
el.dispatchEvent(new Event("input", {bubbles:true, composed:true}));
|
||||||
|
el.dispatchEvent(new Event("change", {bubbles:true}));
|
||||||
|
return {route:"setvalue", error:""};
|
||||||
|
}
|
||||||
|
// Ruta needsinput: seleccionar el valor previo para que insertText lo reemplace.
|
||||||
|
el.select();
|
||||||
|
el.focus();
|
||||||
|
return {route:"needsinput", error:""};
|
||||||
|
}
|
||||||
|
if (tag === "textarea") {
|
||||||
|
if (el.disabled) return {route:"", error:"textarea deshabilitado"};
|
||||||
|
if (el.readOnly) return {route:"", error:"textarea es readonly"};
|
||||||
|
el.selectionStart = 0;
|
||||||
|
el.selectionEnd = el.value.length;
|
||||||
|
el.focus();
|
||||||
|
return {route:"needsinput", error:""};
|
||||||
|
}
|
||||||
|
if (el.isContentEditable) {
|
||||||
|
el.focus();
|
||||||
|
var range = el.ownerDocument.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
var sel = el.ownerDocument.defaultView.getSelection();
|
||||||
|
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
|
||||||
|
return {route:"needsinput", error:""};
|
||||||
|
}
|
||||||
|
return {route:"", error:"el elemento no es input, textarea ni [contenteditable]"};
|
||||||
|
}`
|
||||||
|
|
||||||
|
// fillVerifyJS lee el valor actual del nodo (input.value/textarea.value o
|
||||||
|
// textContent de contenteditable) para verificar que el fill surtio efecto.
|
||||||
|
// arg[0] = expected (string). Devuelve {ok:bool, got:string, verifiable:bool}.
|
||||||
|
const fillVerifyJS = `function(expected){
|
||||||
|
var el = this;
|
||||||
|
var tag = el.nodeName.toLowerCase();
|
||||||
|
if (tag === "input" || tag === "textarea") {
|
||||||
|
var type = tag === "input" ? (el.type||"text").toLowerCase() : "text";
|
||||||
|
var got = String(el.value);
|
||||||
|
var exp = expected;
|
||||||
|
if (type === "number" || type === "color" || type === "date" || type === "time" ||
|
||||||
|
type === "datetime-local" || type === "month" || type === "range" || type === "week") {
|
||||||
|
exp = expected.trim();
|
||||||
|
if (type === "color") exp = exp.toLowerCase();
|
||||||
|
}
|
||||||
|
return {ok: got === exp, got: got, verifiable: true};
|
||||||
|
}
|
||||||
|
// contenteditable: no verificable de forma fiable (el motor normaliza el HTML).
|
||||||
|
return {ok: true, got: String(el.textContent||""), verifiable: false};
|
||||||
|
}`
|
||||||
|
|
||||||
|
// CdpFill rellena un campo de texto controlado por frameworks (React/Vue) de
|
||||||
|
// forma robusta, estilo Playwright. backendNodeID es un backendDOMNodeId (el #ref
|
||||||
|
// del AX outline de page_perceive).
|
||||||
|
//
|
||||||
|
// Comportamiento (replica InjectedScript.fill):
|
||||||
|
// 1. Valida visible + enabled + editable (no readonly/disabled) en el contexto JS.
|
||||||
|
// 2. Enfoca el nodo.
|
||||||
|
// 3. Detecta el tipo:
|
||||||
|
// - text/textarea/email/search/url/tel/password/number/contenteditable: ruta
|
||||||
|
// "needsinput" — selecciona el valor previo y luego inserta value con
|
||||||
|
// Input.insertText (eventos input/beforeinput confiables del motor; React/Vue
|
||||||
|
// reconcilian solos). Con value=="" borra la seleccion (Delete) en vez de insertar.
|
||||||
|
// - color/date/time/datetime-local/month/range/week: ruta "setvalue" — fija
|
||||||
|
// el.value y dispara input{bubbles,composed} + change{bubbles}.
|
||||||
|
// 4. Verifica que el.value === value al final (casos verificables); si no, error.
|
||||||
|
//
|
||||||
|
// A diferencia del patron focus+type que concatena al valor existente, CdpFill
|
||||||
|
// reemplaza el contenido entero y es fiable con inputs controlados por frameworks.
|
||||||
|
func CdpFill(c *CDPConn, backendNodeID int, value string) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("cdp fill: conexion nula")
|
||||||
|
}
|
||||||
|
|
||||||
|
objID, err := resolveObjectID(c, backendNodeID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cdp fill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enfocar el nodo (idempotente; fillPrepareJS tambien enfoca, pero DOM.focus
|
||||||
|
// hace scroll-into-view y deja el activeElement listo para Input.insertText).
|
||||||
|
if _, err := c.sendCDP("DOM.focus", map[string]any{"backendNodeId": backendNodeID}); err != nil {
|
||||||
|
return fmt.Errorf("cdp fill: focus ref %d: %w", backendNodeID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar + preparar el nodo (selecciona valor previo o fija value+eventos).
|
||||||
|
var info fillNodeInfo
|
||||||
|
if err := callFunctionOnJSON(c, objID, fillPrepareJS, []any{value}, &info); err != nil {
|
||||||
|
return fmt.Errorf("cdp fill: preparar ref %d: %w", backendNodeID, err)
|
||||||
|
}
|
||||||
|
if info.Error != "" {
|
||||||
|
return fmt.Errorf("cdp fill: ref %d no editable: %s", backendNodeID, info.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch info.Route {
|
||||||
|
case "setvalue":
|
||||||
|
// El valor ya se fijo y se dispararon los eventos en fillPrepareJS.
|
||||||
|
case "needsinput":
|
||||||
|
if value == "" {
|
||||||
|
// Sin valor: borrar la seleccion (el valor previo ya esta seleccionado).
|
||||||
|
// Delete elimina la seleccion sin insertar nada.
|
||||||
|
del := map[string]any{"type": "keyDown", "key": "Delete", "code": "Delete", "windowsVirtualKeyCode": 46}
|
||||||
|
if _, err := c.sendCDP("Input.dispatchKeyEvent", del); err != nil {
|
||||||
|
return fmt.Errorf("cdp fill: borrar ref %d: %w", backendNodeID, err)
|
||||||
|
}
|
||||||
|
delUp := map[string]any{"type": "keyUp", "key": "Delete", "code": "Delete", "windowsVirtualKeyCode": 46}
|
||||||
|
if _, err := c.sendCDP("Input.dispatchKeyEvent", delUp); err != nil {
|
||||||
|
return fmt.Errorf("cdp fill: borrar ref %d: %w", backendNodeID, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Insertar el valor (reemplaza la seleccion previa) en un round-trip.
|
||||||
|
// Input.insertText emite los eventos confiables que React/Vue necesitan.
|
||||||
|
if _, err := c.sendCDP("Input.insertText", map[string]any{"text": value}); err != nil {
|
||||||
|
return fmt.Errorf("cdp fill: insertText ref %d: %w", backendNodeID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cdp fill: ruta de preparacion desconocida %q para ref %d", info.Route, backendNodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el valor cuajo (solo casos verificables: input/textarea).
|
||||||
|
var ver struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Got string `json:"got"`
|
||||||
|
Verifiable bool `json:"verifiable"`
|
||||||
|
}
|
||||||
|
if err := callFunctionOnJSON(c, objID, fillVerifyJS, []any{value}, &ver); err != nil {
|
||||||
|
// La verificacion en si fallo (nodo desaparecido, etc.): no enmascarar.
|
||||||
|
return fmt.Errorf("cdp fill: verificar ref %d: %w", backendNodeID, err)
|
||||||
|
}
|
||||||
|
if ver.Verifiable && !ver.OK {
|
||||||
|
return fmt.Errorf("cdp fill: verificacion fallida en ref %d: el campo quedo con %q, se esperaba %q", backendNodeID, ver.Got, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CdpFillSelector resuelve un selector CSS a su backendDOMNodeId (via
|
||||||
|
// DOM.getDocument + DOM.querySelector + DOM.describeNode) y delega en CdpFill.
|
||||||
|
// Util cuando se tiene un selector estable en vez del #ref del AX outline.
|
||||||
|
func CdpFillSelector(c *CDPConn, selector string, value string) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("cdp fill selector: conexion nula")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(selector) == "" {
|
||||||
|
return fmt.Errorf("cdp fill selector: selector vacio")
|
||||||
|
}
|
||||||
|
|
||||||
|
docRes, err := c.sendCDP("DOM.getDocument", map[string]any{"depth": 0})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cdp fill selector: DOM.getDocument: %w", err)
|
||||||
|
}
|
||||||
|
root, ok := docRes["root"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cdp fill selector: respuesta de DOM.getDocument sin root")
|
||||||
|
}
|
||||||
|
rootNodeID, ok := root["nodeId"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cdp fill selector: DOM.getDocument sin nodeId raiz")
|
||||||
|
}
|
||||||
|
|
||||||
|
qsRes, err := c.sendCDP("DOM.querySelector", map[string]any{
|
||||||
|
"nodeId": int(rootNodeID),
|
||||||
|
"selector": selector,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cdp fill selector: DOM.querySelector %q: %w", selector, err)
|
||||||
|
}
|
||||||
|
nodeIDVal, ok := qsRes["nodeId"].(float64)
|
||||||
|
if !ok || int(nodeIDVal) == 0 {
|
||||||
|
return fmt.Errorf("cdp fill selector: el selector %q no coincide con ningun elemento", selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver el nodeId a backendNodeId (CdpFill opera sobre backendDOMNodeId).
|
||||||
|
descRes, err := c.sendCDP("DOM.describeNode", map[string]any{"nodeId": int(nodeIDVal)})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cdp fill selector: DOM.describeNode %q: %w", selector, err)
|
||||||
|
}
|
||||||
|
node, ok := descRes["node"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cdp fill selector: DOM.describeNode %q sin node", selector)
|
||||||
|
}
|
||||||
|
backendID, ok := node["backendNodeId"].(float64)
|
||||||
|
if !ok || int(backendID) == 0 {
|
||||||
|
return fmt.Errorf("cdp fill selector: %q sin backendNodeId", selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CdpFill(c, int(backendID), value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
name: cdp_fill
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpFill(c *CDPConn, backendNodeID int, value string) error"
|
||||||
|
description: "Rellena un campo de texto de forma robusta estilo Playwright, fiable con inputs controlados por frameworks (React/Vue). Valida visible+enabled+editable, enfoca el nodo, y según el tipo: para text/textarea/email/search/url/tel/password/number/contenteditable selecciona el valor previo y lo reemplaza con Input.insertText (eventos input/beforeinput confiables del motor — React/Vue reconcilian solos); para inputs especiales (color/date/time/range/week/month/datetime-local) fija el.value y dispara input{bubbles,composed}+change{bubbles}. Verifica que el.value===value al final. backendNodeID es el #ref del AX outline. Variante por selector: CdpFillSelector. Reemplaza el patrón frágil focus+type que concatena al valor existente."
|
||||||
|
tags: [cdp, browser, action, ref, fill, form, react, vue, navegator]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "Conexión CDP activa al tab objetivo (*CDPConn)."
|
||||||
|
- name: backendNodeID
|
||||||
|
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||||
|
- name: value
|
||||||
|
desc: "Valor a poner en el campo. Reemplaza el contenido entero (no concatena). value=='' borra el campo. Para input[type=number] debe ser numérico; para color se normaliza a minúsculas."
|
||||||
|
output: "nil si el campo quedó con el valor pedido; error si la conexión es nil, el nodo no es editable (readonly/disabled/oculto), el tipo de input no se puede rellenar, o la verificación final (el.value===value) falla."
|
||||||
|
file_path: "functions/browser/cdp_fill.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Tras un page_perceive que devuelve un <input> React con #ref=4521:
|
||||||
|
conn, _ := CdpConnect(9222)
|
||||||
|
|
||||||
|
// Por #ref del AX outline (camino habitual del bucle percibir→actuar):
|
||||||
|
if err := CdpFill(conn, 4521, "ada@example.com"); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Por selector CSS estable (resuelve a backendNodeID y delega en CdpFill):
|
||||||
|
if err := CdpFillSelector(conn, "input[name='email']", "ada@example.com"); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vaciar un campo:
|
||||||
|
_ = CdpFillSelector(conn, "#search", "")
|
||||||
|
|
||||||
|
// Input especial (date): ruta setvalue + eventos input/change:
|
||||||
|
_ = CdpFillSelector(conn, "input[type='date']", "2026-06-16")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites rellenar inputs de formularios controlados por React/Vue/otros frameworks de forma fiable. Es el reemplazo del patrón `DOM.focus` + `CdpTypeText`/`CdpInsertText` que **concatena** al valor existente y a menudo deja el estado del framework desincronizado (el `value` del DOM cambia pero el estado de React no, o al revés). `CdpFill` selecciona y reemplaza el contenido entero y, al usar `Input.insertText` (no el native value setter), emite los eventos `input`/`beforeinput` confiables que hacen que el framework reconcilie su estado. Úsala para login, registro, búsquedas y cualquier campo donde el patrón focus+type falle o duplique texto. Para teclear carácter a carácter simulando un humano (sitios con detección por pulsación o autocompletes estrictos) sigue prefiriendo `CdpTypeRef` (camino human).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir (`page_perceive`) antes de actuar.
|
||||||
|
- **contenteditable**: la ruta needsinput inserta el valor seleccionando todo el contenido, pero la verificación final **no es fiable** para contenteditable (el motor normaliza el HTML). Por eso para contenteditable `CdpFill` no falla por verificación; confía en que `Input.insertText` cuajó. Si necesitas garantía dura del contenido, léelo aparte con `CdpEvaluate`.
|
||||||
|
- **Inputs especiales** (color/date/time/datetime-local/month/range/week) van por la ruta setvalue: fijan `el.value` y disparan `input`{bubbles,composed}+`change`{bubbles}. Algunos frameworks que escuchan eventos de teclado en estos inputs pueden no reaccionar — es el mismo trade-off que hace Playwright.
|
||||||
|
- **input[type=number]**: el valor debe ser numérico (`isNaN` lo rechaza con error claro). Espacios se recortan.
|
||||||
|
- **Frameworks y el evento nativo**: la clave de la robustez es NO usar el "native value setter" (`Object.getOwnPropertyDescriptor(...).set`). React parchea el setter de `value` y se confunde si lo invocas a mano; `Input.insertText` del motor emite los eventos que React intercepta correctamente. Si una versión muy vieja de un framework custom no reacciona, cae a `CdpTypeRef` (char por char).
|
||||||
|
- **No hace scroll humanizado**: `DOM.focus` hace scroll-into-view del nodo, pero si el input está dentro de un contenedor con scroll propio y oculto, valida visible y puede fallar con "elemento no visible". En ese caso haz `CdpClickRef` (que hace `scrollIntoViewIfNeeded`) antes.
|
||||||
|
- **value==""** borra el campo enviando `Delete` sobre la selección previa (no `Input.insertText` con cadena vacía, que sería no-op). Esto dispara los eventos de borrado que el framework espera.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CdpFindByRoleOpts configura el matching del accessible name de CdpFindByRole.
|
||||||
|
// Si Name == "", solo se filtra por role (cualquier name vale).
|
||||||
|
type CdpFindByRoleOpts struct {
|
||||||
|
// Name es el accessible name a matchear. Vacio = no filtra por name.
|
||||||
|
Name string
|
||||||
|
// Exact: true = el name normalizado debe ser igual al buscado.
|
||||||
|
// false (default) = el name normalizado contiene el buscado (substring).
|
||||||
|
Exact bool
|
||||||
|
// Regex: true = Name se interpreta como expresion regular (RE2 de Go).
|
||||||
|
// Tiene prioridad sobre Exact si ambos estan a true.
|
||||||
|
Regex bool
|
||||||
|
// CaseSensitive: false (default) = comparacion insensible a mayusculas.
|
||||||
|
// Para Regex, false añade el flag (?i) a la expresion.
|
||||||
|
CaseSensitive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeWhiteSpace replica la regla de Playwright (utils/isomorphic/stringUtils.ts):
|
||||||
|
// elimina el zero-width space (U+200B) y el soft hyphen (U+00AD), recorta extremos y
|
||||||
|
// colapsa cualquier run de whitespace a un unico espacio. Es la normalizacion que
|
||||||
|
// Playwright aplica a ambos lados al comparar el accessible name (getByRole({name})),
|
||||||
|
// para que diferencias de whitespace/caracteres invisibles no rompan el match.
|
||||||
|
func normalizeWhiteSpace(s string) string {
|
||||||
|
// Strip zero-width space y soft hyphen.
|
||||||
|
s = strings.ReplaceAll(s, "", "")
|
||||||
|
s = strings.ReplaceAll(s, "", "")
|
||||||
|
// Colapsar runs de whitespace a un espacio.
|
||||||
|
s = whitespaceRun.ReplaceAllString(s, " ")
|
||||||
|
// Trim de extremos.
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// whitespaceRun matchea uno o mas caracteres de espacio en blanco. Equivale a
|
||||||
|
// `\s+` de la regex de normalizeWhiteSpace de Playwright.
|
||||||
|
var whitespaceRun = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
// CdpFindByRole localiza el primer elemento por su ROLE ARIA y, opcionalmente, su
|
||||||
|
// accessible name — el equivalente a getByRole de Playwright. Reutiliza el AX tree
|
||||||
|
// que ya pedimos para page_perceive (Accessibility.getFullAXTree) en vez de tocar el
|
||||||
|
// DOM/CSS, lo que la hace robusta a cambios de markup/estilos.
|
||||||
|
//
|
||||||
|
// Recorre los nodos del AX tree y matchea:
|
||||||
|
// - role: igualdad exacta del rol ARIA (ej "button", "link", "textbox").
|
||||||
|
// - name (si opts.Name != ""): el accessible name del nodo contra opts.Name, con
|
||||||
|
// normalizeWhiteSpace aplicado a ambos lados (regla Playwright). Por defecto es
|
||||||
|
// substring; Exact => igualdad; Regex => expresion regular. Insensible a
|
||||||
|
// mayusculas salvo CaseSensitive.
|
||||||
|
//
|
||||||
|
// Retorna (ref, count, error):
|
||||||
|
// - ref: backendDOMNodeId del primer match — el mismo #ref que produce el outline
|
||||||
|
// de page_perceive y que consume CdpClickRef/CdpHoverRef.
|
||||||
|
// - count: numero total de nodos que matchean. count > 1 indica ambiguedad: el
|
||||||
|
// caller decide si refinar (Name mas especifico, Exact, etc.).
|
||||||
|
// - error: conexion nula, role vacio, regex invalida, fallo CDP, o 0 matches.
|
||||||
|
func CdpFindByRole(c *CDPConn, role string, opts CdpFindByRoleOpts) (ref int, count int, err error) {
|
||||||
|
if c == nil {
|
||||||
|
return 0, 0, fmt.Errorf("cdp find by role: conexion nula")
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
return 0, 0, fmt.Errorf("cdp find by role: role vacio")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir el matcher del name una sola vez (compila la regex si aplica).
|
||||||
|
matchName, err := buildNameMatcher(opts)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("cdp find by role: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accessibility.enable (idempotente, cacheado) antes de getFullAXTree.
|
||||||
|
if err := c.ensureAX(); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("cdp find by role: Accessibility.enable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := c.sendCDP("Accessibility.getFullAXTree", nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("cdp find by role: Accessibility.getFullAXTree: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := axoParseNodes(res)
|
||||||
|
|
||||||
|
firstRef := 0
|
||||||
|
haveFirst := false
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.ignored {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n.role != role {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if opts.Name != "" && !matchName(n.name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if !haveFirst {
|
||||||
|
// axoRefID prefiere backendDOMNodeID; ese es el ref que consume CdpClickRef.
|
||||||
|
if id, ok := atoiRef(axoRefID(n)); ok {
|
||||||
|
firstRef = id
|
||||||
|
haveFirst = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
if opts.Name != "" {
|
||||||
|
return 0, 0, fmt.Errorf("cdp find by role: no element with role %q and name %q", role, opts.Name)
|
||||||
|
}
|
||||||
|
return 0, 0, fmt.Errorf("cdp find by role: no element with role %q", role)
|
||||||
|
}
|
||||||
|
if !haveFirst {
|
||||||
|
// Hubo matches pero ninguno tenia un ref entero usable (backendDOMNodeId
|
||||||
|
// ausente y nodeId no numerico): no podemos devolver un #ref valido.
|
||||||
|
return 0, count, fmt.Errorf("cdp find by role: %d match(es) para role %q pero sin backendDOMNodeId usable", count, role)
|
||||||
|
}
|
||||||
|
return firstRef, count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildNameMatcher devuelve la funcion que decide si un accessible name candidato
|
||||||
|
// matchea opts.Name, normalizando ambos lados con normalizeWhiteSpace. Si Name == ""
|
||||||
|
// el matcher siempre es true (no se filtra por name). Compila la regex una vez.
|
||||||
|
func buildNameMatcher(opts CdpFindByRoleOpts) (func(candidate string) bool, error) {
|
||||||
|
if opts.Name == "" {
|
||||||
|
return func(string) bool { return true }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
want := normalizeWhiteSpace(opts.Name)
|
||||||
|
|
||||||
|
if opts.Regex {
|
||||||
|
pat := opts.Name
|
||||||
|
if !opts.CaseSensitive {
|
||||||
|
pat = "(?i)" + pat
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(pat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("regex invalida %q: %w", opts.Name, err)
|
||||||
|
}
|
||||||
|
return func(candidate string) bool {
|
||||||
|
return re.MatchString(normalizeWhiteSpace(candidate))
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.CaseSensitive {
|
||||||
|
want = strings.ToLower(want)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(candidate string) bool {
|
||||||
|
got := normalizeWhiteSpace(candidate)
|
||||||
|
if !opts.CaseSensitive {
|
||||||
|
got = strings.ToLower(got)
|
||||||
|
}
|
||||||
|
if opts.Exact {
|
||||||
|
return got == want
|
||||||
|
}
|
||||||
|
return strings.Contains(got, want)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// atoiRef convierte el ref string (backendDOMNodeId, ya normalizado a entero-string
|
||||||
|
// por axoStr) a int. Devuelve (0, false) si no es un entero parseable.
|
||||||
|
func atoiRef(s string) (int, bool) {
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
neg := false
|
||||||
|
i := 0
|
||||||
|
if s[0] == '-' {
|
||||||
|
neg = true
|
||||||
|
i = 1
|
||||||
|
if len(s) == 1 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for ; i < len(s); i++ {
|
||||||
|
ch := s[i]
|
||||||
|
if ch < '0' || ch > '9' {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n = n*10 + int(ch-'0')
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: cdp_find_by_role
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpFindByRole(c *CDPConn, role string, opts CdpFindByRoleOpts) (ref int, count int, err error)"
|
||||||
|
description: "Localiza el primer elemento por su ROLE ARIA + accessible name (estilo getByRole de Playwright) reusando el AX tree (Accessibility.getFullAXTree). Devuelve el backendDOMNodeId (#ref) del primer match y el total de matches para detectar ambiguedad."
|
||||||
|
tags: [browser]
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "Conexion CDP viva (*CDPConn) del pool. nil => error."
|
||||||
|
- name: role
|
||||||
|
desc: "Rol ARIA exacto a matchear (ej 'button', 'link', 'textbox', 'checkbox')."
|
||||||
|
- name: opts
|
||||||
|
desc: "CdpFindByRoleOpts: Name (accessible name, vacio = no filtra), Exact (igualdad en vez de substring), Regex (Name como expresion regular RE2), CaseSensitive (default false)."
|
||||||
|
output: "(ref int, count int, err error): ref = backendDOMNodeId del primer match (#ref para CdpClickRef/CdpHoverRef); count = total de matches (>1 = ambiguo); err si conexion nula, role vacio, regex invalida, fallo CDP o 0 matches."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "functions/browser/cdp_find_by_role.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
c, _ := browser.CdpConnect(9333) // conexion CDP del pool
|
||||||
|
ref, count, err := browser.CdpFindByRole(c, "button", browser.CdpFindByRoleOpts{
|
||||||
|
Name: "Aceptar", // substring del accessible name, case-insensitive
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err) // ej: no element with role "button" and name "Aceptar"
|
||||||
|
}
|
||||||
|
if count > 1 {
|
||||||
|
log.Printf("aviso: %d botones matchean 'Aceptar', usando el primero", count)
|
||||||
|
}
|
||||||
|
// ref es el mismo #ref que produce page_perceive: alimentarlo a CdpClickRef.
|
||||||
|
_ = browser.CdpClickRef(c, ref, browser.MouseHumanOpts{})
|
||||||
|
|
||||||
|
// Match exacto + case-sensitive:
|
||||||
|
ref, _, _ = browser.CdpFindByRole(c, "link", browser.CdpFindByRoleOpts{
|
||||||
|
Name: "Iniciar sesion", Exact: true, CaseSensitive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Match por regex (ej "Eliminar 3 elementos" / "Eliminar 12 elementos"):
|
||||||
|
ref, _, _ = browser.CdpFindByRole(c, "button", browser.CdpFindByRoleOpts{
|
||||||
|
Name: `^Eliminar \d+ elementos$`, Regex: true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites localizar un control de forma robusta a cambios de DOM/CSS: el rol
|
||||||
|
ARIA + accessible name sobreviven a refactors de markup y clases CSS que romperian un
|
||||||
|
selector `nth-of-type`. Es el patron primario que recomienda Playwright (getByRole)
|
||||||
|
para encontrar elementos accionables (botones, links, inputs). Combina el `ref`
|
||||||
|
devuelto directamente con `cdp_click_ref` / `cdp_hover_ref` para actuar sin pasar por
|
||||||
|
un selector fragil. Revisa `count` antes de actuar: si es >1 la busqueda es ambigua
|
||||||
|
y conviene refinar (Name mas especifico, Exact, o Regex anclada).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- El `name` que se matchea es el **accessible name computado** por el motor de
|
||||||
|
accesibilidad de Chrome (deriva de aria-label, label asociado, contenido, alt,
|
||||||
|
title segun la spec ARIA), **no** el `innerText` del elemento. Si buscas por el
|
||||||
|
texto visible literal, usa `cdp_find_ref_by_text` en su lugar.
|
||||||
|
- `count > 1` => ambiguedad: se devuelve el primer match en orden del AX tree, que no
|
||||||
|
siempre es el visualmente primero ni el que quieres. Refina la busqueda.
|
||||||
|
- El `role` se compara por **igualdad exacta** del rol ARIA: "button" no matchea
|
||||||
|
"menuitem" aunque ambos sean clicables. Mira el outline de `page_perceive` /
|
||||||
|
`cdp_get_ax_outline` para ver el rol real que Chrome asigna a cada nodo.
|
||||||
|
- Nodos `ignored` del AX tree se descartan. Si el elemento esta oculto (aria-hidden,
|
||||||
|
display:none) puede no aparecer y dar 0 matches.
|
||||||
|
- El `ref` es un `backendDOMNodeId`: estable mientras el nodo viva, pero si el DOM
|
||||||
|
muta entre el find y el click el ref puede quedar obsoleto.
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CdpDropdownOpts configura la seleccion en un desplegable custom (no nativo).
|
||||||
|
type CdpDropdownOpts struct {
|
||||||
|
// Exact: true = el texto de la opcion debe ser igual (tras normalizar) a
|
||||||
|
// optionText. false (default) = match por substring. La comparacion siempre
|
||||||
|
// es case-insensitive y sobre el texto normalizado (trim + colapsar espacios).
|
||||||
|
Exact bool
|
||||||
|
// TimeoutMs es el tope de espera (ms) para que el listbox monte/anime y la
|
||||||
|
// opcion aparezca visible. <=0 usa el default 3000.
|
||||||
|
TimeoutMs int
|
||||||
|
// OptionRole es el rol ARIA de las opciones a buscar ("option" por defecto).
|
||||||
|
// Usar "menuitem" para menus tipo dropdown-menu, "treeitem" para arboles, etc.
|
||||||
|
OptionRole string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CdpSelectDropdown selecciona una opcion en un DESPLEGABLE CUSTOM (combobox/listbox
|
||||||
|
// ARIA, react-select, MUI Select, headlessui, select2, ...) — esos en los que un
|
||||||
|
// <select> nativo NO aplica y por tanto CdpSelectOption no sirve.
|
||||||
|
//
|
||||||
|
// El patron replica como Playwright compone la accion (no tiene API para custom
|
||||||
|
// dropdowns): click(trigger) -> esperar apertura -> getByRole('option', {name}) ->
|
||||||
|
// click(option). Pasos:
|
||||||
|
//
|
||||||
|
// 1. Localiza el trigger por triggerSelector (CSS) y hace CLICK REAL (mouse
|
||||||
|
// mousePressed/mouseReleased sobre el centro del bbox, no element.click() JS):
|
||||||
|
// muchos dropdowns escuchan 'mousedown', no 'click'.
|
||||||
|
// 2. Espera la apertura (polling hasta TimeoutMs): el trigger pasa a
|
||||||
|
// aria-expanded="true", O aparece un [role=listbox]/[role=menu] visible, O hay
|
||||||
|
// elementos con el rol de opcion (OptionRole / li[role] / menuitem) con rect>0.
|
||||||
|
// No avanza hasta que haya opciones visibles.
|
||||||
|
// 3. Localiza la opcion cuyo texto normalizado (trim + colapsar espacios)
|
||||||
|
// coincide con optionText (substring si Exact=false, igualdad si Exact=true),
|
||||||
|
// entre las opciones con rol visibles. Error claro si no aparece en el timeout.
|
||||||
|
// 4. CLICK REAL en el centro de esa opcion.
|
||||||
|
// 5. Verifica el cierre/seleccion: aria-expanded vuelve a false O el trigger
|
||||||
|
// refleja el texto elegido; si la verificacion es ambigua, intenta Enter como
|
||||||
|
// fallback suave. No falla duro si el click se hizo pero la verificacion queda
|
||||||
|
// incierta.
|
||||||
|
//
|
||||||
|
// purity: impure (DOM + input real + tiempo). Devuelve error si el trigger no
|
||||||
|
// existe, si el dropdown no abre en el timeout, o si la opcion no aparece.
|
||||||
|
func CdpSelectDropdown(c *CDPConn, triggerSelector string, optionText string, opts CdpDropdownOpts) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("cdp select dropdown: conexion nula")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(triggerSelector) == "" {
|
||||||
|
return fmt.Errorf("cdp select dropdown: triggerSelector vacio")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(optionText) == "" {
|
||||||
|
return fmt.Errorf("cdp select dropdown: optionText vacio")
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutMs := opts.TimeoutMs
|
||||||
|
if timeoutMs <= 0 {
|
||||||
|
timeoutMs = 3000
|
||||||
|
}
|
||||||
|
optionRole := strings.TrimSpace(opts.OptionRole)
|
||||||
|
if optionRole == "" {
|
||||||
|
optionRole = "option"
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
|
||||||
|
|
||||||
|
// 1. Click REAL en el trigger.
|
||||||
|
if err := dropdownClickSelector(c, triggerSelector); err != nil {
|
||||||
|
return fmt.Errorf("cdp select dropdown: click trigger %q: %w", triggerSelector, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Esperar apertura (opciones visibles).
|
||||||
|
if err := dropdownWaitOpen(c, triggerSelector, optionRole, deadline); err != nil {
|
||||||
|
return fmt.Errorf("cdp select dropdown: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 + 4. Localizar la opcion por texto y click REAL en su centro.
|
||||||
|
cx, cy, err := dropdownFindOptionCenter(c, optionRole, optionText, opts.Exact, deadline)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cdp select dropdown: %w", err)
|
||||||
|
}
|
||||||
|
if err := CdpClickXYHuman(c, cx, cy, MouseHumanOpts{Mode: "auto"}); err != nil {
|
||||||
|
return fmt.Errorf("cdp select dropdown: click opcion %q: %w", optionText, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Verificacion suave: dar un instante a que se cierre/refleje, y si sigue
|
||||||
|
// abierto intentar Enter (algunos comboboxes confirman con Enter sobre la
|
||||||
|
// opcion activa). No es fatal si la verificacion queda ambigua.
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
if dropdownStillOpen(c, triggerSelector, optionRole) {
|
||||||
|
_ = CdpPressKey(c, "Enter")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdownClickSelector resuelve el bbox del elemento (por selector CSS) y hace
|
||||||
|
// click real sobre su centro. Hace scroll si hace falta. Cae a element.click() JS
|
||||||
|
// solo si el nodo no tiene geometria (display:contents, area 0).
|
||||||
|
func dropdownClickSelector(c *CDPConn, selector string) error {
|
||||||
|
// Centro del bbox del elemento via getBoundingClientRect en el contexto JS.
|
||||||
|
js := fmt.Sprintf(`(function(){
|
||||||
|
var el = document.querySelector(%s);
|
||||||
|
if (!el) return '__NO_EL__';
|
||||||
|
el.scrollIntoView({block:'center', inline:'center'});
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
if (r.width <= 0 || r.height <= 0) return '__NO_BOX__';
|
||||||
|
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
|
||||||
|
})()`, jsString(selector))
|
||||||
|
|
||||||
|
res, err := CdpEvaluate(c, js)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolver bbox: %w", err)
|
||||||
|
}
|
||||||
|
res = strings.Trim(res, `"`)
|
||||||
|
switch res {
|
||||||
|
case "__NO_EL__":
|
||||||
|
return fmt.Errorf("trigger no encontrado para selector %q", selector)
|
||||||
|
case "__NO_BOX__":
|
||||||
|
// Sin geometria: fallback a element.click() JS (no dispara mousedown real).
|
||||||
|
return dropdownClickViaJS(c, selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
x, y, ok := parseXY(res)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("bbox invalido %q", res)
|
||||||
|
}
|
||||||
|
return CdpClickXYHuman(c, x, y, MouseHumanOpts{Mode: "auto"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdownClickViaJS es el fallback sin geometria: element.click() en el contexto JS.
|
||||||
|
func dropdownClickViaJS(c *CDPConn, selector string) error {
|
||||||
|
js := fmt.Sprintf(`(function(){
|
||||||
|
var el = document.querySelector(%s);
|
||||||
|
if (!el) return '__NO_EL__';
|
||||||
|
el.click();
|
||||||
|
return '__OK__';
|
||||||
|
})()`, jsString(selector))
|
||||||
|
res, err := CdpEvaluate(c, js)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.Trim(res, `"`) != "__OK__" {
|
||||||
|
return fmt.Errorf("element.click() JS fallo (%s)", strings.Trim(res, `"`))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdownWaitOpen hace polling hasta deadline esperando que el dropdown este
|
||||||
|
// abierto: trigger con aria-expanded="true", O un [role=listbox]/[role=menu]
|
||||||
|
// visible, O algun elemento con el rol de opcion (rect>0). Error si no abre.
|
||||||
|
func dropdownWaitOpen(c *CDPConn, triggerSelector, optionRole string, deadline time.Time) error {
|
||||||
|
for {
|
||||||
|
open, err := dropdownIsOpen(c, triggerSelector, optionRole)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if open {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("el dropdown no abrio (sin opciones visibles) tras el timeout para trigger %q", triggerSelector)
|
||||||
|
}
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdownIsOpen comprueba una vez si el dropdown esta abierto.
|
||||||
|
func dropdownIsOpen(c *CDPConn, triggerSelector, optionRole string) (bool, error) {
|
||||||
|
js := fmt.Sprintf(`(function(){
|
||||||
|
var trigger = document.querySelector(%s);
|
||||||
|
if (trigger && trigger.getAttribute('aria-expanded') === 'true') return 'open';
|
||||||
|
function visible(el){
|
||||||
|
if (!el) return false;
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
if (r.width <= 0 || r.height <= 0) return false;
|
||||||
|
var cs = getComputedStyle(el);
|
||||||
|
if (cs.visibility === 'hidden' || cs.display === 'none') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Un contenedor listbox/menu visible cuenta como abierto.
|
||||||
|
var containers = document.querySelectorAll('[role=listbox],[role=menu]');
|
||||||
|
for (var i=0;i<containers.length;i++){ if (visible(containers[i])) return 'open'; }
|
||||||
|
// O al menos una opcion (por rol o por li[role]) visible.
|
||||||
|
var role = %s;
|
||||||
|
var sel = '[role=' + role + '],li[role],[role=menuitem]';
|
||||||
|
var opts = document.querySelectorAll(sel);
|
||||||
|
for (var j=0;j<opts.length;j++){ if (visible(opts[j])) return 'open'; }
|
||||||
|
return 'closed';
|
||||||
|
})()`, jsString(triggerSelector), jsString(optionRole))
|
||||||
|
|
||||||
|
res, err := CdpEvaluate(c, js)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("comprobar apertura: %w", err)
|
||||||
|
}
|
||||||
|
return strings.Trim(res, `"`) == "open", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdownStillOpen es una comprobacion best-effort para la verificacion final;
|
||||||
|
// nunca propaga error (un fallo aqui no debe invalidar el click ya hecho).
|
||||||
|
func dropdownStillOpen(c *CDPConn, triggerSelector, optionRole string) bool {
|
||||||
|
open, err := dropdownIsOpen(c, triggerSelector, optionRole)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return open
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdownFindOptionCenter localiza, entre las opciones visibles del dropdown, la
|
||||||
|
// que matchea optionText (substring si exact=false, igualdad si exact=true; ambas
|
||||||
|
// case-insensitive sobre texto normalizado) y devuelve el centro de su bbox. Hace
|
||||||
|
// polling hasta deadline para tolerar listas virtualizadas que montan tarde.
|
||||||
|
func dropdownFindOptionCenter(c *CDPConn, optionRole, optionText string, exact bool, deadline time.Time) (float64, float64, error) {
|
||||||
|
js := fmt.Sprintf(`(function(){
|
||||||
|
var role = %s;
|
||||||
|
var want = %s;
|
||||||
|
var exact = %t;
|
||||||
|
function norm(v){ return (v||'').replace(/\s+/g,' ').trim().toLowerCase(); }
|
||||||
|
function visible(el){
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
if (r.width <= 0 || r.height <= 0) return false;
|
||||||
|
var cs = getComputedStyle(el);
|
||||||
|
if (cs.visibility === 'hidden' || cs.display === 'none') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var target = norm(want);
|
||||||
|
var sel = '[role=' + role + '],li[role],[role=menuitem]';
|
||||||
|
var nodes = document.querySelectorAll(sel);
|
||||||
|
for (var i=0;i<nodes.length;i++){
|
||||||
|
var el = nodes[i];
|
||||||
|
if (!visible(el)) continue;
|
||||||
|
var t = norm(el.innerText || el.textContent || '');
|
||||||
|
var ok = exact ? (t === target) : (t.indexOf(target) >= 0);
|
||||||
|
if (ok){
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '__NO_OPTION__';
|
||||||
|
})()`, jsString(optionRole), jsString(optionText), exact)
|
||||||
|
|
||||||
|
for {
|
||||||
|
res, err := CdpEvaluate(c, js)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("buscar opcion: %w", err)
|
||||||
|
}
|
||||||
|
res = strings.Trim(res, `"`)
|
||||||
|
if res != "__NO_OPTION__" {
|
||||||
|
if x, y, ok := parseXY(res); ok {
|
||||||
|
return x, y, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return 0, 0, fmt.Errorf("option %q not found in dropdown", optionText)
|
||||||
|
}
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseXY extrae x/y de un JSON {"x":..,"y":..} que llega ya des-escapado de
|
||||||
|
// CdpEvaluate (que devuelve el JSON.stringify como string). Hace un parse ligero
|
||||||
|
// sin importar encoding/json de nuevo en el hot path: busca los numeros tras x/y.
|
||||||
|
func parseXY(s string) (float64, float64, bool) {
|
||||||
|
// CdpEvaluate devuelve la cadena producida por JSON.stringify; las comillas
|
||||||
|
// internas vienen escapadas como \" tras pasar por el unmarshal de Go.
|
||||||
|
s = strings.ReplaceAll(s, `\"`, `"`)
|
||||||
|
var x, y float64
|
||||||
|
n, err := fmt.Sscanf(s, `{"x":%g,"y":%g}`, &x, &y)
|
||||||
|
if err != nil || n != 2 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return x, y, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: cdp_select_dropdown
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpSelectDropdown(c *CDPConn, triggerSelector string, optionText string, opts CdpDropdownOpts) error"
|
||||||
|
description: "Selecciona una opcion en un DESPLEGABLE CUSTOM (combobox/listbox ARIA, react-select, MUI Select, headlessui, select2) — esos donde un <select> nativo NO aplica. Replica el patron de Playwright (que no tiene API para custom dropdowns): click REAL en el trigger (mousedown, no element.click JS), espera la apertura por polling (aria-expanded=true O [role=listbox]/[role=menu] visible O opciones con rect>0), localiza la opcion por texto normalizado (substring o exacto, case-insensitive) y hace click REAL en su centro, con verificacion suave (aria-expanded vuelve a false o Enter como fallback). Reusa CdpEvaluate, CdpClickXYHuman y CdpPressKey."
|
||||||
|
tags: [browser, chrome, cdp, automation, dropdown, combobox, listbox, aria, select, react-select, mui, headlessui, devtools]
|
||||||
|
uses_functions: [cdp_evaluate_go_browser, cdp_click_xy_human_go_browser, cdp_press_key_go_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt, strings, time]
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "conexion CDP activa (*CDPConn)"
|
||||||
|
- name: triggerSelector
|
||||||
|
desc: "selector CSS del elemento que abre el desplegable (el boton/combobox sobre el que se hace click real)"
|
||||||
|
- name: optionText
|
||||||
|
desc: "texto visible de la opcion a elegir; se normaliza (trim + colapsar espacios) y se compara case-insensitive, por substring si opts.Exact=false o por igualdad si opts.Exact=true"
|
||||||
|
- name: opts
|
||||||
|
desc: "CdpDropdownOpts{Exact bool (igualdad vs substring, default substring); TimeoutMs int (espera apertura+opcion, default 3000); OptionRole string (rol ARIA de las opciones, default 'option' — usar 'menuitem' para menus, 'treeitem' para arboles)}"
|
||||||
|
output: "error si el trigger no existe, si el dropdown no abre dentro del timeout (\"el dropdown no abrio\"), o si la opcion no aparece (\"option %q not found in dropdown\"); nil si el click sobre la opcion se realizo (la verificacion de cierre es suave y no falla duro si queda ambigua)"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "functions/browser/cdp_select_dropdown.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
conn, _ := CdpConnect(9222)
|
||||||
|
CdpNavigate(conn, "https://mui.com/material-ui/react-select/")
|
||||||
|
|
||||||
|
// Combobox MUI: el trigger es el div con role=combobox; el listbox monta y
|
||||||
|
// anima al abrir. CdpSelectDropdown clica el trigger, espera a que el listbox
|
||||||
|
// este visible y entonces clica la opcion "Twenty".
|
||||||
|
err := CdpSelectDropdown(conn, "[role=combobox]", "Twenty", CdpDropdownOpts{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// react-select / headlessui: trigger por clase + match exacto + timeout amplio
|
||||||
|
// para listas que tardan en montar.
|
||||||
|
err = CdpSelectDropdown(conn, ".select__control", "España", CdpDropdownOpts{
|
||||||
|
Exact: true,
|
||||||
|
TimeoutMs: 6000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Menu tipo dropdown-menu (no listbox): las opciones son role=menuitem.
|
||||||
|
err = CdpSelectDropdown(conn, "#user-menu-btn", "Cerrar sesion", CdpDropdownOpts{
|
||||||
|
OptionRole: "menuitem",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala cuando el desplegable NO es un `<select>` nativo: comboboxes/listboxes ARIA,
|
||||||
|
react-select, MUI Select, headlessui, select2, Ant Design, o cualquier menu hecho
|
||||||
|
con `<div>`/`<li>` + JS donde elegir = clicar el trigger y luego clicar la opcion
|
||||||
|
del menu desplegado. Es el equivalente al patron de Playwright
|
||||||
|
`click(trigger) -> getByRole('option', {name}) -> click(option)`, con la espera de
|
||||||
|
apertura ya resuelta. Para un `<select>` nativo de HTML usa `CdpSelectOption` (setea
|
||||||
|
`select.value` + dispara `input`/`change`), que es mas robusto y directo para ese
|
||||||
|
caso.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Click real, no element.click()**: muchos dropdowns custom escuchan `mousedown`
|
||||||
|
(no `click`), por eso esta funcion despacha eventos de raton reales sobre el
|
||||||
|
centro del bbox. Solo cae a `element.click()` JS si el nodo no tiene geometria.
|
||||||
|
- **Animaciones de apertura**: el fallo nº1 reportado en Playwright es clicar la
|
||||||
|
opcion ANTES de que el listbox monte/anime. Por eso hay polling de apertura
|
||||||
|
(`dropdownWaitOpen`) que no avanza hasta que hay opciones visibles. Si tu
|
||||||
|
dropdown anima muy lento, sube `TimeoutMs`.
|
||||||
|
- **Listas virtualizadas** (react-window, virtuoso): solo renderizan las opciones
|
||||||
|
en viewport. Si la opcion buscada esta fuera del scroll inicial, puede que nunca
|
||||||
|
se monte y la funcion devuelva "not found" aunque exista. Mitigacion: escribe en
|
||||||
|
el combobox para filtrar (`CdpTypeText`) antes de llamar a esta funcion, o haz
|
||||||
|
scroll dentro del listbox primero.
|
||||||
|
- **Trigger vs contenedor**: `triggerSelector` debe apuntar al elemento que ABRE el
|
||||||
|
menu (el boton/combobox), no al `[role=listbox]` (que no existe hasta abrir).
|
||||||
|
- **Match de texto**: normaliza espacios y es case-insensitive; por defecto es
|
||||||
|
substring (`Exact=false`). Si varias opciones comparten substring, elige la
|
||||||
|
primera visible en orden de documento — usa `Exact=true` para desambiguar.
|
||||||
|
- **OptionRole**: por defecto `option` (`[role=option]`). Para menus de acciones usa
|
||||||
|
`menuitem`; para arboles `treeitem`. La deteccion de apertura tambien considera
|
||||||
|
`[role=menu]` y `li[role]` para cubrir patrones comunes.
|
||||||
|
- **Verificacion suave**: tras clicar, si el dropdown sigue abierto la funcion pulsa
|
||||||
|
`Enter` como fallback y devuelve `nil`. No falla duro si la seleccion no se puede
|
||||||
|
confirmar inequivocamente pero el click se hizo — comprueba el estado resultante
|
||||||
|
(texto del trigger, valor del formulario) si necesitas certeza.
|
||||||
|
- **iframes**: opera en el documento principal (via `CdpEvaluate`). Para un dropdown
|
||||||
|
dentro de un iframe necesitarias el contexto del frame (no cubierto aqui).
|
||||||
@@ -5,41 +5,105 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CdpSelectOption selecciona la <option> de un <select> (localizado por selector
|
// CdpSelectOption selecciona una <option> de un <select> nativo (localizado por
|
||||||
// CSS) cuyo value coincide con value; si ningun value coincide, busca por texto
|
// selector CSS) replicando la semantica de Playwright (injectedScript.selectOptions).
|
||||||
// visible de la option. Tras setear select.value despacha los eventos 'input' y
|
|
||||||
// 'change' con bubbles:true para que frameworks (React/Vue) reaccionen al cambio.
|
|
||||||
//
|
//
|
||||||
// Devuelve error si el select no existe ("select not found") o si ninguna option
|
// Orden de matching de value contra cada <option>, en este orden:
|
||||||
// coincide por value ni por texto ("option not found").
|
// 1. value exacto: option.value === value.
|
||||||
|
// 2. label/texto exacto: option.label === value (sin normalizar).
|
||||||
|
// 3. label/texto NORMALIZADO: normalizeWhiteSpace(option.label) === normalizeWhiteSpace(value),
|
||||||
|
// donde normalizar = quitar zero-width space (U+200B) y soft hyphen (U+00AD),
|
||||||
|
// trim, y colapsar cualquier secuencia de whitespace a un solo espacio.
|
||||||
|
// 4. label/texto por substring NORMALIZADO: la primera option cuyo label normalizado
|
||||||
|
// contenga el value normalizado (fallback para etiquetas largas).
|
||||||
|
// 5. fallback por indice: solo si value es un entero (>= 0) y existe esa posicion.
|
||||||
|
//
|
||||||
|
// Sobre la option encontrada hace focus del select, setea option.selected = true
|
||||||
|
// (no solo select.value, para que funcione tambien con <select multiple>) y despacha
|
||||||
|
// 'input' {bubbles:true, composed:true} seguido de 'change' {bubbles:true}, en ese
|
||||||
|
// orden, para que frameworks (React/Vue/Angular) y shadow DOM reaccionen al cambio.
|
||||||
|
//
|
||||||
|
// Si el selector apunta a un <label for=...>, sigue la referencia hasta su control
|
||||||
|
// (retarget follow-label) antes de validar que sea un <select>.
|
||||||
|
//
|
||||||
|
// Devuelve error claro si:
|
||||||
|
// - el selector no encuentra elemento ("element not found"),
|
||||||
|
// - el elemento no es un <select> ("element is not a <select> ..."),
|
||||||
|
// - ninguna option coincide ("option not found in <select>").
|
||||||
func CdpSelectOption(c *CDPConn, selector string, value string) error {
|
func CdpSelectOption(c *CDPConn, selector string, value string) error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("cdp select option: conexion nula")
|
return fmt.Errorf("cdp select option: conexion nula")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Script JS: localiza el select, busca la option por value y, como fallback,
|
// Script JS alineado con Playwright. Devuelve centinelas en string:
|
||||||
// por textContent (trim). Si encuentra, setea value, dispara input+change y
|
// __OK__:<value> cuando selecciona; el resto son codigos de error claros.
|
||||||
// devuelve "__OK__". Si no, devuelve un centinela de error claro. Usamos
|
// Usamos jsString para inyectar selector/value de forma segura (anti-inyeccion).
|
||||||
// JSON.stringify de los inputs para inyectarlos de forma segura.
|
|
||||||
js := fmt.Sprintf(`(function() {
|
js := fmt.Sprintf(`(function() {
|
||||||
var sel = document.querySelector(%s);
|
function normWS(t) {
|
||||||
if (!sel) return '__NO_SELECT__';
|
return (t == null ? '' : String(t))
|
||||||
|
.replace(/[]/g, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
var el = document.querySelector(%s);
|
||||||
|
if (!el) return '__NO_EL__';
|
||||||
|
// retarget follow-label: si es un <label for>, salta a su control.
|
||||||
|
if (el.nodeName.toLowerCase() === 'label') {
|
||||||
|
var labelled = null;
|
||||||
|
var forId = el.getAttribute('for');
|
||||||
|
if (forId) labelled = document.getElementById(forId);
|
||||||
|
if (!labelled) labelled = el.querySelector('select, input, textarea');
|
||||||
|
if (labelled) el = labelled;
|
||||||
|
}
|
||||||
|
if (el.nodeName.toLowerCase() !== 'select') return '__NOT_SELECT__';
|
||||||
|
var sel = el;
|
||||||
var want = %s;
|
var want = %s;
|
||||||
|
var wantNorm = normWS(want);
|
||||||
var opts = Array.prototype.slice.call(sel.options);
|
var opts = Array.prototype.slice.call(sel.options);
|
||||||
var match = null;
|
var match = null;
|
||||||
for (var i = 0; i < opts.length; i++) {
|
|
||||||
if (opts[i].value === want) { match = opts[i]; break; }
|
// 1. value exacto.
|
||||||
|
for (var i = 0; i < opts.length && !match; i++) {
|
||||||
|
if (opts[i].value === want) match = opts[i];
|
||||||
}
|
}
|
||||||
|
// 2. label/texto exacto.
|
||||||
if (!match) {
|
if (!match) {
|
||||||
for (var j = 0; j < opts.length; j++) {
|
for (var j = 0; j < opts.length && !match; j++) {
|
||||||
if ((opts[j].textContent || '').trim() === want) { match = opts[j]; break; }
|
if (opts[j].label === want || (opts[j].textContent || '') === want) match = opts[j];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 3. label/texto normalizado exacto.
|
||||||
|
if (!match && wantNorm !== '') {
|
||||||
|
for (var k = 0; k < opts.length && !match; k++) {
|
||||||
|
var ln = normWS(opts[k].label || opts[k].textContent);
|
||||||
|
if (ln === wantNorm) match = opts[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4. label/texto por substring normalizado.
|
||||||
|
if (!match && wantNorm !== '') {
|
||||||
|
for (var m = 0; m < opts.length && !match; m++) {
|
||||||
|
var ln2 = normWS(opts[m].label || opts[m].textContent);
|
||||||
|
if (ln2.indexOf(wantNorm) !== -1) match = opts[m];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 5. fallback por indice: solo si want es un entero >= 0 valido.
|
||||||
|
if (!match && /^[0-9]+$/.test(want)) {
|
||||||
|
var idx = parseInt(want, 10);
|
||||||
|
if (idx >= 0 && idx < opts.length) match = opts[idx];
|
||||||
|
}
|
||||||
|
|
||||||
if (!match) return '__NO_OPTION__';
|
if (!match) return '__NO_OPTION__';
|
||||||
sel.value = match.value;
|
|
||||||
sel.dispatchEvent(new Event('input', {bubbles: true}));
|
try { sel.focus(); } catch (e) {}
|
||||||
sel.dispatchEvent(new Event('change', {bubbles: true}));
|
// option.selected en vez de solo select.value: necesario para <select multiple>
|
||||||
return '__OK__';
|
// y mas fiel a como un usuario elige una entrada concreta.
|
||||||
|
if (!sel.multiple) {
|
||||||
|
for (var n = 0; n < opts.length; n++) opts[n].selected = false;
|
||||||
|
}
|
||||||
|
match.selected = true;
|
||||||
|
sel.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||||
|
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return '__OK__:' + match.value;
|
||||||
})()`, jsString(selector), jsString(value))
|
})()`, jsString(selector), jsString(value))
|
||||||
|
|
||||||
res, err := CdpEvaluate(c, js)
|
res, err := CdpEvaluate(c, js)
|
||||||
@@ -48,13 +112,15 @@ func CdpSelectOption(c *CDPConn, selector string, value string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res = strings.Trim(res, `"`)
|
res = strings.Trim(res, `"`)
|
||||||
switch res {
|
switch {
|
||||||
case "__OK__":
|
case strings.HasPrefix(res, "__OK__"):
|
||||||
return nil
|
return nil
|
||||||
case "__NO_SELECT__":
|
case res == "__NO_EL__":
|
||||||
return fmt.Errorf("cdp select option: select not found para selector %q", selector)
|
return fmt.Errorf("cdp select option: element not found para selector %q", selector)
|
||||||
case "__NO_OPTION__":
|
case res == "__NOT_SELECT__":
|
||||||
return fmt.Errorf("cdp select option: option not found para value %q en select %q", value, selector)
|
return fmt.Errorf("cdp select option: element %q is not a <select> (use cdp_select_dropdown / click el trigger+option para dropdowns custom)", selector)
|
||||||
|
case res == "__NO_OPTION__":
|
||||||
|
return fmt.Errorf("cdp select option: option %q not found in <select> %q", value, selector)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("cdp select option: resultado inesperado %q para selector %q", res, selector)
|
return fmt.Errorf("cdp select option: resultado inesperado %q para selector %q", res, selector)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ name: cdp_select_option
|
|||||||
kind: function
|
kind: function
|
||||||
lang: go
|
lang: go
|
||||||
domain: browser
|
domain: browser
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "func CdpSelectOption(c *CDPConn, selector string, value string) error"
|
signature: "func CdpSelectOption(c *CDPConn, selector string, value string) error"
|
||||||
description: "Selecciona la <option> de un <select> (localizado por selector CSS) cuyo value coincide con el valor dado; si ningun value coincide, busca por texto visible de la option. Tras setear select.value despacha los eventos 'input' y 'change' con bubbles:true para que frameworks (React/Vue) reaccionen al cambio. Via Runtime.evaluate, reusa CdpEvaluate."
|
description: "Selecciona una <option> de un <select> nativo (localizado por selector CSS) replicando la semantica de Playwright (injectedScript.selectOptions). Match por value exacto, luego label/texto exacto, luego label normalizado (whitespace-collapse + strip zero-width/soft-hyphen), luego substring normalizado, y por ultimo indice si value es entero. Setea option.selected (soporta <select multiple>), hace focus, y despacha 'input' {bubbles,composed} + 'change' {bubbles}. Valida que el elemento sea <select> (error claro si no) y sigue <label for>. Via Runtime.evaluate, reusa CdpEvaluate."
|
||||||
tags: [chrome, cdp, browser, automation, select, dropdown, form, dom, devtools]
|
tags: [chrome, cdp, browser, automation, select, dropdown, form, dom, devtools]
|
||||||
uses_functions: [cdp_evaluate_go_browser]
|
uses_functions: [cdp_evaluate_go_browser]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
@@ -20,8 +20,8 @@ params:
|
|||||||
- name: selector
|
- name: selector
|
||||||
desc: "selector CSS del elemento <select> a modificar"
|
desc: "selector CSS del elemento <select> a modificar"
|
||||||
- name: value
|
- name: value
|
||||||
desc: "value de la <option> a seleccionar; si no hay match por value, se busca por texto visible (textContent trimeado)"
|
desc: "criterio de seleccion. Se prueba en orden: value exacto → label/texto exacto → label normalizado (whitespace-collapse + strip U+200B/U+00AD) → label por substring normalizado → indice (si value es un entero)"
|
||||||
output: "error si el select no existe (\"select not found\") o ninguna option coincide por value ni por texto (\"option not found\"); nil si la selección y los eventos se despacharon correctamente"
|
output: "error si el selector no encuentra elemento (\"element not found\"), si el elemento no es un <select> (\"element is not a <select> ...\"), o si ninguna option coincide (\"option not found in <select>\"); nil si la selección y los eventos se despacharon correctamente"
|
||||||
tested: false
|
tested: false
|
||||||
tests: []
|
tests: []
|
||||||
test_file_path: ""
|
test_file_path: ""
|
||||||
@@ -43,6 +43,11 @@ if err := CdpSelectOption(conn, "#country", "ES"); err != nil {
|
|||||||
if err := CdpSelectOption(conn, "select[name=lang]", "Español"); err != nil {
|
if err := CdpSelectOption(conn, "select[name=lang]", "Español"); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seleccionar por indice (3a opcion) cuando ni value ni texto son estables
|
||||||
|
if err := CdpSelectOption(conn, "#size", "2"); err != nil { // index 2 = 3a option
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cuando usarla
|
## Cuando usarla
|
||||||
@@ -50,21 +55,53 @@ if err := CdpSelectOption(conn, "select[name=lang]", "Español"); err != nil {
|
|||||||
Usala cuando necesites elegir una opcion de un `<select>` nativo en un formulario
|
Usala cuando necesites elegir una opcion de un `<select>` nativo en un formulario
|
||||||
web y quieras que un framework (React, Vue, Angular) reaccione al cambio. Es la
|
web y quieras que un framework (React, Vue, Angular) reaccione al cambio. Es la
|
||||||
forma robusta de rellenar dropdowns durante automatizacion/scraping: a diferencia
|
forma robusta de rellenar dropdowns durante automatizacion/scraping: a diferencia
|
||||||
de un click sobre la option, setea `select.value` y dispara `input`+`change`, que
|
de un click sobre la option, setea `option.selected` y dispara `input`+`change`,
|
||||||
es lo que los frameworks escuchan. Combinala con `CdpClick` para enviar el
|
que es lo que los frameworks escuchan. Combinala con `CdpClick` para enviar el
|
||||||
formulario despues.
|
formulario despues. Si no conoces el `value` interno, pasa el texto visible (se
|
||||||
|
normaliza el whitespace) o el indice numerico de la option.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Solo funciona con `<select>` nativos (HTML). Dropdowns custom hechos con `<div>`
|
- **Solo `<select>` nativos.** Si el elemento no es un `<select>` retorna error
|
||||||
+ JS (ej. react-select, headlessui) NO son `<select>` reales: para esos hay que
|
claro `element is not a <select> ...`. Dropdowns custom hechos con `<div>` + JS
|
||||||
clickar y elegir la opcion del menu desplegado, no usar esta funcion.
|
(react-select, headlessui, Radix, etc.) NO son `<select>` reales: para esos usa
|
||||||
- El match por value es exacto (`===`); el fallback por texto compara `textContent`
|
`cdp_select_dropdown` (cuando exista) o clica el trigger con `CdpClickRef` y
|
||||||
trimeado de forma exacta tras `.trim()` (no substring, no case-insensitive).
|
luego la opcion del menu desplegado (`CdpFindRefByText` + `CdpClickRef`). NO uses
|
||||||
- No hace scroll ni verifica visibilidad: opera sobre el DOM directamente. Si el
|
esta funcion para ellos.
|
||||||
`<select>` esta deshabilitado (`disabled`), el value se setea igual pero la UI
|
- **Orden de matching del `value` recibido** (se prueba en este orden y para en el
|
||||||
puede ignorarlo segun el framework.
|
primer match):
|
||||||
- Para `<select multiple>` solo selecciona una opcion (la que coincide) y resetea
|
1. `option.value` exacto (`===`).
|
||||||
el resto, porque setea `select.value` (no añade a `selectedOptions`).
|
2. `option.label` / `textContent` exacto (sin normalizar).
|
||||||
- Si el elemento aun no existe (carga dinamica), retorna "select not found" sin
|
3. label/texto NORMALIZADO exacto: se quita zero-width space (U+200B) y soft
|
||||||
|
hyphen (U+00AD), se hace `trim`, y se colapsa cualquier whitespace (`\s+`) a un
|
||||||
|
solo espacio — igual que `normalizeWhiteSpace` de Playwright.
|
||||||
|
4. label/texto por SUBSTRING normalizado (primera option cuyo label normalizado
|
||||||
|
contenga el value normalizado). Util para etiquetas largas; cuidado con
|
||||||
|
ambiguedad (gana la primera en orden de documento).
|
||||||
|
5. fallback por INDICE: solo si `value` es un entero `>= 0` valido (`"2"` → 3a
|
||||||
|
option). Por eso un `value` que casualmente sea numerico puede caer aqui si no
|
||||||
|
hubo ningun match textual antes — preferi el `value` real cuando exista.
|
||||||
|
El matching es case-sensitive en todos los pasos (no se hace lowercase).
|
||||||
|
- **`<select multiple>` soportado:** setea `option.selected = true` sobre la option
|
||||||
|
encontrada sin tocar el resto de selecciones. En un `<select>` simple deselecciona
|
||||||
|
las demas antes de marcar la elegida. (La version 1.0.0 solo seteaba `select.value`
|
||||||
|
y reseteaba el multiple — corregido.)
|
||||||
|
- **Eventos:** dispara `input` con `{bubbles:true, composed:true}` (el `composed`
|
||||||
|
permite cruzar shadow DOM, p.ej. web components que envuelven el `<select>`) y
|
||||||
|
luego `change` con `{bubbles:true}`, en ese orden. Hace `focus()` del select antes.
|
||||||
|
- No hace scroll ni verifica visibilidad/enabled: opera sobre el DOM directamente.
|
||||||
|
Si el `<select>` o la `<option>` estan `disabled`, la seleccion se aplica igual
|
||||||
|
pero la UI puede ignorarla segun el framework (Playwright aqui devolveria
|
||||||
|
`optionnotenabled`; esta funcion no chequea enabled — mantiene KISS).
|
||||||
|
- Si el elemento aun no existe (carga dinamica), retorna `element not found` sin
|
||||||
esperar — combinar con `CdpWaitElement` para elementos diferidos.
|
esperar — combinar con `CdpWaitElement` para elementos diferidos.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-16) — alineada con Playwright `injectedScript.selectOptions`:
|
||||||
|
valida que el elemento sea `<select>` (error claro si no, apuntando a dropdowns
|
||||||
|
custom), sigue `<label for>`, matching multi-criterio (value → label exacto →
|
||||||
|
label normalizado whitespace-collapse → substring → indice), usa
|
||||||
|
`option.selected` en vez de solo `select.value` (soporta `<select multiple>`),
|
||||||
|
añade `composed:true` al evento `input` (cruza shadow DOM) y `focus()` previo.
|
||||||
|
Firma intacta (no rompe el caller del MCP `dom_select_option`).
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// actionableBackoff es el calendario de espera entre reintentos del bucle de
|
||||||
|
// actionability, copiado del _retryAction de Playwright (waitTime [0,20,100,100,500]).
|
||||||
|
// Tras agotar la tabla, se mantiene en el ultimo valor (500ms) hasta el timeout.
|
||||||
|
// El primer intento es inmediato (0ms): muchas veces el elemento ya esta listo.
|
||||||
|
var actionableBackoff = []time.Duration{
|
||||||
|
0,
|
||||||
|
20 * time.Millisecond,
|
||||||
|
100 * time.Millisecond,
|
||||||
|
100 * time.Millisecond,
|
||||||
|
500 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionableScrollAligns rota la alineacion block de scrollIntoView entre
|
||||||
|
// reintentos. Cyclar las alineaciones (center/start/end) destraba casos donde un
|
||||||
|
// header position:sticky o un footer fijo tapa el punto al alinear de una sola
|
||||||
|
// forma — replica el scrollOptions cycling de _retryPointerAction de Playwright.
|
||||||
|
var actionableScrollAligns = []string{"center", "start", "end"}
|
||||||
|
|
||||||
|
// actionableResult es el veredicto que el JS inyectado devuelve por iteracion.
|
||||||
|
// state describe el primer estado que fallo (para el mensaje de error final);
|
||||||
|
// x,y son el punto central listo para el pointer cuando ok==true.
|
||||||
|
type actionableResult struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
State string `json:"state"` // "visible" | "stable" | "enabled" | "inviewport" | "intercepted" | "notconnected"
|
||||||
|
Detail string `json:"detail"` // descripcion del interceptor u otro detalle
|
||||||
|
X float64 `json:"x"` // punto central viewport (CSS px)
|
||||||
|
Y float64 `json:"y"` //
|
||||||
|
PageX float64 `json:"pageX"` // punto central en coords de pagina (scroll incluido)
|
||||||
|
PageY float64 `json:"pageY"` //
|
||||||
|
}
|
||||||
|
|
||||||
|
// CdpWaitActionable bloquea hasta que el elemento identificado por backendNodeID
|
||||||
|
// sea accionable (listo para recibir un click/hover fiable) o expire timeout.
|
||||||
|
// Reproduce el modelo de actionability de Playwright: en cada iteracion comprueba
|
||||||
|
// que el elemento esta visible, estable (mismo rect en dos requestAnimationFrame
|
||||||
|
// consecutivos), opcionalmente enabled, dentro del viewport tras scrollIntoView,
|
||||||
|
// y que el hit-test (document.elementFromPoint subiendo por shadow DOM) apunta al
|
||||||
|
// propio nodo o a un descendiente. Si algo falla, espera con backoff
|
||||||
|
// [0,20,100,100,500]ms (luego 500ms constante) y reintenta, rotando la alineacion
|
||||||
|
// del scroll para destrabar overlays sticky.
|
||||||
|
//
|
||||||
|
// Devuelve el punto central (x,y) en coordenadas de viewport (CSS px), listo para
|
||||||
|
// Input.dispatchMouseEvent. Al expirar, el error indica QUE estado fallo en el
|
||||||
|
// ultimo intento (not visible / not stable / disabled / outside viewport /
|
||||||
|
// intercepted by other element).
|
||||||
|
//
|
||||||
|
// needEnabled controla si se exige el estado enabled (no `disabled`,
|
||||||
|
// `aria-disabled="true"`, ni dentro de un <fieldset disabled>). Pasar false para
|
||||||
|
// elementos no interactivos (texto, contenedores) donde enabled no aplica.
|
||||||
|
func CdpWaitActionable(c *CDPConn, backendNodeID int, needEnabled bool, timeout time.Duration) (x float64, y float64, err error) {
|
||||||
|
if c == nil {
|
||||||
|
return 0, 0, fmt.Errorf("cdp wait actionable: conexión nil")
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver el backendNodeID a un objectId una sola vez. El objectId apunta al
|
||||||
|
// nodo DOM vivo y se reutiliza en cada iteracion via Runtime.callFunctionOn,
|
||||||
|
// evitando un resolveNode por reintento.
|
||||||
|
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("cdp wait actionable: resolveNode ref %d: %w", backendNodeID, err)
|
||||||
|
}
|
||||||
|
obj, _ := res["object"].(map[string]any)
|
||||||
|
objID, _ := obj["objectId"].(string)
|
||||||
|
if objID == "" {
|
||||||
|
return 0, 0, fmt.Errorf("cdp wait actionable: sin objectId para ref %d (nodo inexistente)", backendNodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
var last actionableResult
|
||||||
|
last.State = "visible" // estado por defecto si nunca llegamos a evaluar
|
||||||
|
|
||||||
|
for retry := 0; ; retry++ {
|
||||||
|
// Espera con backoff antes de reintentar (el primer intento es inmediato).
|
||||||
|
if retry > 0 {
|
||||||
|
wait := actionableBackoff[len(actionableBackoff)-1]
|
||||||
|
if retry-1 < len(actionableBackoff) {
|
||||||
|
wait = actionableBackoff[retry-1]
|
||||||
|
}
|
||||||
|
if wait > 0 {
|
||||||
|
// No dormir mas alla del deadline.
|
||||||
|
if remaining := time.Until(deadline); remaining < wait {
|
||||||
|
wait = remaining
|
||||||
|
}
|
||||||
|
if wait > 0 {
|
||||||
|
time.Sleep(wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
align := actionableScrollAligns[retry%len(actionableScrollAligns)]
|
||||||
|
r, evalErr := evalActionable(c, objID, needEnabled, align)
|
||||||
|
if evalErr != nil {
|
||||||
|
// Un error de protocolo (tab cerrada, nodo liberado) es terminal: no
|
||||||
|
// tiene sentido reintentar sobre un objectId muerto.
|
||||||
|
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d: %w", backendNodeID, evalErr)
|
||||||
|
}
|
||||||
|
last = r
|
||||||
|
|
||||||
|
if r.OK {
|
||||||
|
return r.X, r.Y, nil
|
||||||
|
}
|
||||||
|
if r.State == "notconnected" {
|
||||||
|
// El nodo dejo de estar conectado al DOM — reintentar no lo revivira.
|
||||||
|
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d desconectado del DOM", backendNodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d no accionable tras %s: %s", backendNodeID, timeout, describeActionableFailure(last))
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeActionableFailure traduce el estado fallido a un mensaje humano.
|
||||||
|
func describeActionableFailure(r actionableResult) string {
|
||||||
|
switch r.State {
|
||||||
|
case "visible":
|
||||||
|
return "not visible (display:none, visibility:hidden, opacity:0 o tamaño 0)"
|
||||||
|
case "stable":
|
||||||
|
return "not stable (el rect sigue cambiando entre frames; animación o layout en curso)"
|
||||||
|
case "enabled":
|
||||||
|
return "disabled (atributo disabled, aria-disabled=true o <fieldset disabled>)"
|
||||||
|
case "inviewport":
|
||||||
|
return "outside of the viewport (scrollIntoView no logró revelarlo)"
|
||||||
|
case "intercepted":
|
||||||
|
if r.Detail != "" {
|
||||||
|
return "intercepted by other element: " + r.Detail
|
||||||
|
}
|
||||||
|
return "intercepted by other element (overlay capta el pointer en el punto central)"
|
||||||
|
case "notconnected":
|
||||||
|
return "not connected to the DOM"
|
||||||
|
default:
|
||||||
|
if r.State != "" {
|
||||||
|
return "not " + r.State
|
||||||
|
}
|
||||||
|
return "estado desconocido"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalActionable corre una iteracion completa de chequeos en el contexto JS de la
|
||||||
|
// pagina, sobre el nodo apuntado por objID. Devuelve el veredicto serializado.
|
||||||
|
//
|
||||||
|
// El JS hace, en orden y cortocircuitando al primer fallo:
|
||||||
|
// 1. visible: tiene client rects y computed style no lo oculta.
|
||||||
|
// 2. stable: getBoundingClientRect identico en dos requestAnimationFrame seguidos.
|
||||||
|
// 3. enabled (si needEnabled): no disabled / aria-disabled=true / dentro de
|
||||||
|
// <fieldset disabled> (subiendo por la jerarquia, como getAriaDisabled).
|
||||||
|
// 4. scrollIntoView con la alineacion dada + comprobacion de que el centro cae
|
||||||
|
// dentro del viewport.
|
||||||
|
// 5. hit-test: elementFromPoint en el punto central, subiendo por shadow roots
|
||||||
|
// (assignedSlot / parentNode.host) y comprobando que el elemento golpeado es
|
||||||
|
// el target o uno de sus descendientes.
|
||||||
|
func evalActionable(c *CDPConn, objID string, needEnabled bool, scrollAlign string) (actionableResult, error) {
|
||||||
|
params := map[string]any{
|
||||||
|
"objectId": objID,
|
||||||
|
"functionDeclaration": actionableJS,
|
||||||
|
"arguments": []any{
|
||||||
|
map[string]any{"value": needEnabled},
|
||||||
|
map[string]any{"value": scrollAlign},
|
||||||
|
},
|
||||||
|
"awaitPromise": true,
|
||||||
|
"returnByValue": true,
|
||||||
|
}
|
||||||
|
result, err := c.sendCDP("Runtime.callFunctionOn", params)
|
||||||
|
if err != nil {
|
||||||
|
return actionableResult{}, err
|
||||||
|
}
|
||||||
|
if exc, ok := result["exceptionDetails"]; ok && exc != nil {
|
||||||
|
excMap, _ := exc.(map[string]any)
|
||||||
|
text, _ := excMap["text"].(string)
|
||||||
|
return actionableResult{}, fmt.Errorf("excepción JS en chequeo de actionability: %s", text)
|
||||||
|
}
|
||||||
|
resVal, ok := result["result"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return actionableResult{}, fmt.Errorf("resultado inesperado: %v", result)
|
||||||
|
}
|
||||||
|
raw, ok := resVal["value"]
|
||||||
|
if !ok {
|
||||||
|
return actionableResult{}, fmt.Errorf("chequeo de actionability sin valor de retorno")
|
||||||
|
}
|
||||||
|
// returnByValue=true entrega el objeto JS ya deserializado a map[string]any;
|
||||||
|
// lo re-marshalamos para decodificar en el struct tipado de forma robusta.
|
||||||
|
b, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
return actionableResult{}, fmt.Errorf("marshal resultado: %w", err)
|
||||||
|
}
|
||||||
|
var out actionableResult
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
return actionableResult{}, fmt.Errorf("unmarshal resultado %q: %w", string(b), err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionableJS es la funcion ejecutada sobre el nodo (this) via callFunctionOn.
|
||||||
|
// Devuelve una Promise<actionableResult>. La logica replica checkElementStates +
|
||||||
|
// _checkElementIsStable + expectHitTarget del injected script de Playwright,
|
||||||
|
// adaptada a un solo paso autocontenido (sin caches ni dependencias externas).
|
||||||
|
const actionableJS = `function(needEnabled, scrollAlign) {
|
||||||
|
var target = this;
|
||||||
|
var fail = function(state, detail) { return {ok:false, state:state, detail:detail||"", x:0, y:0, pageX:0, pageY:0}; };
|
||||||
|
|
||||||
|
if (!target || !target.isConnected) return Promise.resolve(fail("notconnected"));
|
||||||
|
if (target.nodeType !== 1) {
|
||||||
|
// Si el nodo no es un Element (ej. texto), intentar su elemento padre.
|
||||||
|
target = target.parentElement;
|
||||||
|
if (!target) return Promise.resolve(fail("notconnected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) VISIBLE: rect con area + computed style no oculto.
|
||||||
|
var isVisible = function(el) {
|
||||||
|
if (!el || !el.isConnected) return false;
|
||||||
|
var rects = el.getClientRects();
|
||||||
|
if (!rects || rects.length === 0) return false;
|
||||||
|
var st = (el.ownerDocument && el.ownerDocument.defaultView)
|
||||||
|
? el.ownerDocument.defaultView.getComputedStyle(el) : null;
|
||||||
|
if (st) {
|
||||||
|
if (st.visibility === "hidden" || st.display === "none") return false;
|
||||||
|
if (parseFloat(st.opacity || "1") === 0) return false;
|
||||||
|
}
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
return r.width > 0 && r.height > 0;
|
||||||
|
};
|
||||||
|
if (!isVisible(target)) return Promise.resolve(fail("visible"));
|
||||||
|
|
||||||
|
// 2) ENABLED (opcional): disabled nativo, aria-disabled o <fieldset disabled>.
|
||||||
|
if (needEnabled) {
|
||||||
|
var isDisabled = function(el) {
|
||||||
|
var native = ["BUTTON","INPUT","SELECT","TEXTAREA","OPTION","OPTGROUP"];
|
||||||
|
var n = el;
|
||||||
|
while (n) {
|
||||||
|
if (n.nodeType === 1) {
|
||||||
|
var tag = (n.tagName || "").toUpperCase();
|
||||||
|
if (native.indexOf(tag) !== -1 && n.hasAttribute && n.hasAttribute("disabled")) return true;
|
||||||
|
// fieldset disabled deshabilita a sus controles (salvo dentro del legend).
|
||||||
|
if (tag === "FIELDSET" && n.hasAttribute && n.hasAttribute("disabled")) return true;
|
||||||
|
var ad = n.getAttribute && n.getAttribute("aria-disabled");
|
||||||
|
if (ad && ad.toLowerCase() === "true") return true;
|
||||||
|
}
|
||||||
|
// Subir por DOM y cruzar shadow boundaries.
|
||||||
|
n = n.parentElement || (n.parentNode && n.parentNode.host) || (n.assignedSlot || null);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if (isDisabled(target)) return Promise.resolve(fail("enabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) SCROLL INTO VIEW con la alineacion rotada por el caller.
|
||||||
|
try { target.scrollIntoView({block: scrollAlign, inline: scrollAlign, behavior: "instant"}); }
|
||||||
|
catch (e) { try { target.scrollIntoView(); } catch (e2) {} }
|
||||||
|
|
||||||
|
// 3) STABLE: comparar getBoundingClientRect en dos requestAnimationFrame seguidos.
|
||||||
|
var rectOf = function(el) {
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
return {x: r.left, y: r.top, w: r.width, h: r.height};
|
||||||
|
};
|
||||||
|
var rafTwice = function() {
|
||||||
|
return new Promise(function(res) {
|
||||||
|
requestAnimationFrame(function() { requestAnimationFrame(function() { res(); }); });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var first = rectOf(target);
|
||||||
|
return rafTwice().then(function() {
|
||||||
|
if (!target.isConnected) return fail("notconnected");
|
||||||
|
var second = rectOf(target);
|
||||||
|
var same = first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
||||||
|
if (!same) return fail("stable");
|
||||||
|
|
||||||
|
var r = second;
|
||||||
|
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||||
|
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
var cx = r.x + r.w / 2;
|
||||||
|
var cy = r.y + r.h / 2;
|
||||||
|
|
||||||
|
// 4b) IN VIEWPORT: el punto central debe caer dentro del viewport tras el scroll.
|
||||||
|
if (cx < 0 || cy < 0 || cx > vw || cy > vh) return fail("inviewport");
|
||||||
|
|
||||||
|
// 5) HIT-TEST: elementFromPoint subiendo por shadow roots; el golpeado debe ser
|
||||||
|
// el target o un descendiente suyo (cruzando shadow boundaries).
|
||||||
|
var enclosingRoot = function(el) {
|
||||||
|
var node = el;
|
||||||
|
while (node && node.parentNode) node = node.parentNode;
|
||||||
|
if (node && (node.nodeType === 11 || node.nodeType === 9)) return node;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
var parentOrHost = function(el) {
|
||||||
|
if (el.parentElement) return el.parentElement;
|
||||||
|
if (el.parentNode && el.parentNode.nodeType === 11 && el.parentNode.host) return el.parentNode.host;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recolectar roots desde el target hacia arriba (document u shadow roots).
|
||||||
|
var roots = [];
|
||||||
|
var p = target;
|
||||||
|
while (p) {
|
||||||
|
var root = enclosingRoot(p);
|
||||||
|
if (!root) break;
|
||||||
|
roots.push(root);
|
||||||
|
if (root.nodeType === 9) break;
|
||||||
|
p = root.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit en cada root debe apuntar al siguiente root; en el ultimo, al target/descendiente.
|
||||||
|
var hit = null;
|
||||||
|
for (var i = roots.length - 1; i >= 0; i--) {
|
||||||
|
var rt = roots[i];
|
||||||
|
var inner = rt.elementFromPoint ? rt.elementFromPoint(cx, cy) : null;
|
||||||
|
if (!inner) break;
|
||||||
|
hit = inner;
|
||||||
|
if (i && roots[i - 1] && inner !== roots[i - 1].host) break;
|
||||||
|
}
|
||||||
|
if (!hit) return fail("intercepted", "ningún elemento en el punto central");
|
||||||
|
|
||||||
|
// Subir desde el hit hasta el target (composed tree: assignedSlot primero).
|
||||||
|
var cur = hit;
|
||||||
|
while (cur && cur !== target) {
|
||||||
|
cur = cur.assignedSlot || parentOrHost(cur);
|
||||||
|
}
|
||||||
|
if (cur !== target) {
|
||||||
|
var desc = hit.tagName ? hit.tagName.toLowerCase() : "node";
|
||||||
|
if (hit.id) desc += "#" + hit.id;
|
||||||
|
else if (hit.className && typeof hit.className === "string" && hit.className.trim())
|
||||||
|
desc += "." + hit.className.trim().split(/\s+/)[0];
|
||||||
|
return fail("intercepted", desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sx = window.scrollX || window.pageXOffset || 0;
|
||||||
|
var sy = window.scrollY || window.pageYOffset || 0;
|
||||||
|
return {ok:true, state:"ok", detail:"", x:cx, y:cy, pageX:cx + sx, pageY:cy + sy};
|
||||||
|
});
|
||||||
|
}`
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: cdp_wait_actionable
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpWaitActionable(c *CDPConn, backendNodeID int, needEnabled bool, timeout time.Duration) (x float64, y float64, err error)"
|
||||||
|
description: "Bloquea hasta que el elemento del #ref sea accionable (listo para un click/hover fiable) o expire timeout. Reproduce el modelo de actionability de Playwright: en bucle con backoff [0,20,100,100,500]ms comprueba visible (client rects + computed style), stable (mismo getBoundingClientRect en dos requestAnimationFrame seguidos), enabled opcional (disabled / aria-disabled / fieldset disabled subiendo la jerarquía), scroll into view rotando alineación block (center/start/end), y hit-test (elementFromPoint subiendo por shadow DOM apunta al target o descendiente). Devuelve el punto central (x,y) en coords de viewport listo para Input.dispatchMouseEvent. Al expirar, el error indica qué estado falló (not visible / not stable / disabled / outside viewport / intercepted by other element)."
|
||||||
|
tags: [cdp, browser, action, ref, actionability, browser-actionability, navegator]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "Conexión CDP activa al tab objetivo."
|
||||||
|
- name: backendNodeID
|
||||||
|
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||||
|
- name: needEnabled
|
||||||
|
desc: "Si true, exige también el estado enabled (no disabled, no aria-disabled=true, no dentro de <fieldset disabled>). Pasar false para elementos no interactivos (texto, contenedores) donde enabled no aplica."
|
||||||
|
- name: timeout
|
||||||
|
desc: "Tiempo máximo de espera antes de rendirse. <=0 usa 5s por defecto. El bucle de reintento nunca duerme más allá de este deadline."
|
||||||
|
output: "(x, y) punto central del elemento en coordenadas de viewport (CSS px), listo para despachar el pointer, cuando todos los chequeos pasan; error si la conexión es nil, el nodo no resuelve a objectId, se desconecta del DOM, o expira el timeout (con el estado que falló al final)."
|
||||||
|
file_path: "functions/browser/cdp_wait_actionable.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Tras un page_perceive que devuelve outline con #ref=1234, esperar a que el
|
||||||
|
// elemento sea accionable y luego clicar el punto exacto que devuelve:
|
||||||
|
conn, _ := CdpConnect(9222)
|
||||||
|
x, y, err := CdpWaitActionable(conn, 1234, true, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("no accionable: %v", err) // ej: "intercepted by other element: div#cookie-banner"
|
||||||
|
}
|
||||||
|
// x,y ya están en viewport, estables y sin overlay encima: click fiable.
|
||||||
|
_ = CdpClickXYHuman(conn, x, y, MouseHumanOpts{})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Antes de CUALQUIER click/hover/type que deba ser fiable sobre un #ref del outline.
|
||||||
|
Llamarla justo después de `page_perceive` y antes de `cdp_click_ref` /
|
||||||
|
`cdp_click_xy_human` / `dom_*_ref` para evitar los fallos clásicos del navegador:
|
||||||
|
clicar un botón que aún se está animando hacia su posición, un elemento tapado por
|
||||||
|
un banner de cookies / modal / spinner, o un control todavía `disabled`. Es la
|
||||||
|
puerta de actionability que separa "el nodo existe en el DOM" de "el nodo está
|
||||||
|
listo para recibir el evento ahí donde lo voy a despachar". Usar `needEnabled=true`
|
||||||
|
para botones/inputs/enlaces; `needEnabled=false` para hover sobre texto o medir un
|
||||||
|
contenedor.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Coste de polling.** Es síncrona y bloqueante: hace un `Runtime.callFunctionOn`
|
||||||
|
por iteración + 2 `requestAnimationFrame` por chequeo de estabilidad. En el peor
|
||||||
|
caso poll-ea hasta `timeout` con backoff creciente (0,20,100,100,500ms → 500ms).
|
||||||
|
No la metas en un bucle apretado sobre N elementos sin necesidad; una sola
|
||||||
|
llamada por acción es lo correcto. Timeouts altos sobre elementos que nunca
|
||||||
|
llegan (genuinamente ocultos) cuestan el timeout entero.
|
||||||
|
- **Shadow DOM.** El hit-test sube por shadow roots (`assignedSlot` /
|
||||||
|
`parentNode.host`) y por eso funciona con web components con shadow root
|
||||||
|
*abierto*. Con shadow roots **cerrados** `elementFromPoint` no expone el interior
|
||||||
|
y el hit-test puede reportar `intercepted` erróneamente; en ese caso usar el
|
||||||
|
click vía `element.click()` (modo instant de `cdp_click_ref`), que no depende del
|
||||||
|
hit-test geométrico.
|
||||||
|
- **iframes.** Opera sobre el contexto de la página/frame al que apunta el
|
||||||
|
`*CDPConn`. Un `backendNodeID` de otro frame no resuelve aquí: hay que tener la
|
||||||
|
conexión/contexto del frame correcto (ver `cdp_eval_in_frame`). Las coordenadas
|
||||||
|
devueltas son relativas al viewport de ESE documento, no compuestas con el offset
|
||||||
|
del iframe en la página padre.
|
||||||
|
- **Estabilidad vs animaciones infinitas.** Un elemento con una animación CSS
|
||||||
|
perpetua que mueve su rect (spinner que se desplaza, marquee) nunca pasará el
|
||||||
|
chequeo `stable` y agotará el timeout con "not stable". Es comportamiento
|
||||||
|
correcto (no es accionable de forma fiable), pero conviene saberlo.
|
||||||
|
- **El punto devuelto es (x,y) de viewport**, no de página. Es lo que
|
||||||
|
`Input.dispatchMouseEvent` espera. Si necesitas coords de página (con scroll),
|
||||||
|
el JS interno ya las calcula (`pageX/pageY`) pero la firma pública expone solo
|
||||||
|
las de viewport para encajar con el dispatch de pointer.
|
||||||
Reference in New Issue
Block a user