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:
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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};
|
||||
});
|
||||
}`
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user