Automatización de Aplicaciones Electron con el Protocolo Chrome DevTools

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 pantallas
  • DOM: exploración y manipulación del árbol de nodos
  • Runtime: evaluación de JavaScript y acceso al contexto de ejecución
  • Input: simulación precisa de eventos de teclado y ratón
  • Network: interceptación y modificación de solicitudes HTTP
  • Target: 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é nodeId siempre 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.loadEventFired en lugar de sleep()

Etiquetas: chrome-devtools-protocol Electron automation WebSocket python-asyncio

Publicado el 9-2 11:31