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:
Egutierrez
2026-06-16 20:49:37 +02:00
parent c4ecf871c8
commit 4187f9b6b1
10 changed files with 1585 additions and 44 deletions
+275
View File
@@ -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
}