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
+298
View File
@@ -0,0 +1,298 @@
package browser
import (
"encoding/json"
"fmt"
"strings"
)
// fillNodeInfo es el diagnostico que devuelve fillPrepare tras inspeccionar y
// preparar el nodo en el contexto JS de la pagina. Replica la logica de
// InjectedScript.fill de Playwright sin usar el "native value setter": para los
// campos de texto/contenteditable selecciona el contenido previo y deja que el
// motor inserte el valor con eventos confiables (ruta needsinput); para los
// inputs especiales fija el valor y dispara los eventos (ruta setvalue).
type fillNodeInfo struct {
// Route es "needsinput" (hay que insertar el valor via Input.insertText),
// "setvalue" (ya se fijo el valor + eventos, nada mas que hacer) o "" si hubo error.
Route string `json:"route"`
// Error describe por que el nodo no se puede rellenar (no editable, readonly,
// disabled, oculto, tipo no soportado). Vacio si todo OK.
Error string `json:"error"`
}
// resolveObjectID resuelve un backendDOMNodeId a un Runtime objectId, para poder
// ejecutar JS con `this` apuntando a ese nodo concreto via Runtime.callFunctionOn.
func resolveObjectID(c *CDPConn, backendNodeID int) (string, error) {
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
if err != nil {
return "", fmt.Errorf("resolveNode ref %d: %w", backendNodeID, err)
}
obj, _ := res["object"].(map[string]any)
objID, _ := obj["objectId"].(string)
if objID == "" {
return "", fmt.Errorf("sin objectId para ref %d", backendNodeID)
}
return objID, nil
}
// callFunctionOnJSON ejecuta functionDeclaration con `this` = objectId, pasando
// args como argumentos posicionales, y deserializa el valor de retorno (por valor)
// en out. La funcion JS debe devolver un objeto serializable.
func callFunctionOnJSON(c *CDPConn, objectID, functionDeclaration string, args []any, out any) error {
callArgs := make([]any, len(args))
for i, a := range args {
callArgs[i] = map[string]any{"value": a}
}
res, err := c.sendCDP("Runtime.callFunctionOn", map[string]any{
"objectId": objectID,
"functionDeclaration": functionDeclaration,
"arguments": callArgs,
"returnByValue": true,
"awaitPromise": true,
})
if err != nil {
return err
}
if exc, ok := res["exceptionDetails"]; ok && exc != nil {
excMap, _ := exc.(map[string]any)
text, _ := excMap["text"].(string)
return fmt.Errorf("excepcion JS: %s", text)
}
if out == nil {
return nil
}
resVal, ok := res["result"].(map[string]any)
if !ok {
return fmt.Errorf("resultado inesperado: %v", res)
}
b, err := json.Marshal(resVal["value"])
if err != nil {
return fmt.Errorf("marshal valor de retorno: %w", err)
}
return json.Unmarshal(b, out)
}
// fillPrepareJS es la funcion JS (con `this` = elemento) que valida editabilidad,
// detecta el tipo y prepara el nodo. Replica InjectedScript.fill de Playwright:
// NO usa el native value setter para text/textarea/contenteditable (selecciona el
// valor previo y devuelve "needsinput" para que Input.insertText, con eventos
// confiables del motor, haga que React/Vue reconcilien solos). Para inputs
// especiales fija el valor y dispara input/change con {bubbles, composed}.
//
// arg[0] = value (string).
const fillPrepareJS = `function(value){
var el = this;
if (!el || el.nodeType !== 1) return {route:"", error:"el #ref no es un elemento"};
// Visibilidad: rect con area + no display:none/visibility:hidden.
var rect = el.getBoundingClientRect();
var style = el.ownerDocument.defaultView.getComputedStyle(el);
if (style.visibility === "hidden" || style.display === "none" || (rect.width === 0 && rect.height === 0))
return {route:"", error:"elemento no visible"};
var tag = el.nodeName.toLowerCase();
if (tag === "input") {
var type = (el.type || "text").toLowerCase();
if (el.disabled) return {route:"", error:"input deshabilitado"};
if (el.readOnly) return {route:"", error:"input es readonly"};
var kSetValue = {color:1, date:1, time:1, "datetime-local":1, month:1, range:1, week:1};
var kTypeInto = {"":1, email:1, number:1, password:1, search:1, tel:1, text:1, url:1};
if (!kTypeInto[type] && !kSetValue[type])
return {route:"", error:"input de tipo '"+type+"' no se puede rellenar"};
if (type === "number") {
value = value.trim();
if (value !== "" && isNaN(Number(value)))
return {route:"", error:"no se puede escribir texto en input[type=number]"};
}
if (type === "color") value = value.toLowerCase();
if (kSetValue[type]) {
value = value.trim();
el.focus();
el.value = value;
if (el.value !== value) return {route:"", error:"valor malformado para input[type="+type+"]"};
el.dispatchEvent(new Event("input", {bubbles:true, composed:true}));
el.dispatchEvent(new Event("change", {bubbles:true}));
return {route:"setvalue", error:""};
}
// Ruta needsinput: seleccionar el valor previo para que insertText lo reemplace.
el.select();
el.focus();
return {route:"needsinput", error:""};
}
if (tag === "textarea") {
if (el.disabled) return {route:"", error:"textarea deshabilitado"};
if (el.readOnly) return {route:"", error:"textarea es readonly"};
el.selectionStart = 0;
el.selectionEnd = el.value.length;
el.focus();
return {route:"needsinput", error:""};
}
if (el.isContentEditable) {
el.focus();
var range = el.ownerDocument.createRange();
range.selectNodeContents(el);
var sel = el.ownerDocument.defaultView.getSelection();
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
return {route:"needsinput", error:""};
}
return {route:"", error:"el elemento no es input, textarea ni [contenteditable]"};
}`
// fillVerifyJS lee el valor actual del nodo (input.value/textarea.value o
// textContent de contenteditable) para verificar que el fill surtio efecto.
// arg[0] = expected (string). Devuelve {ok:bool, got:string, verifiable:bool}.
const fillVerifyJS = `function(expected){
var el = this;
var tag = el.nodeName.toLowerCase();
if (tag === "input" || tag === "textarea") {
var type = tag === "input" ? (el.type||"text").toLowerCase() : "text";
var got = String(el.value);
var exp = expected;
if (type === "number" || type === "color" || type === "date" || type === "time" ||
type === "datetime-local" || type === "month" || type === "range" || type === "week") {
exp = expected.trim();
if (type === "color") exp = exp.toLowerCase();
}
return {ok: got === exp, got: got, verifiable: true};
}
// contenteditable: no verificable de forma fiable (el motor normaliza el HTML).
return {ok: true, got: String(el.textContent||""), verifiable: false};
}`
// CdpFill rellena un campo de texto controlado por frameworks (React/Vue) de
// forma robusta, estilo Playwright. backendNodeID es un backendDOMNodeId (el #ref
// del AX outline de page_perceive).
//
// Comportamiento (replica InjectedScript.fill):
// 1. Valida visible + enabled + editable (no readonly/disabled) en el contexto JS.
// 2. Enfoca el nodo.
// 3. Detecta el tipo:
// - text/textarea/email/search/url/tel/password/number/contenteditable: ruta
// "needsinput" — selecciona el valor previo y luego inserta value con
// Input.insertText (eventos input/beforeinput confiables del motor; React/Vue
// reconcilian solos). Con value=="" borra la seleccion (Delete) en vez de insertar.
// - color/date/time/datetime-local/month/range/week: ruta "setvalue" — fija
// el.value y dispara input{bubbles,composed} + change{bubbles}.
// 4. Verifica que el.value === value al final (casos verificables); si no, error.
//
// A diferencia del patron focus+type que concatena al valor existente, CdpFill
// reemplaza el contenido entero y es fiable con inputs controlados por frameworks.
func CdpFill(c *CDPConn, backendNodeID int, value string) error {
if c == nil {
return fmt.Errorf("cdp fill: conexion nula")
}
objID, err := resolveObjectID(c, backendNodeID)
if err != nil {
return fmt.Errorf("cdp fill: %w", err)
}
// Enfocar el nodo (idempotente; fillPrepareJS tambien enfoca, pero DOM.focus
// hace scroll-into-view y deja el activeElement listo para Input.insertText).
if _, err := c.sendCDP("DOM.focus", map[string]any{"backendNodeId": backendNodeID}); err != nil {
return fmt.Errorf("cdp fill: focus ref %d: %w", backendNodeID, err)
}
// Validar + preparar el nodo (selecciona valor previo o fija value+eventos).
var info fillNodeInfo
if err := callFunctionOnJSON(c, objID, fillPrepareJS, []any{value}, &info); err != nil {
return fmt.Errorf("cdp fill: preparar ref %d: %w", backendNodeID, err)
}
if info.Error != "" {
return fmt.Errorf("cdp fill: ref %d no editable: %s", backendNodeID, info.Error)
}
switch info.Route {
case "setvalue":
// El valor ya se fijo y se dispararon los eventos en fillPrepareJS.
case "needsinput":
if value == "" {
// Sin valor: borrar la seleccion (el valor previo ya esta seleccionado).
// Delete elimina la seleccion sin insertar nada.
del := map[string]any{"type": "keyDown", "key": "Delete", "code": "Delete", "windowsVirtualKeyCode": 46}
if _, err := c.sendCDP("Input.dispatchKeyEvent", del); err != nil {
return fmt.Errorf("cdp fill: borrar ref %d: %w", backendNodeID, err)
}
delUp := map[string]any{"type": "keyUp", "key": "Delete", "code": "Delete", "windowsVirtualKeyCode": 46}
if _, err := c.sendCDP("Input.dispatchKeyEvent", delUp); err != nil {
return fmt.Errorf("cdp fill: borrar ref %d: %w", backendNodeID, err)
}
} else {
// Insertar el valor (reemplaza la seleccion previa) en un round-trip.
// Input.insertText emite los eventos confiables que React/Vue necesitan.
if _, err := c.sendCDP("Input.insertText", map[string]any{"text": value}); err != nil {
return fmt.Errorf("cdp fill: insertText ref %d: %w", backendNodeID, err)
}
}
default:
return fmt.Errorf("cdp fill: ruta de preparacion desconocida %q para ref %d", info.Route, backendNodeID)
}
// Verificar que el valor cuajo (solo casos verificables: input/textarea).
var ver struct {
OK bool `json:"ok"`
Got string `json:"got"`
Verifiable bool `json:"verifiable"`
}
if err := callFunctionOnJSON(c, objID, fillVerifyJS, []any{value}, &ver); err != nil {
// La verificacion en si fallo (nodo desaparecido, etc.): no enmascarar.
return fmt.Errorf("cdp fill: verificar ref %d: %w", backendNodeID, err)
}
if ver.Verifiable && !ver.OK {
return fmt.Errorf("cdp fill: verificacion fallida en ref %d: el campo quedo con %q, se esperaba %q", backendNodeID, ver.Got, value)
}
return nil
}
// CdpFillSelector resuelve un selector CSS a su backendDOMNodeId (via
// DOM.getDocument + DOM.querySelector + DOM.describeNode) y delega en CdpFill.
// Util cuando se tiene un selector estable en vez del #ref del AX outline.
func CdpFillSelector(c *CDPConn, selector string, value string) error {
if c == nil {
return fmt.Errorf("cdp fill selector: conexion nula")
}
if strings.TrimSpace(selector) == "" {
return fmt.Errorf("cdp fill selector: selector vacio")
}
docRes, err := c.sendCDP("DOM.getDocument", map[string]any{"depth": 0})
if err != nil {
return fmt.Errorf("cdp fill selector: DOM.getDocument: %w", err)
}
root, ok := docRes["root"].(map[string]any)
if !ok {
return fmt.Errorf("cdp fill selector: respuesta de DOM.getDocument sin root")
}
rootNodeID, ok := root["nodeId"].(float64)
if !ok {
return fmt.Errorf("cdp fill selector: DOM.getDocument sin nodeId raiz")
}
qsRes, err := c.sendCDP("DOM.querySelector", map[string]any{
"nodeId": int(rootNodeID),
"selector": selector,
})
if err != nil {
return fmt.Errorf("cdp fill selector: DOM.querySelector %q: %w", selector, err)
}
nodeIDVal, ok := qsRes["nodeId"].(float64)
if !ok || int(nodeIDVal) == 0 {
return fmt.Errorf("cdp fill selector: el selector %q no coincide con ningun elemento", selector)
}
// Resolver el nodeId a backendNodeId (CdpFill opera sobre backendDOMNodeId).
descRes, err := c.sendCDP("DOM.describeNode", map[string]any{"nodeId": int(nodeIDVal)})
if err != nil {
return fmt.Errorf("cdp fill selector: DOM.describeNode %q: %w", selector, err)
}
node, ok := descRes["node"].(map[string]any)
if !ok {
return fmt.Errorf("cdp fill selector: DOM.describeNode %q sin node", selector)
}
backendID, ok := node["backendNodeId"].(float64)
if !ok || int(backendID) == 0 {
return fmt.Errorf("cdp fill selector: %q sin backendNodeId", selector)
}
return CdpFill(c, int(backendID), value)
}
+66
View File
@@ -0,0 +1,66 @@
---
name: cdp_fill
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpFill(c *CDPConn, backendNodeID int, value string) error"
description: "Rellena un campo de texto de forma robusta estilo Playwright, fiable con inputs controlados por frameworks (React/Vue). Valida visible+enabled+editable, enfoca el nodo, y según el tipo: para text/textarea/email/search/url/tel/password/number/contenteditable selecciona el valor previo y lo reemplaza con Input.insertText (eventos input/beforeinput confiables del motor — React/Vue reconcilian solos); para inputs especiales (color/date/time/range/week/month/datetime-local) fija el.value y dispara input{bubbles,composed}+change{bubbles}. Verifica que el.value===value al final. backendNodeID es el #ref del AX outline. Variante por selector: CdpFillSelector. Reemplaza el patrón frágil focus+type que concatena al valor existente."
tags: [cdp, browser, action, ref, fill, form, react, vue, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
params:
- name: c
desc: "Conexión CDP activa al tab objetivo (*CDPConn)."
- name: backendNodeID
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
- name: value
desc: "Valor a poner en el campo. Reemplaza el contenido entero (no concatena). value=='' borra el campo. Para input[type=number] debe ser numérico; para color se normaliza a minúsculas."
output: "nil si el campo quedó con el valor pedido; error si la conexión es nil, el nodo no es editable (readonly/disabled/oculto), el tipo de input no se puede rellenar, o la verificación final (el.value===value) falla."
file_path: "functions/browser/cdp_fill.go"
---
## Ejemplo
```go
// Tras un page_perceive que devuelve un <input> 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.
+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
}
+82
View File
@@ -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.
+275
View File
@@ -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
// <select> nativo NO aplica y por tanto CdpSelectOption no sirve.
//
// El patron replica como Playwright compone la accion (no tiene API para custom
// dropdowns): click(trigger) -> esperar apertura -> getByRole('option', {name}) ->
// click(option). Pasos:
//
// 1. Localiza el trigger por triggerSelector (CSS) y hace CLICK REAL (mouse
// mousePressed/mouseReleased sobre el centro del bbox, no element.click() JS):
// muchos dropdowns escuchan 'mousedown', no 'click'.
// 2. Espera la apertura (polling hasta TimeoutMs): el trigger pasa a
// aria-expanded="true", O aparece un [role=listbox]/[role=menu] visible, O hay
// elementos con el rol de opcion (OptionRole / li[role] / menuitem) con rect>0.
// No avanza hasta que haya opciones visibles.
// 3. Localiza la opcion cuyo texto normalizado (trim + colapsar espacios)
// coincide con optionText (substring si Exact=false, igualdad si Exact=true),
// entre las opciones con rol visibles. Error claro si no aparece en el timeout.
// 4. CLICK REAL en el centro de esa opcion.
// 5. Verifica el cierre/seleccion: aria-expanded vuelve a false O el trigger
// refleja el texto elegido; si la verificacion es ambigua, intenta Enter como
// fallback suave. No falla duro si el click se hizo pero la verificacion queda
// incierta.
//
// purity: impure (DOM + input real + tiempo). Devuelve error si el trigger no
// existe, si el dropdown no abre en el timeout, o si la opcion no aparece.
func CdpSelectDropdown(c *CDPConn, triggerSelector string, optionText string, opts CdpDropdownOpts) error {
if c == nil {
return fmt.Errorf("cdp select dropdown: conexion nula")
}
if strings.TrimSpace(triggerSelector) == "" {
return fmt.Errorf("cdp select dropdown: triggerSelector vacio")
}
if strings.TrimSpace(optionText) == "" {
return fmt.Errorf("cdp select dropdown: optionText vacio")
}
timeoutMs := opts.TimeoutMs
if timeoutMs <= 0 {
timeoutMs = 3000
}
optionRole := strings.TrimSpace(opts.OptionRole)
if optionRole == "" {
optionRole = "option"
}
deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
// 1. Click REAL en el trigger.
if err := dropdownClickSelector(c, triggerSelector); err != nil {
return fmt.Errorf("cdp select dropdown: click trigger %q: %w", triggerSelector, err)
}
// 2. Esperar apertura (opciones visibles).
if err := dropdownWaitOpen(c, triggerSelector, optionRole, deadline); err != nil {
return fmt.Errorf("cdp select dropdown: %w", err)
}
// 3 + 4. Localizar la opcion por texto y click REAL en su centro.
cx, cy, err := dropdownFindOptionCenter(c, optionRole, optionText, opts.Exact, deadline)
if err != nil {
return fmt.Errorf("cdp select dropdown: %w", err)
}
if err := CdpClickXYHuman(c, cx, cy, MouseHumanOpts{Mode: "auto"}); err != nil {
return fmt.Errorf("cdp select dropdown: click opcion %q: %w", optionText, err)
}
// 5. Verificacion suave: dar un instante a que se cierre/refleje, y si sigue
// abierto intentar Enter (algunos comboboxes confirman con Enter sobre la
// opcion activa). No es fatal si la verificacion queda ambigua.
time.Sleep(120 * time.Millisecond)
if dropdownStillOpen(c, triggerSelector, optionRole) {
_ = CdpPressKey(c, "Enter")
}
return nil
}
// dropdownClickSelector resuelve el bbox del elemento (por selector CSS) y hace
// click real sobre su centro. Hace scroll si hace falta. Cae a element.click() JS
// solo si el nodo no tiene geometria (display:contents, area 0).
func dropdownClickSelector(c *CDPConn, selector string) error {
// Centro del bbox del elemento via getBoundingClientRect en el contexto JS.
js := fmt.Sprintf(`(function(){
var el = document.querySelector(%s);
if (!el) return '__NO_EL__';
el.scrollIntoView({block:'center', inline:'center'});
var r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return '__NO_BOX__';
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
})()`, jsString(selector))
res, err := CdpEvaluate(c, js)
if err != nil {
return fmt.Errorf("resolver bbox: %w", err)
}
res = strings.Trim(res, `"`)
switch res {
case "__NO_EL__":
return fmt.Errorf("trigger no encontrado para selector %q", selector)
case "__NO_BOX__":
// Sin geometria: fallback a element.click() JS (no dispara mousedown real).
return dropdownClickViaJS(c, selector)
}
x, y, ok := parseXY(res)
if !ok {
return fmt.Errorf("bbox invalido %q", res)
}
return CdpClickXYHuman(c, x, y, MouseHumanOpts{Mode: "auto"})
}
// dropdownClickViaJS es el fallback sin geometria: element.click() en el contexto JS.
func dropdownClickViaJS(c *CDPConn, selector string) error {
js := fmt.Sprintf(`(function(){
var el = document.querySelector(%s);
if (!el) return '__NO_EL__';
el.click();
return '__OK__';
})()`, jsString(selector))
res, err := CdpEvaluate(c, js)
if err != nil {
return err
}
if strings.Trim(res, `"`) != "__OK__" {
return fmt.Errorf("element.click() JS fallo (%s)", strings.Trim(res, `"`))
}
return nil
}
// dropdownWaitOpen hace polling hasta deadline esperando que el dropdown este
// abierto: trigger con aria-expanded="true", O un [role=listbox]/[role=menu]
// visible, O algun elemento con el rol de opcion (rect>0). Error si no abre.
func dropdownWaitOpen(c *CDPConn, triggerSelector, optionRole string, deadline time.Time) error {
for {
open, err := dropdownIsOpen(c, triggerSelector, optionRole)
if err != nil {
return err
}
if open {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("el dropdown no abrio (sin opciones visibles) tras el timeout para trigger %q", triggerSelector)
}
time.Sleep(80 * time.Millisecond)
}
}
// dropdownIsOpen comprueba una vez si el dropdown esta abierto.
func dropdownIsOpen(c *CDPConn, triggerSelector, optionRole string) (bool, error) {
js := fmt.Sprintf(`(function(){
var trigger = document.querySelector(%s);
if (trigger && trigger.getAttribute('aria-expanded') === 'true') return 'open';
function visible(el){
if (!el) return false;
var r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return false;
var cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.display === 'none') return false;
return true;
}
// Un contenedor listbox/menu visible cuenta como abierto.
var containers = document.querySelectorAll('[role=listbox],[role=menu]');
for (var i=0;i<containers.length;i++){ if (visible(containers[i])) return 'open'; }
// O al menos una opcion (por rol o por li[role]) visible.
var role = %s;
var sel = '[role=' + role + '],li[role],[role=menuitem]';
var opts = document.querySelectorAll(sel);
for (var j=0;j<opts.length;j++){ if (visible(opts[j])) return 'open'; }
return 'closed';
})()`, jsString(triggerSelector), jsString(optionRole))
res, err := CdpEvaluate(c, js)
if err != nil {
return false, fmt.Errorf("comprobar apertura: %w", err)
}
return strings.Trim(res, `"`) == "open", nil
}
// dropdownStillOpen es una comprobacion best-effort para la verificacion final;
// nunca propaga error (un fallo aqui no debe invalidar el click ya hecho).
func dropdownStillOpen(c *CDPConn, triggerSelector, optionRole string) bool {
open, err := dropdownIsOpen(c, triggerSelector, optionRole)
if err != nil {
return false
}
return open
}
// dropdownFindOptionCenter localiza, entre las opciones visibles del dropdown, la
// que matchea optionText (substring si exact=false, igualdad si exact=true; ambas
// case-insensitive sobre texto normalizado) y devuelve el centro de su bbox. Hace
// polling hasta deadline para tolerar listas virtualizadas que montan tarde.
func dropdownFindOptionCenter(c *CDPConn, optionRole, optionText string, exact bool, deadline time.Time) (float64, float64, error) {
js := fmt.Sprintf(`(function(){
var role = %s;
var want = %s;
var exact = %t;
function norm(v){ return (v||'').replace(/\s+/g,' ').trim().toLowerCase(); }
function visible(el){
var r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return false;
var cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.display === 'none') return false;
return true;
}
var target = norm(want);
var sel = '[role=' + role + '],li[role],[role=menuitem]';
var nodes = document.querySelectorAll(sel);
for (var i=0;i<nodes.length;i++){
var el = nodes[i];
if (!visible(el)) continue;
var t = norm(el.innerText || el.textContent || '');
var ok = exact ? (t === target) : (t.indexOf(target) >= 0);
if (ok){
var r = el.getBoundingClientRect();
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
}
}
return '__NO_OPTION__';
})()`, jsString(optionRole), jsString(optionText), exact)
for {
res, err := CdpEvaluate(c, js)
if err != nil {
return 0, 0, fmt.Errorf("buscar opcion: %w", err)
}
res = strings.Trim(res, `"`)
if res != "__NO_OPTION__" {
if x, y, ok := parseXY(res); ok {
return x, y, nil
}
}
if time.Now().After(deadline) {
return 0, 0, fmt.Errorf("option %q not found in dropdown", optionText)
}
time.Sleep(80 * time.Millisecond)
}
}
// parseXY extrae x/y de un JSON {"x":..,"y":..} que llega ya des-escapado de
// CdpEvaluate (que devuelve el JSON.stringify como string). Hace un parse ligero
// sin importar encoding/json de nuevo en el hot path: busca los numeros tras x/y.
func parseXY(s string) (float64, float64, bool) {
// CdpEvaluate devuelve la cadena producida por JSON.stringify; las comillas
// internas vienen escapadas como \" tras pasar por el unmarshal de Go.
s = strings.ReplaceAll(s, `\"`, `"`)
var x, y float64
n, err := fmt.Sscanf(s, `{"x":%g,"y":%g}`, &x, &y)
if err != nil || n != 2 {
return 0, 0, false
}
return x, y, true
}
+98
View File
@@ -0,0 +1,98 @@
---
name: cdp_select_dropdown
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpSelectDropdown(c *CDPConn, triggerSelector string, optionText string, opts CdpDropdownOpts) error"
description: "Selecciona una opcion en un DESPLEGABLE CUSTOM (combobox/listbox ARIA, react-select, MUI Select, headlessui, select2) — esos donde un <select> 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 `<select>` nativo: comboboxes/listboxes ARIA,
react-select, MUI Select, headlessui, select2, Ant Design, o cualquier menu hecho
con `<div>`/`<li>` + JS donde elegir = clicar el trigger y luego clicar la opcion
del menu desplegado. Es el equivalente al patron de Playwright
`click(trigger) -> getByRole('option', {name}) -> click(option)`, con la espera de
apertura ya resuelta. Para un `<select>` 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).
+92 -26
View File
@@ -5,41 +5,105 @@ import (
"strings"
)
// CdpSelectOption selecciona la <option> de un <select> (localizado por selector
// CSS) cuyo value coincide con value; si ningun value coincide, busca por texto
// visible de la option. Tras setear select.value despacha los eventos 'input' y
// 'change' con bubbles:true para que frameworks (React/Vue) reaccionen al cambio.
// CdpSelectOption selecciona una <option> de un <select> nativo (localizado por
// selector CSS) replicando la semantica de Playwright (injectedScript.selectOptions).
//
// Devuelve error si el select no existe ("select not found") o si ninguna option
// coincide por value ni por texto ("option not found").
// Orden de matching de value contra cada <option>, en este orden:
// 1. value exacto: option.value === value.
// 2. label/texto exacto: option.label === value (sin normalizar).
// 3. label/texto NORMALIZADO: normalizeWhiteSpace(option.label) === normalizeWhiteSpace(value),
// donde normalizar = quitar zero-width space (U+200B) y soft hyphen (U+00AD),
// trim, y colapsar cualquier secuencia de whitespace a un solo espacio.
// 4. label/texto por substring NORMALIZADO: la primera option cuyo label normalizado
// contenga el value normalizado (fallback para etiquetas largas).
// 5. fallback por indice: solo si value es un entero (>= 0) y existe esa posicion.
//
// Sobre la option encontrada hace focus del select, setea option.selected = true
// (no solo select.value, para que funcione tambien con <select multiple>) y despacha
// 'input' {bubbles:true, composed:true} seguido de 'change' {bubbles:true}, en ese
// orden, para que frameworks (React/Vue/Angular) y shadow DOM reaccionen al cambio.
//
// Si el selector apunta a un <label for=...>, sigue la referencia hasta su control
// (retarget follow-label) antes de validar que sea un <select>.
//
// Devuelve error claro si:
// - el selector no encuentra elemento ("element not found"),
// - el elemento no es un <select> ("element is not a <select> ..."),
// - ninguna option coincide ("option not found in <select>").
func CdpSelectOption(c *CDPConn, selector string, value string) error {
if c == nil {
return fmt.Errorf("cdp select option: conexion nula")
}
// Script JS: localiza el select, busca la option por value y, como fallback,
// por textContent (trim). Si encuentra, setea value, dispara input+change y
// devuelve "__OK__". Si no, devuelve un centinela de error claro. Usamos
// JSON.stringify de los inputs para inyectarlos de forma segura.
// Script JS alineado con Playwright. Devuelve centinelas en string:
// __OK__:<value> cuando selecciona; el resto son codigos de error claros.
// Usamos jsString para inyectar selector/value de forma segura (anti-inyeccion).
js := fmt.Sprintf(`(function() {
var sel = document.querySelector(%s);
if (!sel) return '__NO_SELECT__';
function normWS(t) {
return (t == null ? '' : String(t))
.replace(/[­]/g, '')
.trim()
.replace(/\s+/g, ' ');
}
var el = document.querySelector(%s);
if (!el) return '__NO_EL__';
// retarget follow-label: si es un <label for>, salta a su control.
if (el.nodeName.toLowerCase() === 'label') {
var labelled = null;
var forId = el.getAttribute('for');
if (forId) labelled = document.getElementById(forId);
if (!labelled) labelled = el.querySelector('select, input, textarea');
if (labelled) el = labelled;
}
if (el.nodeName.toLowerCase() !== 'select') return '__NOT_SELECT__';
var sel = el;
var want = %s;
var wantNorm = normWS(want);
var opts = Array.prototype.slice.call(sel.options);
var match = null;
for (var i = 0; i < opts.length; i++) {
if (opts[i].value === want) { match = opts[i]; break; }
// 1. value exacto.
for (var i = 0; i < opts.length && !match; i++) {
if (opts[i].value === want) match = opts[i];
}
// 2. label/texto exacto.
if (!match) {
for (var j = 0; j < opts.length; j++) {
if ((opts[j].textContent || '').trim() === want) { match = opts[j]; break; }
for (var j = 0; j < opts.length && !match; j++) {
if (opts[j].label === want || (opts[j].textContent || '') === want) match = opts[j];
}
}
// 3. label/texto normalizado exacto.
if (!match && wantNorm !== '') {
for (var k = 0; k < opts.length && !match; k++) {
var ln = normWS(opts[k].label || opts[k].textContent);
if (ln === wantNorm) match = opts[k];
}
}
// 4. label/texto por substring normalizado.
if (!match && wantNorm !== '') {
for (var m = 0; m < opts.length && !match; m++) {
var ln2 = normWS(opts[m].label || opts[m].textContent);
if (ln2.indexOf(wantNorm) !== -1) match = opts[m];
}
}
// 5. fallback por indice: solo si want es un entero >= 0 valido.
if (!match && /^[0-9]+$/.test(want)) {
var idx = parseInt(want, 10);
if (idx >= 0 && idx < opts.length) match = opts[idx];
}
if (!match) return '__NO_OPTION__';
sel.value = match.value;
sel.dispatchEvent(new Event('input', {bubbles: true}));
sel.dispatchEvent(new Event('change', {bubbles: true}));
return '__OK__';
try { sel.focus(); } catch (e) {}
// option.selected en vez de solo select.value: necesario para <select multiple>
// y mas fiel a como un usuario elige una entrada concreta.
if (!sel.multiple) {
for (var n = 0; n < opts.length; n++) opts[n].selected = false;
}
match.selected = true;
sel.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
sel.dispatchEvent(new Event('change', { bubbles: true }));
return '__OK__:' + match.value;
})()`, jsString(selector), jsString(value))
res, err := CdpEvaluate(c, js)
@@ -48,13 +112,15 @@ func CdpSelectOption(c *CDPConn, selector string, value string) error {
}
res = strings.Trim(res, `"`)
switch res {
case "__OK__":
switch {
case strings.HasPrefix(res, "__OK__"):
return nil
case "__NO_SELECT__":
return fmt.Errorf("cdp select option: select not found para selector %q", selector)
case "__NO_OPTION__":
return fmt.Errorf("cdp select option: option not found para value %q en select %q", value, selector)
case res == "__NO_EL__":
return fmt.Errorf("cdp select option: element not found para selector %q", selector)
case res == "__NOT_SELECT__":
return fmt.Errorf("cdp select option: element %q is not a <select> (use cdp_select_dropdown / click el trigger+option para dropdowns custom)", selector)
case res == "__NO_OPTION__":
return fmt.Errorf("cdp select option: option %q not found in <select> %q", value, selector)
default:
return fmt.Errorf("cdp select option: resultado inesperado %q para selector %q", res, selector)
}
+55 -18
View File
@@ -3,10 +3,10 @@ name: cdp_select_option
kind: function
lang: go
domain: browser
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "func CdpSelectOption(c *CDPConn, selector string, value string) error"
description: "Selecciona la <option> de un <select> (localizado por selector CSS) cuyo value coincide con el valor dado; si ningun value coincide, busca por texto visible de la option. Tras setear select.value despacha los eventos 'input' y 'change' con bubbles:true para que frameworks (React/Vue) reaccionen al cambio. Via Runtime.evaluate, reusa CdpEvaluate."
description: "Selecciona una <option> de un <select> nativo (localizado por selector CSS) replicando la semantica de Playwright (injectedScript.selectOptions). Match por value exacto, luego label/texto exacto, luego label normalizado (whitespace-collapse + strip zero-width/soft-hyphen), luego substring normalizado, y por ultimo indice si value es entero. Setea option.selected (soporta <select multiple>), hace focus, y despacha 'input' {bubbles,composed} + 'change' {bubbles}. Valida que el elemento sea <select> (error claro si no) y sigue <label for>. Via Runtime.evaluate, reusa CdpEvaluate."
tags: [chrome, cdp, browser, automation, select, dropdown, form, dom, devtools]
uses_functions: [cdp_evaluate_go_browser]
uses_types: []
@@ -20,8 +20,8 @@ params:
- name: selector
desc: "selector CSS del elemento <select> a modificar"
- name: value
desc: "value de la <option> a seleccionar; si no hay match por value, se busca por texto visible (textContent trimeado)"
output: "error si el select no existe (\"select not found\") o ninguna option coincide por value ni por texto (\"option not found\"); nil si la selección y los eventos se despacharon correctamente"
desc: "criterio de seleccion. Se prueba en orden: value exacto → label/texto exacto → label normalizado (whitespace-collapse + strip U+200B/U+00AD) → label por substring normalizado → indice (si value es un entero)"
output: "error si el selector no encuentra elemento (\"element not found\"), si el elemento no es un <select> (\"element is not a <select> ...\"), o si ninguna option coincide (\"option not found in <select>\"); nil si la selección y los eventos se despacharon correctamente"
tested: false
tests: []
test_file_path: ""
@@ -43,6 +43,11 @@ if err := CdpSelectOption(conn, "#country", "ES"); err != nil {
if err := CdpSelectOption(conn, "select[name=lang]", "Español"); err != nil {
log.Fatal(err)
}
// Seleccionar por indice (3a opcion) cuando ni value ni texto son estables
if err := CdpSelectOption(conn, "#size", "2"); err != nil { // index 2 = 3a option
log.Fatal(err)
}
```
## Cuando usarla
@@ -50,21 +55,53 @@ if err := CdpSelectOption(conn, "select[name=lang]", "Español"); err != nil {
Usala cuando necesites elegir una opcion de un `<select>` nativo en un formulario
web y quieras que un framework (React, Vue, Angular) reaccione al cambio. Es la
forma robusta de rellenar dropdowns durante automatizacion/scraping: a diferencia
de un click sobre la option, setea `select.value` y dispara `input`+`change`, que
es lo que los frameworks escuchan. Combinala con `CdpClick` para enviar el
formulario despues.
de un click sobre la option, setea `option.selected` y dispara `input`+`change`,
que es lo que los frameworks escuchan. Combinala con `CdpClick` para enviar el
formulario despues. Si no conoces el `value` interno, pasa el texto visible (se
normaliza el whitespace) o el indice numerico de la option.
## Gotchas
- Solo funciona con `<select>` nativos (HTML). Dropdowns custom hechos con `<div>`
+ JS (ej. react-select, headlessui) NO son `<select>` reales: para esos hay que
clickar y elegir la opcion del menu desplegado, no usar esta funcion.
- El match por value es exacto (`===`); el fallback por texto compara `textContent`
trimeado de forma exacta tras `.trim()` (no substring, no case-insensitive).
- No hace scroll ni verifica visibilidad: opera sobre el DOM directamente. Si el
`<select>` esta deshabilitado (`disabled`), el value se setea igual pero la UI
puede ignorarlo segun el framework.
- Para `<select multiple>` solo selecciona una opcion (la que coincide) y resetea
el resto, porque setea `select.value` (no añade a `selectedOptions`).
- Si el elemento aun no existe (carga dinamica), retorna "select not found" sin
- **Solo `<select>` nativos.** Si el elemento no es un `<select>` retorna error
claro `element is not a <select> ...`. Dropdowns custom hechos con `<div>` + JS
(react-select, headlessui, Radix, etc.) NO son `<select>` reales: para esos usa
`cdp_select_dropdown` (cuando exista) o clica el trigger con `CdpClickRef` y
luego la opcion del menu desplegado (`CdpFindRefByText` + `CdpClickRef`). NO uses
esta funcion para ellos.
- **Orden de matching del `value` recibido** (se prueba en este orden y para en el
primer match):
1. `option.value` exacto (`===`).
2. `option.label` / `textContent` exacto (sin normalizar).
3. label/texto NORMALIZADO exacto: se quita zero-width space (U+200B) y soft
hyphen (U+00AD), se hace `trim`, y se colapsa cualquier whitespace (`\s+`) a un
solo espacio — igual que `normalizeWhiteSpace` de Playwright.
4. label/texto por SUBSTRING normalizado (primera option cuyo label normalizado
contenga el value normalizado). Util para etiquetas largas; cuidado con
ambiguedad (gana la primera en orden de documento).
5. fallback por INDICE: solo si `value` es un entero `>= 0` valido (`"2"` → 3a
option). Por eso un `value` que casualmente sea numerico puede caer aqui si no
hubo ningun match textual antes — preferi el `value` real cuando exista.
El matching es case-sensitive en todos los pasos (no se hace lowercase).
- **`<select multiple>` soportado:** setea `option.selected = true` sobre la option
encontrada sin tocar el resto de selecciones. En un `<select>` simple deselecciona
las demas antes de marcar la elegida. (La version 1.0.0 solo seteaba `select.value`
y reseteaba el multiple — corregido.)
- **Eventos:** dispara `input` con `{bubbles:true, composed:true}` (el `composed`
permite cruzar shadow DOM, p.ej. web components que envuelven el `<select>`) y
luego `change` con `{bubbles:true}`, en ese orden. Hace `focus()` del select antes.
- No hace scroll ni verifica visibilidad/enabled: opera sobre el DOM directamente.
Si el `<select>` o la `<option>` estan `disabled`, la seleccion se aplica igual
pero la UI puede ignorarla segun el framework (Playwright aqui devolveria
`optionnotenabled`; esta funcion no chequea enabled — mantiene KISS).
- Si el elemento aun no existe (carga dinamica), retorna `element not found` sin
esperar — combinar con `CdpWaitElement` para elementos diferidos.
## Capability growth log
- v1.1.0 (2026-06-16) — alineada con Playwright `injectedScript.selectOptions`:
valida que el elemento sea `<select>` (error claro si no, apuntando a dropdowns
custom), sigue `<label for>`, matching multi-criterio (value → label exacto →
label normalizado whitespace-collapse → substring → indice), usa
`option.selected` en vez de solo `select.value` (soporta `<select multiple>`),
añade `composed:true` al evento `input` (cruza shadow DOM) y `focus()` previo.
Firma intacta (no rompe el caller del MCP `dom_select_option`).
+343
View File
@@ -0,0 +1,343 @@
package browser
import (
"encoding/json"
"fmt"
"time"
)
// actionableBackoff es el calendario de espera entre reintentos del bucle de
// actionability, copiado del _retryAction de Playwright (waitTime [0,20,100,100,500]).
// Tras agotar la tabla, se mantiene en el ultimo valor (500ms) hasta el timeout.
// El primer intento es inmediato (0ms): muchas veces el elemento ya esta listo.
var actionableBackoff = []time.Duration{
0,
20 * time.Millisecond,
100 * time.Millisecond,
100 * time.Millisecond,
500 * time.Millisecond,
}
// actionableScrollAligns rota la alineacion block de scrollIntoView entre
// reintentos. Cyclar las alineaciones (center/start/end) destraba casos donde un
// header position:sticky o un footer fijo tapa el punto al alinear de una sola
// forma — replica el scrollOptions cycling de _retryPointerAction de Playwright.
var actionableScrollAligns = []string{"center", "start", "end"}
// actionableResult es el veredicto que el JS inyectado devuelve por iteracion.
// state describe el primer estado que fallo (para el mensaje de error final);
// x,y son el punto central listo para el pointer cuando ok==true.
type actionableResult struct {
OK bool `json:"ok"`
State string `json:"state"` // "visible" | "stable" | "enabled" | "inviewport" | "intercepted" | "notconnected"
Detail string `json:"detail"` // descripcion del interceptor u otro detalle
X float64 `json:"x"` // punto central viewport (CSS px)
Y float64 `json:"y"` //
PageX float64 `json:"pageX"` // punto central en coords de pagina (scroll incluido)
PageY float64 `json:"pageY"` //
}
// CdpWaitActionable bloquea hasta que el elemento identificado por backendNodeID
// sea accionable (listo para recibir un click/hover fiable) o expire timeout.
// Reproduce el modelo de actionability de Playwright: en cada iteracion comprueba
// que el elemento esta visible, estable (mismo rect en dos requestAnimationFrame
// consecutivos), opcionalmente enabled, dentro del viewport tras scrollIntoView,
// y que el hit-test (document.elementFromPoint subiendo por shadow DOM) apunta al
// propio nodo o a un descendiente. Si algo falla, espera con backoff
// [0,20,100,100,500]ms (luego 500ms constante) y reintenta, rotando la alineacion
// del scroll para destrabar overlays sticky.
//
// Devuelve el punto central (x,y) en coordenadas de viewport (CSS px), listo para
// Input.dispatchMouseEvent. Al expirar, el error indica QUE estado fallo en el
// ultimo intento (not visible / not stable / disabled / outside viewport /
// intercepted by other element).
//
// needEnabled controla si se exige el estado enabled (no `disabled`,
// `aria-disabled="true"`, ni dentro de un <fieldset disabled>). Pasar false para
// elementos no interactivos (texto, contenedores) donde enabled no aplica.
func CdpWaitActionable(c *CDPConn, backendNodeID int, needEnabled bool, timeout time.Duration) (x float64, y float64, err error) {
if c == nil {
return 0, 0, fmt.Errorf("cdp wait actionable: conexión nil")
}
if timeout <= 0 {
timeout = 5 * time.Second
}
// Resolver el backendNodeID a un objectId una sola vez. El objectId apunta al
// nodo DOM vivo y se reutiliza en cada iteracion via Runtime.callFunctionOn,
// evitando un resolveNode por reintento.
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
if err != nil {
return 0, 0, fmt.Errorf("cdp wait actionable: resolveNode ref %d: %w", backendNodeID, err)
}
obj, _ := res["object"].(map[string]any)
objID, _ := obj["objectId"].(string)
if objID == "" {
return 0, 0, fmt.Errorf("cdp wait actionable: sin objectId para ref %d (nodo inexistente)", backendNodeID)
}
deadline := time.Now().Add(timeout)
var last actionableResult
last.State = "visible" // estado por defecto si nunca llegamos a evaluar
for retry := 0; ; retry++ {
// Espera con backoff antes de reintentar (el primer intento es inmediato).
if retry > 0 {
wait := actionableBackoff[len(actionableBackoff)-1]
if retry-1 < len(actionableBackoff) {
wait = actionableBackoff[retry-1]
}
if wait > 0 {
// No dormir mas alla del deadline.
if remaining := time.Until(deadline); remaining < wait {
wait = remaining
}
if wait > 0 {
time.Sleep(wait)
}
}
}
align := actionableScrollAligns[retry%len(actionableScrollAligns)]
r, evalErr := evalActionable(c, objID, needEnabled, align)
if evalErr != nil {
// Un error de protocolo (tab cerrada, nodo liberado) es terminal: no
// tiene sentido reintentar sobre un objectId muerto.
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d: %w", backendNodeID, evalErr)
}
last = r
if r.OK {
return r.X, r.Y, nil
}
if r.State == "notconnected" {
// El nodo dejo de estar conectado al DOM — reintentar no lo revivira.
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d desconectado del DOM", backendNodeID)
}
if time.Now().After(deadline) {
break
}
}
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d no accionable tras %s: %s", backendNodeID, timeout, describeActionableFailure(last))
}
// describeActionableFailure traduce el estado fallido a un mensaje humano.
func describeActionableFailure(r actionableResult) string {
switch r.State {
case "visible":
return "not visible (display:none, visibility:hidden, opacity:0 o tamaño 0)"
case "stable":
return "not stable (el rect sigue cambiando entre frames; animación o layout en curso)"
case "enabled":
return "disabled (atributo disabled, aria-disabled=true o <fieldset disabled>)"
case "inviewport":
return "outside of the viewport (scrollIntoView no logró revelarlo)"
case "intercepted":
if r.Detail != "" {
return "intercepted by other element: " + r.Detail
}
return "intercepted by other element (overlay capta el pointer en el punto central)"
case "notconnected":
return "not connected to the DOM"
default:
if r.State != "" {
return "not " + r.State
}
return "estado desconocido"
}
}
// evalActionable corre una iteracion completa de chequeos en el contexto JS de la
// pagina, sobre el nodo apuntado por objID. Devuelve el veredicto serializado.
//
// El JS hace, en orden y cortocircuitando al primer fallo:
// 1. visible: tiene client rects y computed style no lo oculta.
// 2. stable: getBoundingClientRect identico en dos requestAnimationFrame seguidos.
// 3. enabled (si needEnabled): no disabled / aria-disabled=true / dentro de
// <fieldset disabled> (subiendo por la jerarquia, como getAriaDisabled).
// 4. scrollIntoView con la alineacion dada + comprobacion de que el centro cae
// dentro del viewport.
// 5. hit-test: elementFromPoint en el punto central, subiendo por shadow roots
// (assignedSlot / parentNode.host) y comprobando que el elemento golpeado es
// el target o uno de sus descendientes.
func evalActionable(c *CDPConn, objID string, needEnabled bool, scrollAlign string) (actionableResult, error) {
params := map[string]any{
"objectId": objID,
"functionDeclaration": actionableJS,
"arguments": []any{
map[string]any{"value": needEnabled},
map[string]any{"value": scrollAlign},
},
"awaitPromise": true,
"returnByValue": true,
}
result, err := c.sendCDP("Runtime.callFunctionOn", params)
if err != nil {
return actionableResult{}, err
}
if exc, ok := result["exceptionDetails"]; ok && exc != nil {
excMap, _ := exc.(map[string]any)
text, _ := excMap["text"].(string)
return actionableResult{}, fmt.Errorf("excepción JS en chequeo de actionability: %s", text)
}
resVal, ok := result["result"].(map[string]any)
if !ok {
return actionableResult{}, fmt.Errorf("resultado inesperado: %v", result)
}
raw, ok := resVal["value"]
if !ok {
return actionableResult{}, fmt.Errorf("chequeo de actionability sin valor de retorno")
}
// returnByValue=true entrega el objeto JS ya deserializado a map[string]any;
// lo re-marshalamos para decodificar en el struct tipado de forma robusta.
b, err := json.Marshal(raw)
if err != nil {
return actionableResult{}, fmt.Errorf("marshal resultado: %w", err)
}
var out actionableResult
if err := json.Unmarshal(b, &out); err != nil {
return actionableResult{}, fmt.Errorf("unmarshal resultado %q: %w", string(b), err)
}
return out, nil
}
// actionableJS es la funcion ejecutada sobre el nodo (this) via callFunctionOn.
// Devuelve una Promise<actionableResult>. La logica replica checkElementStates +
// _checkElementIsStable + expectHitTarget del injected script de Playwright,
// adaptada a un solo paso autocontenido (sin caches ni dependencias externas).
const actionableJS = `function(needEnabled, scrollAlign) {
var target = this;
var fail = function(state, detail) { return {ok:false, state:state, detail:detail||"", x:0, y:0, pageX:0, pageY:0}; };
if (!target || !target.isConnected) return Promise.resolve(fail("notconnected"));
if (target.nodeType !== 1) {
// Si el nodo no es un Element (ej. texto), intentar su elemento padre.
target = target.parentElement;
if (!target) return Promise.resolve(fail("notconnected"));
}
// 1) VISIBLE: rect con area + computed style no oculto.
var isVisible = function(el) {
if (!el || !el.isConnected) return false;
var rects = el.getClientRects();
if (!rects || rects.length === 0) return false;
var st = (el.ownerDocument && el.ownerDocument.defaultView)
? el.ownerDocument.defaultView.getComputedStyle(el) : null;
if (st) {
if (st.visibility === "hidden" || st.display === "none") return false;
if (parseFloat(st.opacity || "1") === 0) return false;
}
var r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
if (!isVisible(target)) return Promise.resolve(fail("visible"));
// 2) ENABLED (opcional): disabled nativo, aria-disabled o <fieldset disabled>.
if (needEnabled) {
var isDisabled = function(el) {
var native = ["BUTTON","INPUT","SELECT","TEXTAREA","OPTION","OPTGROUP"];
var n = el;
while (n) {
if (n.nodeType === 1) {
var tag = (n.tagName || "").toUpperCase();
if (native.indexOf(tag) !== -1 && n.hasAttribute && n.hasAttribute("disabled")) return true;
// fieldset disabled deshabilita a sus controles (salvo dentro del legend).
if (tag === "FIELDSET" && n.hasAttribute && n.hasAttribute("disabled")) return true;
var ad = n.getAttribute && n.getAttribute("aria-disabled");
if (ad && ad.toLowerCase() === "true") return true;
}
// Subir por DOM y cruzar shadow boundaries.
n = n.parentElement || (n.parentNode && n.parentNode.host) || (n.assignedSlot || null);
}
return false;
};
if (isDisabled(target)) return Promise.resolve(fail("enabled"));
}
// 4) SCROLL INTO VIEW con la alineacion rotada por el caller.
try { target.scrollIntoView({block: scrollAlign, inline: scrollAlign, behavior: "instant"}); }
catch (e) { try { target.scrollIntoView(); } catch (e2) {} }
// 3) STABLE: comparar getBoundingClientRect en dos requestAnimationFrame seguidos.
var rectOf = function(el) {
var r = el.getBoundingClientRect();
return {x: r.left, y: r.top, w: r.width, h: r.height};
};
var rafTwice = function() {
return new Promise(function(res) {
requestAnimationFrame(function() { requestAnimationFrame(function() { res(); }); });
});
};
var first = rectOf(target);
return rafTwice().then(function() {
if (!target.isConnected) return fail("notconnected");
var second = rectOf(target);
var same = first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
if (!same) return fail("stable");
var r = second;
var vw = window.innerWidth || document.documentElement.clientWidth;
var vh = window.innerHeight || document.documentElement.clientHeight;
var cx = r.x + r.w / 2;
var cy = r.y + r.h / 2;
// 4b) IN VIEWPORT: el punto central debe caer dentro del viewport tras el scroll.
if (cx < 0 || cy < 0 || cx > vw || cy > vh) return fail("inviewport");
// 5) HIT-TEST: elementFromPoint subiendo por shadow roots; el golpeado debe ser
// el target o un descendiente suyo (cruzando shadow boundaries).
var enclosingRoot = function(el) {
var node = el;
while (node && node.parentNode) node = node.parentNode;
if (node && (node.nodeType === 11 || node.nodeType === 9)) return node;
return null;
};
var parentOrHost = function(el) {
if (el.parentElement) return el.parentElement;
if (el.parentNode && el.parentNode.nodeType === 11 && el.parentNode.host) return el.parentNode.host;
return null;
};
// Recolectar roots desde el target hacia arriba (document u shadow roots).
var roots = [];
var p = target;
while (p) {
var root = enclosingRoot(p);
if (!root) break;
roots.push(root);
if (root.nodeType === 9) break;
p = root.host;
}
// Hit en cada root debe apuntar al siguiente root; en el ultimo, al target/descendiente.
var hit = null;
for (var i = roots.length - 1; i >= 0; i--) {
var rt = roots[i];
var inner = rt.elementFromPoint ? rt.elementFromPoint(cx, cy) : null;
if (!inner) break;
hit = inner;
if (i && roots[i - 1] && inner !== roots[i - 1].host) break;
}
if (!hit) return fail("intercepted", "ningún elemento en el punto central");
// Subir desde el hit hasta el target (composed tree: assignedSlot primero).
var cur = hit;
while (cur && cur !== target) {
cur = cur.assignedSlot || parentOrHost(cur);
}
if (cur !== target) {
var desc = hit.tagName ? hit.tagName.toLowerCase() : "node";
if (hit.id) desc += "#" + hit.id;
else if (hit.className && typeof hit.className === "string" && hit.className.trim())
desc += "." + hit.className.trim().split(/\s+/)[0];
return fail("intercepted", desc);
}
var sx = window.scrollX || window.pageXOffset || 0;
var sy = window.scrollY || window.pageYOffset || 0;
return {ok:true, state:"ok", detail:"", x:cx, y:cy, pageX:cx + sx, pageY:cy + sy};
});
}`
+85
View File
@@ -0,0 +1,85 @@
---
name: cdp_wait_actionable
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpWaitActionable(c *CDPConn, backendNodeID int, needEnabled bool, timeout time.Duration) (x float64, y float64, err error)"
description: "Bloquea hasta que el elemento del #ref sea accionable (listo para un click/hover fiable) o expire timeout. Reproduce el modelo de actionability de Playwright: en bucle con backoff [0,20,100,100,500]ms comprueba visible (client rects + computed style), stable (mismo getBoundingClientRect en dos requestAnimationFrame seguidos), enabled opcional (disabled / aria-disabled / fieldset disabled subiendo la jerarquía), scroll into view rotando alineación block (center/start/end), y hit-test (elementFromPoint subiendo por shadow DOM apunta al target o descendiente). Devuelve el punto central (x,y) en coords de viewport listo para Input.dispatchMouseEvent. Al expirar, el error indica qué estado falló (not visible / not stable / disabled / outside viewport / intercepted by other element)."
tags: [cdp, browser, action, ref, actionability, browser-actionability, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
params:
- name: c
desc: "Conexión CDP activa al tab objetivo."
- name: backendNodeID
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
- name: needEnabled
desc: "Si true, exige también el estado enabled (no disabled, no aria-disabled=true, no dentro de <fieldset disabled>). Pasar false para elementos no interactivos (texto, contenedores) donde enabled no aplica."
- name: timeout
desc: "Tiempo máximo de espera antes de rendirse. <=0 usa 5s por defecto. El bucle de reintento nunca duerme más allá de este deadline."
output: "(x, y) punto central del elemento en coordenadas de viewport (CSS px), listo para despachar el pointer, cuando todos los chequeos pasan; error si la conexión es nil, el nodo no resuelve a objectId, se desconecta del DOM, o expira el timeout (con el estado que falló al final)."
file_path: "functions/browser/cdp_wait_actionable.go"
---
## Ejemplo
```go
// Tras un page_perceive que devuelve outline con #ref=1234, esperar a que el
// elemento sea accionable y luego clicar el punto exacto que devuelve:
conn, _ := CdpConnect(9222)
x, y, err := CdpWaitActionable(conn, 1234, true, 5*time.Second)
if err != nil {
log.Fatalf("no accionable: %v", err) // ej: "intercepted by other element: div#cookie-banner"
}
// x,y ya están en viewport, estables y sin overlay encima: click fiable.
_ = CdpClickXYHuman(conn, x, y, MouseHumanOpts{})
```
## Cuando usarla
Antes de CUALQUIER click/hover/type que deba ser fiable sobre un #ref del outline.
Llamarla justo después de `page_perceive` y antes de `cdp_click_ref` /
`cdp_click_xy_human` / `dom_*_ref` para evitar los fallos clásicos del navegador:
clicar un botón que aún se está animando hacia su posición, un elemento tapado por
un banner de cookies / modal / spinner, o un control todavía `disabled`. Es la
puerta de actionability que separa "el nodo existe en el DOM" de "el nodo está
listo para recibir el evento ahí donde lo voy a despachar". Usar `needEnabled=true`
para botones/inputs/enlaces; `needEnabled=false` para hover sobre texto o medir un
contenedor.
## Gotchas
- **Coste de polling.** Es síncrona y bloqueante: hace un `Runtime.callFunctionOn`
por iteración + 2 `requestAnimationFrame` por chequeo de estabilidad. En el peor
caso poll-ea hasta `timeout` con backoff creciente (0,20,100,100,500ms → 500ms).
No la metas en un bucle apretado sobre N elementos sin necesidad; una sola
llamada por acción es lo correcto. Timeouts altos sobre elementos que nunca
llegan (genuinamente ocultos) cuestan el timeout entero.
- **Shadow DOM.** El hit-test sube por shadow roots (`assignedSlot` /
`parentNode.host`) y por eso funciona con web components con shadow root
*abierto*. Con shadow roots **cerrados** `elementFromPoint` no expone el interior
y el hit-test puede reportar `intercepted` erróneamente; en ese caso usar el
click vía `element.click()` (modo instant de `cdp_click_ref`), que no depende del
hit-test geométrico.
- **iframes.** Opera sobre el contexto de la página/frame al que apunta el
`*CDPConn`. Un `backendNodeID` de otro frame no resuelve aquí: hay que tener la
conexión/contexto del frame correcto (ver `cdp_eval_in_frame`). Las coordenadas
devueltas son relativas al viewport de ESE documento, no compuestas con el offset
del iframe en la página padre.
- **Estabilidad vs animaciones infinitas.** Un elemento con una animación CSS
perpetua que mueve su rect (spinner que se desplaza, marquee) nunca pasará el
chequeo `stable` y agotará el timeout con "not stable". Es comportamiento
correcto (no es accionable de forma fiable), pero conviene saberlo.
- **El punto devuelto es (x,y) de viewport**, no de página. Es lo que
`Input.dispatchMouseEvent` espera. Si necesitas coords de página (con scroll),
el JS interno ya las calcula (`pageX/pageY`) pero la firma pública expone solo
las de viewport para encajar con el dispatch de pointer.