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