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