Files
fn_registry/functions/browser/cdp_fill.go
T
Egutierrez 4187f9b6b1 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>
2026-06-16 20:49:37 +02:00

299 lines
12 KiB
Go

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)
}