4187f9b6b1
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>
299 lines
12 KiB
Go
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)
|
|
}
|