From 4187f9b6b156f05867e2c70665f43bfc6a8f0cee Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Tue, 16 Jun 2026 20:49:37 +0200 Subject: [PATCH] feat(browser): actionability + dropdowns + fill + role locator (estilo Playwright) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 React con #ref=4521: +conn, _ := CdpConnect(9222) + +// Por #ref del AX outline (camino habitual del bucle percibir→actuar): +if err := CdpFill(conn, 4521, "ada@example.com"); err != nil { + log.Fatal(err) +} + +// Por selector CSS estable (resuelve a backendNodeID y delega en CdpFill): +if err := CdpFillSelector(conn, "input[name='email']", "ada@example.com"); err != nil { + log.Fatal(err) +} + +// Vaciar un campo: +_ = CdpFillSelector(conn, "#search", "") + +// Input especial (date): ruta setvalue + eventos input/change: +_ = CdpFillSelector(conn, "input[type='date']", "2026-06-16") +``` + +## Cuando usarla + +Cuando necesites rellenar inputs de formularios controlados por React/Vue/otros frameworks de forma fiable. Es el reemplazo del patrón `DOM.focus` + `CdpTypeText`/`CdpInsertText` que **concatena** al valor existente y a menudo deja el estado del framework desincronizado (el `value` del DOM cambia pero el estado de React no, o al revés). `CdpFill` selecciona y reemplaza el contenido entero y, al usar `Input.insertText` (no el native value setter), emite los eventos `input`/`beforeinput` confiables que hacen que el framework reconcilie su estado. Úsala para login, registro, búsquedas y cualquier campo donde el patrón focus+type falle o duplique texto. Para teclear carácter a carácter simulando un humano (sitios con detección por pulsación o autocompletes estrictos) sigue prefiriendo `CdpTypeRef` (camino human). + +## Gotchas + +- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir (`page_perceive`) antes de actuar. +- **contenteditable**: la ruta needsinput inserta el valor seleccionando todo el contenido, pero la verificación final **no es fiable** para contenteditable (el motor normaliza el HTML). Por eso para contenteditable `CdpFill` no falla por verificación; confía en que `Input.insertText` cuajó. Si necesitas garantía dura del contenido, léelo aparte con `CdpEvaluate`. +- **Inputs especiales** (color/date/time/datetime-local/month/range/week) van por la ruta setvalue: fijan `el.value` y disparan `input`{bubbles,composed}+`change`{bubbles}. Algunos frameworks que escuchan eventos de teclado en estos inputs pueden no reaccionar — es el mismo trade-off que hace Playwright. +- **input[type=number]**: el valor debe ser numérico (`isNaN` lo rechaza con error claro). Espacios se recortan. +- **Frameworks y el evento nativo**: la clave de la robustez es NO usar el "native value setter" (`Object.getOwnPropertyDescriptor(...).set`). React parchea el setter de `value` y se confunde si lo invocas a mano; `Input.insertText` del motor emite los eventos que React intercepta correctamente. Si una versión muy vieja de un framework custom no reacciona, cae a `CdpTypeRef` (char por char). +- **No hace scroll humanizado**: `DOM.focus` hace scroll-into-view del nodo, pero si el input está dentro de un contenedor con scroll propio y oculto, valida visible y puede fallar con "elemento no visible". En ese caso haz `CdpClickRef` (que hace `scrollIntoViewIfNeeded`) antes. +- **value==""** borra el campo enviando `Delete` sobre la selección previa (no `Input.insertText` con cadena vacía, que sería no-op). Esto dispara los eventos de borrado que el framework espera. diff --git a/functions/browser/cdp_find_by_role.go b/functions/browser/cdp_find_by_role.go new file mode 100644 index 00000000..1031a6ef --- /dev/null +++ b/functions/browser/cdp_find_by_role.go @@ -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 +} diff --git a/functions/browser/cdp_find_by_role.md b/functions/browser/cdp_find_by_role.md new file mode 100644 index 00000000..afcff1f3 --- /dev/null +++ b/functions/browser/cdp_find_by_role.md @@ -0,0 +1,82 @@ +--- +name: cdp_find_by_role +kind: function +lang: go +domain: browser +version: "1.0.0" +purity: impure +signature: "func CdpFindByRole(c *CDPConn, role string, opts CdpFindByRoleOpts) (ref int, count int, err error)" +description: "Localiza el primer elemento por su ROLE ARIA + accessible name (estilo getByRole de Playwright) reusando el AX tree (Accessibility.getFullAXTree). Devuelve el backendDOMNodeId (#ref) del primer match y el total de matches para detectar ambiguedad." +tags: [browser] +params: + - name: c + desc: "Conexion CDP viva (*CDPConn) del pool. nil => error." + - name: role + desc: "Rol ARIA exacto a matchear (ej 'button', 'link', 'textbox', 'checkbox')." + - name: opts + desc: "CdpFindByRoleOpts: Name (accessible name, vacio = no filtra), Exact (igualdad en vez de substring), Regex (Name como expresion regular RE2), CaseSensitive (default false)." +output: "(ref int, count int, err error): ref = backendDOMNodeId del primer match (#ref para CdpClickRef/CdpHoverRef); count = total de matches (>1 = ambiguo); err si conexion nula, role vacio, regex invalida, fallo CDP o 0 matches." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/browser/cdp_find_by_role.go" +--- + +## Ejemplo + +```go +c, _ := browser.CdpConnect(9333) // conexion CDP del pool +ref, count, err := browser.CdpFindByRole(c, "button", browser.CdpFindByRoleOpts{ + Name: "Aceptar", // substring del accessible name, case-insensitive +}) +if err != nil { + log.Fatal(err) // ej: no element with role "button" and name "Aceptar" +} +if count > 1 { + log.Printf("aviso: %d botones matchean 'Aceptar', usando el primero", count) +} +// ref es el mismo #ref que produce page_perceive: alimentarlo a CdpClickRef. +_ = browser.CdpClickRef(c, ref, browser.MouseHumanOpts{}) + +// Match exacto + case-sensitive: +ref, _, _ = browser.CdpFindByRole(c, "link", browser.CdpFindByRoleOpts{ + Name: "Iniciar sesion", Exact: true, CaseSensitive: true, +}) + +// Match por regex (ej "Eliminar 3 elementos" / "Eliminar 12 elementos"): +ref, _, _ = browser.CdpFindByRole(c, "button", browser.CdpFindByRoleOpts{ + Name: `^Eliminar \d+ elementos$`, Regex: true, +}) +``` + +## Cuando usarla + +Cuando necesites localizar un control de forma robusta a cambios de DOM/CSS: el rol +ARIA + accessible name sobreviven a refactors de markup y clases CSS que romperian un +selector `nth-of-type`. Es el patron primario que recomienda Playwright (getByRole) +para encontrar elementos accionables (botones, links, inputs). Combina el `ref` +devuelto directamente con `cdp_click_ref` / `cdp_hover_ref` para actuar sin pasar por +un selector fragil. Revisa `count` antes de actuar: si es >1 la busqueda es ambigua +y conviene refinar (Name mas especifico, Exact, o Regex anclada). + +## Gotchas + +- El `name` que se matchea es el **accessible name computado** por el motor de + accesibilidad de Chrome (deriva de aria-label, label asociado, contenido, alt, + title segun la spec ARIA), **no** el `innerText` del elemento. Si buscas por el + texto visible literal, usa `cdp_find_ref_by_text` en su lugar. +- `count > 1` => ambiguedad: se devuelve el primer match en orden del AX tree, que no + siempre es el visualmente primero ni el que quieres. Refina la busqueda. +- El `role` se compara por **igualdad exacta** del rol ARIA: "button" no matchea + "menuitem" aunque ambos sean clicables. Mira el outline de `page_perceive` / + `cdp_get_ax_outline` para ver el rol real que Chrome asigna a cada nodo. +- Nodos `ignored` del AX tree se descartan. Si el elemento esta oculto (aria-hidden, + display:none) puede no aparecer y dar 0 matches. +- El `ref` es un `backendDOMNodeId`: estable mientras el nodo viva, pero si el DOM + muta entre el find y el click el ref puede quedar obsoleto. diff --git a/functions/browser/cdp_select_dropdown.go b/functions/browser/cdp_select_dropdown.go new file mode 100644 index 00000000..77b1098c --- /dev/null +++ b/functions/browser/cdp_select_dropdown.go @@ -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 +// nativo NO aplica. Replica el patron de Playwright (que no tiene API para custom dropdowns): click REAL en el trigger (mousedown, no element.click JS), espera la apertura por polling (aria-expanded=true O [role=listbox]/[role=menu] visible O opciones con rect>0), localiza la opcion por texto normalizado (substring o exacto, case-insensitive) y hace click REAL en su centro, con verificacion suave (aria-expanded vuelve a false o Enter como fallback). Reusa CdpEvaluate, CdpClickXYHuman y CdpPressKey." +tags: [browser, chrome, cdp, automation, dropdown, combobox, listbox, aria, select, react-select, mui, headlessui, devtools] +uses_functions: [cdp_evaluate_go_browser, cdp_click_xy_human_go_browser, cdp_press_key_go_browser] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [fmt, strings, time] +params: + - name: c + desc: "conexion CDP activa (*CDPConn)" + - name: triggerSelector + desc: "selector CSS del elemento que abre el desplegable (el boton/combobox sobre el que se hace click real)" + - name: optionText + desc: "texto visible de la opcion a elegir; se normaliza (trim + colapsar espacios) y se compara case-insensitive, por substring si opts.Exact=false o por igualdad si opts.Exact=true" + - name: opts + desc: "CdpDropdownOpts{Exact bool (igualdad vs substring, default substring); TimeoutMs int (espera apertura+opcion, default 3000); OptionRole string (rol ARIA de las opciones, default 'option' — usar 'menuitem' para menus, 'treeitem' para arboles)}" +output: "error si el trigger no existe, si el dropdown no abre dentro del timeout (\"el dropdown no abrio\"), o si la opcion no aparece (\"option %q not found in dropdown\"); nil si el click sobre la opcion se realizo (la verificacion de cierre es suave y no falla duro si queda ambigua)" +tested: false +tests: [] +test_file_path: "" +file_path: "functions/browser/cdp_select_dropdown.go" +--- + +## Ejemplo + +```go +conn, _ := CdpConnect(9222) +CdpNavigate(conn, "https://mui.com/material-ui/react-select/") + +// Combobox MUI: el trigger es el div con role=combobox; el listbox monta y +// anima al abrir. CdpSelectDropdown clica el trigger, espera a que el listbox +// este visible y entonces clica la opcion "Twenty". +err := CdpSelectDropdown(conn, "[role=combobox]", "Twenty", CdpDropdownOpts{}) +if err != nil { + log.Fatal(err) +} + +// react-select / headlessui: trigger por clase + match exacto + timeout amplio +// para listas que tardan en montar. +err = CdpSelectDropdown(conn, ".select__control", "España", CdpDropdownOpts{ + Exact: true, + TimeoutMs: 6000, +}) + +// Menu tipo dropdown-menu (no listbox): las opciones son role=menuitem. +err = CdpSelectDropdown(conn, "#user-menu-btn", "Cerrar sesion", CdpDropdownOpts{ + OptionRole: "menuitem", +}) +``` + +## Cuando usarla + +Usala cuando el desplegable NO es un `` nativo de HTML usa `CdpSelectOption` (setea +`select.value` + dispara `input`/`change`), que es mas robusto y directo para ese +caso. + +## Gotchas + +- **Click real, no element.click()**: muchos dropdowns custom escuchan `mousedown` + (no `click`), por eso esta funcion despacha eventos de raton reales sobre el + centro del bbox. Solo cae a `element.click()` JS si el nodo no tiene geometria. +- **Animaciones de apertura**: el fallo nº1 reportado en Playwright es clicar la + opcion ANTES de que el listbox monte/anime. Por eso hay polling de apertura + (`dropdownWaitOpen`) que no avanza hasta que hay opciones visibles. Si tu + dropdown anima muy lento, sube `TimeoutMs`. +- **Listas virtualizadas** (react-window, virtuoso): solo renderizan las opciones + en viewport. Si la opcion buscada esta fuera del scroll inicial, puede que nunca + se monte y la funcion devuelva "not found" aunque exista. Mitigacion: escribe en + el combobox para filtrar (`CdpTypeText`) antes de llamar a esta funcion, o haz + scroll dentro del listbox primero. +- **Trigger vs contenedor**: `triggerSelector` debe apuntar al elemento que ABRE el + menu (el boton/combobox), no al `[role=listbox]` (que no existe hasta abrir). +- **Match de texto**: normaliza espacios y es case-insensitive; por defecto es + substring (`Exact=false`). Si varias opciones comparten substring, elige la + primera visible en orden de documento — usa `Exact=true` para desambiguar. +- **OptionRole**: por defecto `option` (`[role=option]`). Para menus de acciones usa + `menuitem`; para arboles `treeitem`. La deteccion de apertura tambien considera + `[role=menu]` y `li[role]` para cubrir patrones comunes. +- **Verificacion suave**: tras clicar, si el dropdown sigue abierto la funcion pulsa + `Enter` como fallback y devuelve `nil`. No falla duro si la seleccion no se puede + confirmar inequivocamente pero el click se hizo — comprueba el estado resultante + (texto del trigger, valor del formulario) si necesitas certeza. +- **iframes**: opera en el documento principal (via `CdpEvaluate`). Para un dropdown + dentro de un iframe necesitarias el contexto del frame (no cubierto aqui). diff --git a/functions/browser/cdp_select_option.go b/functions/browser/cdp_select_option.go index 42d76450..ebb402eb 100644 --- a/functions/browser/cdp_select_option.go +++ b/functions/browser/cdp_select_option.go @@ -5,41 +5,105 @@ import ( "strings" ) -// CdpSelectOption selecciona la