diff --git a/functions/browser/cdp_fill.go b/functions/browser/cdp_fill.go
new file mode 100644
index 00000000..b8825a96
--- /dev/null
+++ b/functions/browser/cdp_fill.go
@@ -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)
+}
diff --git a/functions/browser/cdp_fill.md b/functions/browser/cdp_fill.md
new file mode 100644
index 00000000..4a17e742
--- /dev/null
+++ b/functions/browser/cdp_fill.md
@@ -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 React con #ref=4521:
+conn, _ := CdpConnect(9222)
+
+// Por #ref del AX outline (camino habitual del bucle percibir→actuar):
+if err := CdpFill(conn, 4521, "ada@example.com"); err != nil {
+ log.Fatal(err)
+}
+
+// Por selector CSS estable (resuelve a backendNodeID y delega en CdpFill):
+if err := CdpFillSelector(conn, "input[name='email']", "ada@example.com"); err != nil {
+ log.Fatal(err)
+}
+
+// Vaciar un campo:
+_ = CdpFillSelector(conn, "#search", "")
+
+// Input especial (date): ruta setvalue + eventos input/change:
+_ = CdpFillSelector(conn, "input[type='date']", "2026-06-16")
+```
+
+## Cuando usarla
+
+Cuando necesites rellenar inputs de formularios controlados por React/Vue/otros frameworks de forma fiable. Es el reemplazo del patrón `DOM.focus` + `CdpTypeText`/`CdpInsertText` que **concatena** al valor existente y a menudo deja el estado del framework desincronizado (el `value` del DOM cambia pero el estado de React no, o al revés). `CdpFill` selecciona y reemplaza el contenido entero y, al usar `Input.insertText` (no el native value setter), emite los eventos `input`/`beforeinput` confiables que hacen que el framework reconcilie su estado. Úsala para login, registro, búsquedas y cualquier campo donde el patrón focus+type falle o duplique texto. Para teclear carácter a carácter simulando un humano (sitios con detección por pulsación o autocompletes estrictos) sigue prefiriendo `CdpTypeRef` (camino human).
+
+## Gotchas
+
+- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir (`page_perceive`) antes de actuar.
+- **contenteditable**: la ruta needsinput inserta el valor seleccionando todo el contenido, pero la verificación final **no es fiable** para contenteditable (el motor normaliza el HTML). Por eso para contenteditable `CdpFill` no falla por verificación; confía en que `Input.insertText` cuajó. Si necesitas garantía dura del contenido, léelo aparte con `CdpEvaluate`.
+- **Inputs especiales** (color/date/time/datetime-local/month/range/week) van por la ruta setvalue: fijan `el.value` y disparan `input`{bubbles,composed}+`change`{bubbles}. Algunos frameworks que escuchan eventos de teclado en estos inputs pueden no reaccionar — es el mismo trade-off que hace Playwright.
+- **input[type=number]**: el valor debe ser numérico (`isNaN` lo rechaza con error claro). Espacios se recortan.
+- **Frameworks y el evento nativo**: la clave de la robustez es NO usar el "native value setter" (`Object.getOwnPropertyDescriptor(...).set`). React parchea el setter de `value` y se confunde si lo invocas a mano; `Input.insertText` del motor emite los eventos que React intercepta correctamente. Si una versión muy vieja de un framework custom no reacciona, cae a `CdpTypeRef` (char por char).
+- **No hace scroll humanizado**: `DOM.focus` hace scroll-into-view del nodo, pero si el input está dentro de un contenedor con scroll propio y oculto, valida visible y puede fallar con "elemento no visible". En ese caso haz `CdpClickRef` (que hace `scrollIntoViewIfNeeded`) antes.
+- **value==""** borra el campo enviando `Delete` sobre la selección previa (no `Input.insertText` con cadena vacía, que sería no-op). Esto dispara los eventos de borrado que el framework espera.
diff --git a/functions/browser/cdp_find_by_role.go b/functions/browser/cdp_find_by_role.go
new file mode 100644
index 00000000..1031a6ef
--- /dev/null
+++ b/functions/browser/cdp_find_by_role.go
@@ -0,0 +1,191 @@
+package browser
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// CdpFindByRoleOpts configura el matching del accessible name de CdpFindByRole.
+// Si Name == "", solo se filtra por role (cualquier name vale).
+type CdpFindByRoleOpts struct {
+ // Name es el accessible name a matchear. Vacio = no filtra por name.
+ Name string
+ // Exact: true = el name normalizado debe ser igual al buscado.
+ // false (default) = el name normalizado contiene el buscado (substring).
+ Exact bool
+ // Regex: true = Name se interpreta como expresion regular (RE2 de Go).
+ // Tiene prioridad sobre Exact si ambos estan a true.
+ Regex bool
+ // CaseSensitive: false (default) = comparacion insensible a mayusculas.
+ // Para Regex, false añade el flag (?i) a la expresion.
+ CaseSensitive bool
+}
+
+// normalizeWhiteSpace replica la regla de Playwright (utils/isomorphic/stringUtils.ts):
+// elimina el zero-width space (U+200B) y el soft hyphen (U+00AD), recorta extremos y
+// colapsa cualquier run de whitespace a un unico espacio. Es la normalizacion que
+// Playwright aplica a ambos lados al comparar el accessible name (getByRole({name})),
+// para que diferencias de whitespace/caracteres invisibles no rompan el match.
+func normalizeWhiteSpace(s string) string {
+ // Strip zero-width space y soft hyphen.
+ s = strings.ReplaceAll(s, "", "")
+ s = strings.ReplaceAll(s, "", "")
+ // Colapsar runs de whitespace a un espacio.
+ s = whitespaceRun.ReplaceAllString(s, " ")
+ // Trim de extremos.
+ return strings.TrimSpace(s)
+}
+
+// whitespaceRun matchea uno o mas caracteres de espacio en blanco. Equivale a
+// `\s+` de la regex de normalizeWhiteSpace de Playwright.
+var whitespaceRun = regexp.MustCompile(`\s+`)
+
+// CdpFindByRole localiza el primer elemento por su ROLE ARIA y, opcionalmente, su
+// accessible name — el equivalente a getByRole de Playwright. Reutiliza el AX tree
+// que ya pedimos para page_perceive (Accessibility.getFullAXTree) en vez de tocar el
+// DOM/CSS, lo que la hace robusta a cambios de markup/estilos.
+//
+// Recorre los nodos del AX tree y matchea:
+// - role: igualdad exacta del rol ARIA (ej "button", "link", "textbox").
+// - name (si opts.Name != ""): el accessible name del nodo contra opts.Name, con
+// normalizeWhiteSpace aplicado a ambos lados (regla Playwright). Por defecto es
+// substring; Exact => igualdad; Regex => expresion regular. Insensible a
+// mayusculas salvo CaseSensitive.
+//
+// Retorna (ref, count, error):
+// - ref: backendDOMNodeId del primer match — el mismo #ref que produce el outline
+// de page_perceive y que consume CdpClickRef/CdpHoverRef.
+// - count: numero total de nodos que matchean. count > 1 indica ambiguedad: el
+// caller decide si refinar (Name mas especifico, Exact, etc.).
+// - error: conexion nula, role vacio, regex invalida, fallo CDP, o 0 matches.
+func CdpFindByRole(c *CDPConn, role string, opts CdpFindByRoleOpts) (ref int, count int, err error) {
+ if c == nil {
+ return 0, 0, fmt.Errorf("cdp find by role: conexion nula")
+ }
+ if role == "" {
+ return 0, 0, fmt.Errorf("cdp find by role: role vacio")
+ }
+
+ // Construir el matcher del name una sola vez (compila la regex si aplica).
+ matchName, err := buildNameMatcher(opts)
+ if err != nil {
+ return 0, 0, fmt.Errorf("cdp find by role: %w", err)
+ }
+
+ // Accessibility.enable (idempotente, cacheado) antes de getFullAXTree.
+ if err := c.ensureAX(); err != nil {
+ return 0, 0, fmt.Errorf("cdp find by role: Accessibility.enable: %w", err)
+ }
+
+ res, err := c.sendCDP("Accessibility.getFullAXTree", nil)
+ if err != nil {
+ return 0, 0, fmt.Errorf("cdp find by role: Accessibility.getFullAXTree: %w", err)
+ }
+
+ nodes := axoParseNodes(res)
+
+ firstRef := 0
+ haveFirst := false
+ for _, n := range nodes {
+ if n.ignored {
+ continue
+ }
+ if n.role != role {
+ continue
+ }
+ if opts.Name != "" && !matchName(n.name) {
+ continue
+ }
+ count++
+ if !haveFirst {
+ // axoRefID prefiere backendDOMNodeID; ese es el ref que consume CdpClickRef.
+ if id, ok := atoiRef(axoRefID(n)); ok {
+ firstRef = id
+ haveFirst = true
+ }
+ }
+ }
+
+ if count == 0 {
+ if opts.Name != "" {
+ return 0, 0, fmt.Errorf("cdp find by role: no element with role %q and name %q", role, opts.Name)
+ }
+ return 0, 0, fmt.Errorf("cdp find by role: no element with role %q", role)
+ }
+ if !haveFirst {
+ // Hubo matches pero ninguno tenia un ref entero usable (backendDOMNodeId
+ // ausente y nodeId no numerico): no podemos devolver un #ref valido.
+ return 0, count, fmt.Errorf("cdp find by role: %d match(es) para role %q pero sin backendDOMNodeId usable", count, role)
+ }
+ return firstRef, count, nil
+}
+
+// buildNameMatcher devuelve la funcion que decide si un accessible name candidato
+// matchea opts.Name, normalizando ambos lados con normalizeWhiteSpace. Si Name == ""
+// el matcher siempre es true (no se filtra por name). Compila la regex una vez.
+func buildNameMatcher(opts CdpFindByRoleOpts) (func(candidate string) bool, error) {
+ if opts.Name == "" {
+ return func(string) bool { return true }, nil
+ }
+
+ want := normalizeWhiteSpace(opts.Name)
+
+ if opts.Regex {
+ pat := opts.Name
+ if !opts.CaseSensitive {
+ pat = "(?i)" + pat
+ }
+ re, err := regexp.Compile(pat)
+ if err != nil {
+ return nil, fmt.Errorf("regex invalida %q: %w", opts.Name, err)
+ }
+ return func(candidate string) bool {
+ return re.MatchString(normalizeWhiteSpace(candidate))
+ }, nil
+ }
+
+ if !opts.CaseSensitive {
+ want = strings.ToLower(want)
+ }
+
+ return func(candidate string) bool {
+ got := normalizeWhiteSpace(candidate)
+ if !opts.CaseSensitive {
+ got = strings.ToLower(got)
+ }
+ if opts.Exact {
+ return got == want
+ }
+ return strings.Contains(got, want)
+ }, nil
+}
+
+// atoiRef convierte el ref string (backendDOMNodeId, ya normalizado a entero-string
+// por axoStr) a int. Devuelve (0, false) si no es un entero parseable.
+func atoiRef(s string) (int, bool) {
+ if s == "" {
+ return 0, false
+ }
+ neg := false
+ i := 0
+ if s[0] == '-' {
+ neg = true
+ i = 1
+ if len(s) == 1 {
+ return 0, false
+ }
+ }
+ n := 0
+ for ; i < len(s); i++ {
+ ch := s[i]
+ if ch < '0' || ch > '9' {
+ return 0, false
+ }
+ n = n*10 + int(ch-'0')
+ }
+ if neg {
+ n = -n
+ }
+ return n, true
+}
diff --git a/functions/browser/cdp_find_by_role.md b/functions/browser/cdp_find_by_role.md
new file mode 100644
index 00000000..afcff1f3
--- /dev/null
+++ b/functions/browser/cdp_find_by_role.md
@@ -0,0 +1,82 @@
+---
+name: cdp_find_by_role
+kind: function
+lang: go
+domain: browser
+version: "1.0.0"
+purity: impure
+signature: "func CdpFindByRole(c *CDPConn, role string, opts CdpFindByRoleOpts) (ref int, count int, err error)"
+description: "Localiza el primer elemento por su ROLE ARIA + accessible name (estilo getByRole de Playwright) reusando el AX tree (Accessibility.getFullAXTree). Devuelve el backendDOMNodeId (#ref) del primer match y el total de matches para detectar ambiguedad."
+tags: [browser]
+params:
+ - name: c
+ desc: "Conexion CDP viva (*CDPConn) del pool. nil => error."
+ - name: role
+ desc: "Rol ARIA exacto a matchear (ej 'button', 'link', 'textbox', 'checkbox')."
+ - name: opts
+ desc: "CdpFindByRoleOpts: Name (accessible name, vacio = no filtra), Exact (igualdad en vez de substring), Regex (Name como expresion regular RE2), CaseSensitive (default false)."
+output: "(ref int, count int, err error): ref = backendDOMNodeId del primer match (#ref para CdpClickRef/CdpHoverRef); count = total de matches (>1 = ambiguo); err si conexion nula, role vacio, regex invalida, fallo CDP o 0 matches."
+uses_functions: []
+uses_types: []
+returns: []
+returns_optional: false
+error_type: "error_go_core"
+imports: []
+tested: false
+tests: []
+test_file_path: ""
+file_path: "functions/browser/cdp_find_by_role.go"
+---
+
+## Ejemplo
+
+```go
+c, _ := browser.CdpConnect(9333) // conexion CDP del pool
+ref, count, err := browser.CdpFindByRole(c, "button", browser.CdpFindByRoleOpts{
+ Name: "Aceptar", // substring del accessible name, case-insensitive
+})
+if err != nil {
+ log.Fatal(err) // ej: no element with role "button" and name "Aceptar"
+}
+if count > 1 {
+ log.Printf("aviso: %d botones matchean 'Aceptar', usando el primero", count)
+}
+// ref es el mismo #ref que produce page_perceive: alimentarlo a CdpClickRef.
+_ = browser.CdpClickRef(c, ref, browser.MouseHumanOpts{})
+
+// Match exacto + case-sensitive:
+ref, _, _ = browser.CdpFindByRole(c, "link", browser.CdpFindByRoleOpts{
+ Name: "Iniciar sesion", Exact: true, CaseSensitive: true,
+})
+
+// Match por regex (ej "Eliminar 3 elementos" / "Eliminar 12 elementos"):
+ref, _, _ = browser.CdpFindByRole(c, "button", browser.CdpFindByRoleOpts{
+ Name: `^Eliminar \d+ elementos$`, Regex: true,
+})
+```
+
+## Cuando usarla
+
+Cuando necesites localizar un control de forma robusta a cambios de DOM/CSS: el rol
+ARIA + accessible name sobreviven a refactors de markup y clases CSS que romperian un
+selector `nth-of-type`. Es el patron primario que recomienda Playwright (getByRole)
+para encontrar elementos accionables (botones, links, inputs). Combina el `ref`
+devuelto directamente con `cdp_click_ref` / `cdp_hover_ref` para actuar sin pasar por
+un selector fragil. Revisa `count` antes de actuar: si es >1 la busqueda es ambigua
+y conviene refinar (Name mas especifico, Exact, o Regex anclada).
+
+## Gotchas
+
+- El `name` que se matchea es el **accessible name computado** por el motor de
+ accesibilidad de Chrome (deriva de aria-label, label asociado, contenido, alt,
+ title segun la spec ARIA), **no** el `innerText` del elemento. Si buscas por el
+ texto visible literal, usa `cdp_find_ref_by_text` en su lugar.
+- `count > 1` => ambiguedad: se devuelve el primer match en orden del AX tree, que no
+ siempre es el visualmente primero ni el que quieres. Refina la busqueda.
+- El `role` se compara por **igualdad exacta** del rol ARIA: "button" no matchea
+ "menuitem" aunque ambos sean clicables. Mira el outline de `page_perceive` /
+ `cdp_get_ax_outline` para ver el rol real que Chrome asigna a cada nodo.
+- Nodos `ignored` del AX tree se descartan. Si el elemento esta oculto (aria-hidden,
+ display:none) puede no aparecer y dar 0 matches.
+- El `ref` es un `backendDOMNodeId`: estable mientras el nodo viva, pero si el DOM
+ muta entre el find y el click el ref puede quedar obsoleto.
diff --git a/functions/browser/cdp_select_dropdown.go b/functions/browser/cdp_select_dropdown.go
new file mode 100644
index 00000000..77b1098c
--- /dev/null
+++ b/functions/browser/cdp_select_dropdown.go
@@ -0,0 +1,275 @@
+package browser
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// CdpDropdownOpts configura la seleccion en un desplegable custom (no nativo).
+type CdpDropdownOpts struct {
+ // Exact: true = el texto de la opcion debe ser igual (tras normalizar) a
+ // optionText. false (default) = match por substring. La comparacion siempre
+ // es case-insensitive y sobre el texto normalizado (trim + colapsar espacios).
+ Exact bool
+ // TimeoutMs es el tope de espera (ms) para que el listbox monte/anime y la
+ // opcion aparezca visible. <=0 usa el default 3000.
+ TimeoutMs int
+ // OptionRole es el rol ARIA de las opciones a buscar ("option" por defecto).
+ // Usar "menuitem" para menus tipo dropdown-menu, "treeitem" para arboles, etc.
+ OptionRole string
+}
+
+// CdpSelectDropdown selecciona una opcion en un DESPLEGABLE CUSTOM (combobox/listbox
+// ARIA, react-select, MUI Select, headlessui, select2, ...) — esos en los que un
+//