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
+191
View File
@@ -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
}