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 +// nativo NO aplica. Replica el patron de Playwright (que no tiene API para custom dropdowns): click REAL en el trigger (mousedown, no element.click JS), espera la apertura por polling (aria-expanded=true O [role=listbox]/[role=menu] visible O opciones con rect>0), localiza la opcion por texto normalizado (substring o exacto, case-insensitive) y hace click REAL en su centro, con verificacion suave (aria-expanded vuelve a false o Enter como fallback). Reusa CdpEvaluate, CdpClickXYHuman y CdpPressKey." +tags: [browser, chrome, cdp, automation, dropdown, combobox, listbox, aria, select, react-select, mui, headlessui, devtools] +uses_functions: [cdp_evaluate_go_browser, cdp_click_xy_human_go_browser, cdp_press_key_go_browser] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [fmt, strings, time] +params: + - name: c + desc: "conexion CDP activa (*CDPConn)" + - name: triggerSelector + desc: "selector CSS del elemento que abre el desplegable (el boton/combobox sobre el que se hace click real)" + - name: optionText + desc: "texto visible de la opcion a elegir; se normaliza (trim + colapsar espacios) y se compara case-insensitive, por substring si opts.Exact=false o por igualdad si opts.Exact=true" + - name: opts + desc: "CdpDropdownOpts{Exact bool (igualdad vs substring, default substring); TimeoutMs int (espera apertura+opcion, default 3000); OptionRole string (rol ARIA de las opciones, default 'option' — usar 'menuitem' para menus, 'treeitem' para arboles)}" +output: "error si el trigger no existe, si el dropdown no abre dentro del timeout (\"el dropdown no abrio\"), o si la opcion no aparece (\"option %q not found in dropdown\"); nil si el click sobre la opcion se realizo (la verificacion de cierre es suave y no falla duro si queda ambigua)" +tested: false +tests: [] +test_file_path: "" +file_path: "functions/browser/cdp_select_dropdown.go" +--- + +## Ejemplo + +```go +conn, _ := CdpConnect(9222) +CdpNavigate(conn, "https://mui.com/material-ui/react-select/") + +// Combobox MUI: el trigger es el div con role=combobox; el listbox monta y +// anima al abrir. CdpSelectDropdown clica el trigger, espera a que el listbox +// este visible y entonces clica la opcion "Twenty". +err := CdpSelectDropdown(conn, "[role=combobox]", "Twenty", CdpDropdownOpts{}) +if err != nil { + log.Fatal(err) +} + +// react-select / headlessui: trigger por clase + match exacto + timeout amplio +// para listas que tardan en montar. +err = CdpSelectDropdown(conn, ".select__control", "España", CdpDropdownOpts{ + Exact: true, + TimeoutMs: 6000, +}) + +// Menu tipo dropdown-menu (no listbox): las opciones son role=menuitem. +err = CdpSelectDropdown(conn, "#user-menu-btn", "Cerrar sesion", CdpDropdownOpts{ + OptionRole: "menuitem", +}) +``` + +## Cuando usarla + +Usala cuando el desplegable NO es un `` nativo de HTML usa `CdpSelectOption` (setea +`select.value` + dispara `input`/`change`), que es mas robusto y directo para ese +caso. + +## Gotchas + +- **Click real, no element.click()**: muchos dropdowns custom escuchan `mousedown` + (no `click`), por eso esta funcion despacha eventos de raton reales sobre el + centro del bbox. Solo cae a `element.click()` JS si el nodo no tiene geometria. +- **Animaciones de apertura**: el fallo nº1 reportado en Playwright es clicar la + opcion ANTES de que el listbox monte/anime. Por eso hay polling de apertura + (`dropdownWaitOpen`) que no avanza hasta que hay opciones visibles. Si tu + dropdown anima muy lento, sube `TimeoutMs`. +- **Listas virtualizadas** (react-window, virtuoso): solo renderizan las opciones + en viewport. Si la opcion buscada esta fuera del scroll inicial, puede que nunca + se monte y la funcion devuelva "not found" aunque exista. Mitigacion: escribe en + el combobox para filtrar (`CdpTypeText`) antes de llamar a esta funcion, o haz + scroll dentro del listbox primero. +- **Trigger vs contenedor**: `triggerSelector` debe apuntar al elemento que ABRE el + menu (el boton/combobox), no al `[role=listbox]` (que no existe hasta abrir). +- **Match de texto**: normaliza espacios y es case-insensitive; por defecto es + substring (`Exact=false`). Si varias opciones comparten substring, elige la + primera visible en orden de documento — usa `Exact=true` para desambiguar. +- **OptionRole**: por defecto `option` (`[role=option]`). Para menus de acciones usa + `menuitem`; para arboles `treeitem`. La deteccion de apertura tambien considera + `[role=menu]` y `li[role]` para cubrir patrones comunes. +- **Verificacion suave**: tras clicar, si el dropdown sigue abierto la funcion pulsa + `Enter` como fallback y devuelve `nil`. No falla duro si la seleccion no se puede + confirmar inequivocamente pero el click se hizo — comprueba el estado resultante + (texto del trigger, valor del formulario) si necesitas certeza. +- **iframes**: opera en el documento principal (via `CdpEvaluate`). Para un dropdown + dentro de un iframe necesitarias el contexto del frame (no cubierto aqui). diff --git a/functions/browser/cdp_select_option.go b/functions/browser/cdp_select_option.go index 42d76450..ebb402eb 100644 --- a/functions/browser/cdp_select_option.go +++ b/functions/browser/cdp_select_option.go @@ -5,41 +5,105 @@ import ( "strings" ) -// CdpSelectOption selecciona la