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>
344 lines
14 KiB
Go
344 lines
14 KiB
Go
package browser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// actionableBackoff es el calendario de espera entre reintentos del bucle de
|
|
// actionability, copiado del _retryAction de Playwright (waitTime [0,20,100,100,500]).
|
|
// Tras agotar la tabla, se mantiene en el ultimo valor (500ms) hasta el timeout.
|
|
// El primer intento es inmediato (0ms): muchas veces el elemento ya esta listo.
|
|
var actionableBackoff = []time.Duration{
|
|
0,
|
|
20 * time.Millisecond,
|
|
100 * time.Millisecond,
|
|
100 * time.Millisecond,
|
|
500 * time.Millisecond,
|
|
}
|
|
|
|
// actionableScrollAligns rota la alineacion block de scrollIntoView entre
|
|
// reintentos. Cyclar las alineaciones (center/start/end) destraba casos donde un
|
|
// header position:sticky o un footer fijo tapa el punto al alinear de una sola
|
|
// forma — replica el scrollOptions cycling de _retryPointerAction de Playwright.
|
|
var actionableScrollAligns = []string{"center", "start", "end"}
|
|
|
|
// actionableResult es el veredicto que el JS inyectado devuelve por iteracion.
|
|
// state describe el primer estado que fallo (para el mensaje de error final);
|
|
// x,y son el punto central listo para el pointer cuando ok==true.
|
|
type actionableResult struct {
|
|
OK bool `json:"ok"`
|
|
State string `json:"state"` // "visible" | "stable" | "enabled" | "inviewport" | "intercepted" | "notconnected"
|
|
Detail string `json:"detail"` // descripcion del interceptor u otro detalle
|
|
X float64 `json:"x"` // punto central viewport (CSS px)
|
|
Y float64 `json:"y"` //
|
|
PageX float64 `json:"pageX"` // punto central en coords de pagina (scroll incluido)
|
|
PageY float64 `json:"pageY"` //
|
|
}
|
|
|
|
// CdpWaitActionable bloquea hasta que el elemento identificado por backendNodeID
|
|
// sea accionable (listo para recibir un click/hover fiable) o expire timeout.
|
|
// Reproduce el modelo de actionability de Playwright: en cada iteracion comprueba
|
|
// que el elemento esta visible, estable (mismo rect en dos requestAnimationFrame
|
|
// consecutivos), opcionalmente enabled, dentro del viewport tras scrollIntoView,
|
|
// y que el hit-test (document.elementFromPoint subiendo por shadow DOM) apunta al
|
|
// propio nodo o a un descendiente. Si algo falla, espera con backoff
|
|
// [0,20,100,100,500]ms (luego 500ms constante) y reintenta, rotando la alineacion
|
|
// del scroll para destrabar overlays sticky.
|
|
//
|
|
// Devuelve el punto central (x,y) en coordenadas de viewport (CSS px), listo para
|
|
// Input.dispatchMouseEvent. Al expirar, el error indica QUE estado fallo en el
|
|
// ultimo intento (not visible / not stable / disabled / outside viewport /
|
|
// intercepted by other element).
|
|
//
|
|
// needEnabled controla si se exige el estado enabled (no `disabled`,
|
|
// `aria-disabled="true"`, ni dentro de un <fieldset disabled>). Pasar false para
|
|
// elementos no interactivos (texto, contenedores) donde enabled no aplica.
|
|
func CdpWaitActionable(c *CDPConn, backendNodeID int, needEnabled bool, timeout time.Duration) (x float64, y float64, err error) {
|
|
if c == nil {
|
|
return 0, 0, fmt.Errorf("cdp wait actionable: conexión nil")
|
|
}
|
|
if timeout <= 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
|
|
// Resolver el backendNodeID a un objectId una sola vez. El objectId apunta al
|
|
// nodo DOM vivo y se reutiliza en cada iteracion via Runtime.callFunctionOn,
|
|
// evitando un resolveNode por reintento.
|
|
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("cdp wait actionable: resolveNode ref %d: %w", backendNodeID, err)
|
|
}
|
|
obj, _ := res["object"].(map[string]any)
|
|
objID, _ := obj["objectId"].(string)
|
|
if objID == "" {
|
|
return 0, 0, fmt.Errorf("cdp wait actionable: sin objectId para ref %d (nodo inexistente)", backendNodeID)
|
|
}
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
var last actionableResult
|
|
last.State = "visible" // estado por defecto si nunca llegamos a evaluar
|
|
|
|
for retry := 0; ; retry++ {
|
|
// Espera con backoff antes de reintentar (el primer intento es inmediato).
|
|
if retry > 0 {
|
|
wait := actionableBackoff[len(actionableBackoff)-1]
|
|
if retry-1 < len(actionableBackoff) {
|
|
wait = actionableBackoff[retry-1]
|
|
}
|
|
if wait > 0 {
|
|
// No dormir mas alla del deadline.
|
|
if remaining := time.Until(deadline); remaining < wait {
|
|
wait = remaining
|
|
}
|
|
if wait > 0 {
|
|
time.Sleep(wait)
|
|
}
|
|
}
|
|
}
|
|
|
|
align := actionableScrollAligns[retry%len(actionableScrollAligns)]
|
|
r, evalErr := evalActionable(c, objID, needEnabled, align)
|
|
if evalErr != nil {
|
|
// Un error de protocolo (tab cerrada, nodo liberado) es terminal: no
|
|
// tiene sentido reintentar sobre un objectId muerto.
|
|
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d: %w", backendNodeID, evalErr)
|
|
}
|
|
last = r
|
|
|
|
if r.OK {
|
|
return r.X, r.Y, nil
|
|
}
|
|
if r.State == "notconnected" {
|
|
// El nodo dejo de estar conectado al DOM — reintentar no lo revivira.
|
|
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d desconectado del DOM", backendNodeID)
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
break
|
|
}
|
|
}
|
|
|
|
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d no accionable tras %s: %s", backendNodeID, timeout, describeActionableFailure(last))
|
|
}
|
|
|
|
// describeActionableFailure traduce el estado fallido a un mensaje humano.
|
|
func describeActionableFailure(r actionableResult) string {
|
|
switch r.State {
|
|
case "visible":
|
|
return "not visible (display:none, visibility:hidden, opacity:0 o tamaño 0)"
|
|
case "stable":
|
|
return "not stable (el rect sigue cambiando entre frames; animación o layout en curso)"
|
|
case "enabled":
|
|
return "disabled (atributo disabled, aria-disabled=true o <fieldset disabled>)"
|
|
case "inviewport":
|
|
return "outside of the viewport (scrollIntoView no logró revelarlo)"
|
|
case "intercepted":
|
|
if r.Detail != "" {
|
|
return "intercepted by other element: " + r.Detail
|
|
}
|
|
return "intercepted by other element (overlay capta el pointer en el punto central)"
|
|
case "notconnected":
|
|
return "not connected to the DOM"
|
|
default:
|
|
if r.State != "" {
|
|
return "not " + r.State
|
|
}
|
|
return "estado desconocido"
|
|
}
|
|
}
|
|
|
|
// evalActionable corre una iteracion completa de chequeos en el contexto JS de la
|
|
// pagina, sobre el nodo apuntado por objID. Devuelve el veredicto serializado.
|
|
//
|
|
// El JS hace, en orden y cortocircuitando al primer fallo:
|
|
// 1. visible: tiene client rects y computed style no lo oculta.
|
|
// 2. stable: getBoundingClientRect identico en dos requestAnimationFrame seguidos.
|
|
// 3. enabled (si needEnabled): no disabled / aria-disabled=true / dentro de
|
|
// <fieldset disabled> (subiendo por la jerarquia, como getAriaDisabled).
|
|
// 4. scrollIntoView con la alineacion dada + comprobacion de que el centro cae
|
|
// dentro del viewport.
|
|
// 5. hit-test: elementFromPoint en el punto central, subiendo por shadow roots
|
|
// (assignedSlot / parentNode.host) y comprobando que el elemento golpeado es
|
|
// el target o uno de sus descendientes.
|
|
func evalActionable(c *CDPConn, objID string, needEnabled bool, scrollAlign string) (actionableResult, error) {
|
|
params := map[string]any{
|
|
"objectId": objID,
|
|
"functionDeclaration": actionableJS,
|
|
"arguments": []any{
|
|
map[string]any{"value": needEnabled},
|
|
map[string]any{"value": scrollAlign},
|
|
},
|
|
"awaitPromise": true,
|
|
"returnByValue": true,
|
|
}
|
|
result, err := c.sendCDP("Runtime.callFunctionOn", params)
|
|
if err != nil {
|
|
return actionableResult{}, err
|
|
}
|
|
if exc, ok := result["exceptionDetails"]; ok && exc != nil {
|
|
excMap, _ := exc.(map[string]any)
|
|
text, _ := excMap["text"].(string)
|
|
return actionableResult{}, fmt.Errorf("excepción JS en chequeo de actionability: %s", text)
|
|
}
|
|
resVal, ok := result["result"].(map[string]any)
|
|
if !ok {
|
|
return actionableResult{}, fmt.Errorf("resultado inesperado: %v", result)
|
|
}
|
|
raw, ok := resVal["value"]
|
|
if !ok {
|
|
return actionableResult{}, fmt.Errorf("chequeo de actionability sin valor de retorno")
|
|
}
|
|
// returnByValue=true entrega el objeto JS ya deserializado a map[string]any;
|
|
// lo re-marshalamos para decodificar en el struct tipado de forma robusta.
|
|
b, err := json.Marshal(raw)
|
|
if err != nil {
|
|
return actionableResult{}, fmt.Errorf("marshal resultado: %w", err)
|
|
}
|
|
var out actionableResult
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
return actionableResult{}, fmt.Errorf("unmarshal resultado %q: %w", string(b), err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// actionableJS es la funcion ejecutada sobre el nodo (this) via callFunctionOn.
|
|
// Devuelve una Promise<actionableResult>. La logica replica checkElementStates +
|
|
// _checkElementIsStable + expectHitTarget del injected script de Playwright,
|
|
// adaptada a un solo paso autocontenido (sin caches ni dependencias externas).
|
|
const actionableJS = `function(needEnabled, scrollAlign) {
|
|
var target = this;
|
|
var fail = function(state, detail) { return {ok:false, state:state, detail:detail||"", x:0, y:0, pageX:0, pageY:0}; };
|
|
|
|
if (!target || !target.isConnected) return Promise.resolve(fail("notconnected"));
|
|
if (target.nodeType !== 1) {
|
|
// Si el nodo no es un Element (ej. texto), intentar su elemento padre.
|
|
target = target.parentElement;
|
|
if (!target) return Promise.resolve(fail("notconnected"));
|
|
}
|
|
|
|
// 1) VISIBLE: rect con area + computed style no oculto.
|
|
var isVisible = function(el) {
|
|
if (!el || !el.isConnected) return false;
|
|
var rects = el.getClientRects();
|
|
if (!rects || rects.length === 0) return false;
|
|
var st = (el.ownerDocument && el.ownerDocument.defaultView)
|
|
? el.ownerDocument.defaultView.getComputedStyle(el) : null;
|
|
if (st) {
|
|
if (st.visibility === "hidden" || st.display === "none") return false;
|
|
if (parseFloat(st.opacity || "1") === 0) return false;
|
|
}
|
|
var r = el.getBoundingClientRect();
|
|
return r.width > 0 && r.height > 0;
|
|
};
|
|
if (!isVisible(target)) return Promise.resolve(fail("visible"));
|
|
|
|
// 2) ENABLED (opcional): disabled nativo, aria-disabled o <fieldset disabled>.
|
|
if (needEnabled) {
|
|
var isDisabled = function(el) {
|
|
var native = ["BUTTON","INPUT","SELECT","TEXTAREA","OPTION","OPTGROUP"];
|
|
var n = el;
|
|
while (n) {
|
|
if (n.nodeType === 1) {
|
|
var tag = (n.tagName || "").toUpperCase();
|
|
if (native.indexOf(tag) !== -1 && n.hasAttribute && n.hasAttribute("disabled")) return true;
|
|
// fieldset disabled deshabilita a sus controles (salvo dentro del legend).
|
|
if (tag === "FIELDSET" && n.hasAttribute && n.hasAttribute("disabled")) return true;
|
|
var ad = n.getAttribute && n.getAttribute("aria-disabled");
|
|
if (ad && ad.toLowerCase() === "true") return true;
|
|
}
|
|
// Subir por DOM y cruzar shadow boundaries.
|
|
n = n.parentElement || (n.parentNode && n.parentNode.host) || (n.assignedSlot || null);
|
|
}
|
|
return false;
|
|
};
|
|
if (isDisabled(target)) return Promise.resolve(fail("enabled"));
|
|
}
|
|
|
|
// 4) SCROLL INTO VIEW con la alineacion rotada por el caller.
|
|
try { target.scrollIntoView({block: scrollAlign, inline: scrollAlign, behavior: "instant"}); }
|
|
catch (e) { try { target.scrollIntoView(); } catch (e2) {} }
|
|
|
|
// 3) STABLE: comparar getBoundingClientRect en dos requestAnimationFrame seguidos.
|
|
var rectOf = function(el) {
|
|
var r = el.getBoundingClientRect();
|
|
return {x: r.left, y: r.top, w: r.width, h: r.height};
|
|
};
|
|
var rafTwice = function() {
|
|
return new Promise(function(res) {
|
|
requestAnimationFrame(function() { requestAnimationFrame(function() { res(); }); });
|
|
});
|
|
};
|
|
|
|
var first = rectOf(target);
|
|
return rafTwice().then(function() {
|
|
if (!target.isConnected) return fail("notconnected");
|
|
var second = rectOf(target);
|
|
var same = first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
|
if (!same) return fail("stable");
|
|
|
|
var r = second;
|
|
var vw = window.innerWidth || document.documentElement.clientWidth;
|
|
var vh = window.innerHeight || document.documentElement.clientHeight;
|
|
var cx = r.x + r.w / 2;
|
|
var cy = r.y + r.h / 2;
|
|
|
|
// 4b) IN VIEWPORT: el punto central debe caer dentro del viewport tras el scroll.
|
|
if (cx < 0 || cy < 0 || cx > vw || cy > vh) return fail("inviewport");
|
|
|
|
// 5) HIT-TEST: elementFromPoint subiendo por shadow roots; el golpeado debe ser
|
|
// el target o un descendiente suyo (cruzando shadow boundaries).
|
|
var enclosingRoot = function(el) {
|
|
var node = el;
|
|
while (node && node.parentNode) node = node.parentNode;
|
|
if (node && (node.nodeType === 11 || node.nodeType === 9)) return node;
|
|
return null;
|
|
};
|
|
var parentOrHost = function(el) {
|
|
if (el.parentElement) return el.parentElement;
|
|
if (el.parentNode && el.parentNode.nodeType === 11 && el.parentNode.host) return el.parentNode.host;
|
|
return null;
|
|
};
|
|
|
|
// Recolectar roots desde el target hacia arriba (document u shadow roots).
|
|
var roots = [];
|
|
var p = target;
|
|
while (p) {
|
|
var root = enclosingRoot(p);
|
|
if (!root) break;
|
|
roots.push(root);
|
|
if (root.nodeType === 9) break;
|
|
p = root.host;
|
|
}
|
|
|
|
// Hit en cada root debe apuntar al siguiente root; en el ultimo, al target/descendiente.
|
|
var hit = null;
|
|
for (var i = roots.length - 1; i >= 0; i--) {
|
|
var rt = roots[i];
|
|
var inner = rt.elementFromPoint ? rt.elementFromPoint(cx, cy) : null;
|
|
if (!inner) break;
|
|
hit = inner;
|
|
if (i && roots[i - 1] && inner !== roots[i - 1].host) break;
|
|
}
|
|
if (!hit) return fail("intercepted", "ningún elemento en el punto central");
|
|
|
|
// Subir desde el hit hasta el target (composed tree: assignedSlot primero).
|
|
var cur = hit;
|
|
while (cur && cur !== target) {
|
|
cur = cur.assignedSlot || parentOrHost(cur);
|
|
}
|
|
if (cur !== target) {
|
|
var desc = hit.tagName ? hit.tagName.toLowerCase() : "node";
|
|
if (hit.id) desc += "#" + hit.id;
|
|
else if (hit.className && typeof hit.className === "string" && hit.className.trim())
|
|
desc += "." + hit.className.trim().split(/\s+/)[0];
|
|
return fail("intercepted", desc);
|
|
}
|
|
|
|
var sx = window.scrollX || window.pageXOffset || 0;
|
|
var sy = window.scrollY || window.pageYOffset || 0;
|
|
return {ok:true, state:"ok", detail:"", x:cx, y:cy, pageX:cx + sx, pageY:cy + sy};
|
|
});
|
|
}`
|