Las aplicaciones Electron combinan un motor Chromium personalizado con un entorno Node.js, lo que genera desafíos únicos para la automatización de interfaz. Los enfoques tradicionales fallan al abordar esta arquitectura híbrida:
| Herramienta | Límite principal |
|---|---|
| Seleinum | No puede adjuntarse al proceso de Electron ni acceder a sus pestañas internas |
| PyAutoGUI | Depende de coordenadas de pantalla; se rompe con cambios de resolución o escalado |
| WinAppDriver | No penetra el nivel de renderizado de Chromium: no ve el DOM ni los eventos web |
| Playwright | Soporte limitado para funcionalidades específicas de Electron (ej. IPC, módulos nativos) |
La raíz del problema es clara: aunque la UI está construida con HTML/CSS/JS, no se ejecuta dentro de un navegador estándar. La solución reside en usar directamente el protocolo subyacente que Chromium expone para depuración: el Chrome DevTools Protocol (CDP).
Arquitectura y funcionamiento de CDP
CDP es una interfaz basada en WebSocket que permite interactuar con el motor de Chromium desde fuera. Todos los paneles de las DevTools (Elements, Console, Network) operan sobre este mismo protocolo.
Cada comando pertenece a un domain, agrupado por funcionalidad:
Page: navegación, recarga, captura de pantallasDOM: exploración y manipulación del árbol de nodosRuntime: evaluación de JavaScript y acceso al contexto de ejecuciónInput: simulación precisa de eventos de teclado y ratónNetwork: interceptación y modificación de solicitudes HTTPTarget: gestión dinámica de ventanas y procesos renderizados
Habilitación del modo depuración en Electron
Para exponer CDP, la aplicación debe iniciarse con banderas específicas:
# En Linux/macOS
./mi-app-electron --remote-debugging-port=9222 --no-sandbox
# En Windows (desde línea de comandos)
mi-app.exe --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1
Si tienes acceso al código fuente, puedes configurarlo programáticamente:
// main.js
const { app } = require('electron');
app.commandLine.appendSwitch('remote-debugging-port', '9222');
app.commandLine.appendSwitch('remote-debugging-address', '127.0.0.1');
app.on('ready', () => {
const ventana = new BrowserWindow({ webPreferences: { nodeIntegration: false } });
ventana.loadFile('index.html');
});
Verifica que el puerto esté activo con:
curl http://localhost:9222/json/list
Esto devuelve una lista de targets disponibles, cada uno con su propia URL WebSocket única.
Conexión y comunicación mediante WebSocket
A continuación, una implementación robusta en Python usando websockets y aiohttp:
import asyncio
import json
import websockets
import aiohttp
class ElectronDebugger:
def __init__(self, ws_url: str):
self.ws_url = ws_url
self.ws = None
self._next_id = 0
self._pending_responses = {}
async def connect(self):
self.ws = await websockets.connect(self.ws_url)
asyncio.create_task(self._listen())
async def _listen(self):
async for msg in self.ws:
data = json.loads(msg)
if 'id' in data and data['id'] in self._pending_responses:
self._pending_responses[data['id']] = data
async def execute(self, method: str, params: dict = None) -> dict:
req_id = self._next_id
self._next_id += 1
payload = {"id": req_id, "method": method, "params": params or {}}
await self.ws.send(json.dumps(payload))
while req_id not in self._pending_responses:
await asyncio.sleep(0.02)
return self._pending_responses.pop(req_id)
async def close(self):
if self.ws:
await self.ws.close()
# Ejecución inicial
async def iniciar_automatizacion():
# Descubrir target activo
async with aiohttp.ClientSession() as session:
async with session.get("http://localhost:9222/json/list") as r:
targets = await r.json()
target = next(t for t in targets if t["type"] == "page")
cliente = ElectronDebugger(target["webSocketDebuggerUrl"])
await cliente.connect()
# Activar dominios clave
await cliente.execute("Page.enable")
await cliente.execute("DOM.enable")
await cliente.execute("Runtime.enable")
# Leer título de la página
resultado = await cliente.execute("Runtime.evaluate", {
"expression": "document.title"
})
print("Título:", resultado["result"]["result"]["value"])
await cliente.close()
Interacción con elementos del DOM
Localziar y manipular elementos requiere múltiples pasos coordinados:
async def localizar_elemento(cliente: ElectronDebugger, selector: str) -> int:
"""Devuelve el nodeId del primer elemento coincidente."""
doc = await cliente.execute("DOM.getDocument")
root_id = doc["result"]["root"]["nodeId"]
res = await cliente.execute("DOM.querySelector", {
"nodeId": root_id,
"selector": selector
})
if res["result"]["nodeId"] == 0:
raise LookupError(f"No se encontró '{selector}'")
return res["result"]["nodeId"]
async def hacer_clic_en(cliente: ElectronDebugger, selector: str):
"""Simula un clic centrado en el elemento."""
node_id = await localizar_elemento(cliente, selector)
box = await cliente.execute("DOM.getBoxModel", {"nodeId": node_id})
coords = box["result"]["model"]["content"]
x = sum(coords[i] for i in [0, 2, 4, 6]) / 4
y = sum(coords[i] for i in [1, 3, 5, 7]) / 4
await cliente.execute("Input.dispatchMouseEvent", {
"type": "mousePressed", "x": x, "y": y, "button": "left"
})
await cliente.execute("Input.dispatchMouseEvent", {
"type": "mouseReleased", "x": x, "y": y, "button": "left"
})
Captura de pantallas y validación visual
CDP permite tomar capturas precisas, incluso de elementos individuales:
import base64
async def capturar_pantalla(cliente: ElectronDebugger, ruta: str, recorte=None):
params = {"format": "png"}
if recorte:
params["clip"] = recorte
resp = await cliente.execute("Page.captureScreenshot", params)
datos = base64.b64decode(resp["result"]["data"])
with open(ruta, "wb") as f:
f.write(datos)
return ruta
# Captura de un elemento específico
async def capturar_elemento(cliente: ElectronDebugger, selector: str, ruta: str):
node_id = await localizar_elemento(cliente, selector)
box = await cliente.execute("DOM.getBoxModel", {"nodeId": node_id})
modelo = box["result"]["model"]
recorte = {
"x": modelo["content"][0],
"y": modelo["content"][1],
"width": modelo["width"],
"height": modelo["height"],
"scale": 1.0
}
return await capturar_pantalla(cliente, ruta, recorte)
Manejo avanzado de casos complejos
Ventanas múltiples: Usa /json/list para identificar targets por URL o título, y reconecta a nuevos targets si se abren ventanas emergentes.
Shadow DOM: Para penetrar sombras, ejecuta código JavaScript que navegue recursivamente:
async def buscar_en_shadow(cliente: ElectronDebugger, cadena: str):
expresion = f"""
(function traverse(selectorList) {{
let nodo = document;
for (const sel of selectorList) {{
if (sel.startsWith('::shadow')) {{
const host = nodo.querySelector(sel.replace('::shadow', ''));
nodo = host?.shadowRoot || null;
}} else {{
nodo = nodo?.querySelector(sel) || null;
}}
if (!nodo) break;
}}
return nodo;
}})({json.dumps(cadena.split(' > '))})
"""
return await cliente.execute("Runtime.evaluate", {"expression": expresion})
Subida de archivos: Omite el diálogo nativo con DOM.setFileInputFiles:
async def subir_archivos(cliente: ElectronDebugger, selector: str, rutas: list):
node_id = await localizar_elemento(cliente, selector)
await cliente.execute("DOM.setFileInputFiles", {
"nodeId": node_id,
"files": rutas
})
Optimización de rendimiento
- Minimiza idas y vueltas: Agrupa operaciones en una sola llamada a
Runtime.evaluate - Evita consultas repetidas: Almacena en caché
nodeIdsiempre que el DOM sea estable - Usa formatos ligeros: Prefiere JPEG sobre PNG para capturas donde la pérdida es aceptable
- Espera inteligente: Suscríbete a eventos como
Page.loadEventFireden lugar desleep()