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 }