4187f9b6b1
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>
192 lines
6.0 KiB
Go
192 lines
6.0 KiB
Go
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
|
||
}
|