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
). 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 )"
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
// (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. 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 .
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};
});
}`