Files
fn_registry/functions/browser/cdp_find_by_role.go
T
Egutierrez 4187f9b6b1 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>
2026-06-16 20:49:37 +02:00

192 lines
6.0 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}