Automatisation des applications Electron : Maîtriser le contrôle via le Protocole Chrome DevTools (CDP)

Les défis de l'automatisation des applications Electron

Une application Electron est fondamentalement une combinaison d'un "navigateur Chromium personnalisé" et d'un "environnement d'exécution Node.js". La majorité des frameworks d'automatisation d'interface utilisateur disponibles sur le marché peinent à gérer efficacement cette architecture hybride.

Les approches courantes rencontrent des limitations importantes :

  • Selenium : Conçu pour les navigateurs web standards, il ne peut pas se connecter directement au processus Electron.
  • PyAutoGUI : Basé sur les coordonnées d'écran, il devient obsolète dès qu'un changement de résolution ou de mise à l'échelle intervient.
  • WinAppDriver : Incapable de traverser la couche de rendu Chromium pour interagir avec le modèle d'objet du document (DOM).
  • Spectron (obsolète) : Son développement a été abandonné par ses créateurs.
  • Playwright : Son support pour Electron est partiel, rendant certaines de ses API inutilisables dans ce contexte.

Le nœud du problème réside dans le fait que l'interface utilisateur d'Electron repose sur des technologies web (HTML/CSS/JS), mais n'est pas exécutée dans un navigateur standard. Il est donc nécessaire de disposer d'un canal direct pour interagir avec le moteur Chromium intégré à Electron – c'est précisément là que le Protocole Chrome DevTools (CDP) prend toute sa valeur.

Comprendre le Protocole Chrome DevTools (CDP)

Qu'est-ce que le CDP ?

Le Chrome DevTools Protocol (CDP) est une interface de débogage exposée par Chromium. Lorsque vous utilisez les outils de développement de Chrome (F12) pour inspecter les éléments, la console ou le réseau, toutes ces interactions reposent sur le CDP pour communiquer avec le navigateur.

Architecture du Protocloe CDP


                                  ┌──────────────────────────────────────────────────────────┐
                                  │                    Application Electron                  │
                                  │  ┌────────────────────────────────────────────────────┐  │
                                  │  │                Moteur Chromium                     │  │
                                  │  │  ┌──────────┐  ┌──────────┐  ┌──────────┐         │  │
                                  │  │  │ Rendu    │  │ Rendu    │  │   GPU    │         │  │
                                  │  │  │ Processus│  │ Processus│  │ Processus│         │  │
                                  │  │  │(Fenêtre du)│(Fenêtre du)│        │         │  │
                                  │  │  │ Navigateur│  │ Navigateur│        │         │  │
                                  │  │  └──────────┘  └──────────┘  └──────────┘         │  │
                                  │  │                                                      │  │
                                  │  │  ┌──────────────────────────────────────────┐       │  │
                                  │  │  │            Processus du Navigateur      │       │  │
                                  │  │  │  ┌──────────────────────────────────┐    │       │  │
                                  │  │  │  │   Serveur DevTools (CDP)         │    │       │  │
                                  │  │  │  │   ws://127.0.0.1:9222            │◄───┼──┐    │  │
                                  │  │  │  └──────────────────────────────────┘    │  │    │  │
                                  │  │  └──────────────────────────────────────────┘  │    │  │
                                  │  └────────────────────────────────────────────────────┘  │
                                  │                                                          │
                                  │  ┌────────────────────────────────────────────────────┐  │
                                  │  │                Environnement d'exécution Node.js    │  │
                                  │  │  · Système de fichiers  · Réseau  · API système    │  │
                                  │  └────────────────────────────────────────────────────┘  │
                                  └──────────────────────────────────────────────────────────┘
                                                                                      │
                                                               WebSocket           │
                                                               Connexion           │
                                                              ┌────────────────────┘
                                                              ▼
                                                    ┌──────────────────┐
                                                    │  Script d'auto   │
                                                    │  (Python/Node)   │
                                                    └──────────────────┘

Modèle de Domaine CDP

Le protocole CDP est structuré en plusieurs domaines, chacun offrant un ensemble de méthodes et d'événements spécifiques :

Domaine Fonctionnalité Méthodes courantes pour l'automatisation
Page Gestion de la page navigate, reload, captureScreenshot
DOM Manipulation du DOM getDocument, querySelector, getOuterHTML
Runtime Exécution JavaScript evaluate, callFunctionOn
Input Simulation d'entrée dispatchMouseEvent, dispatchKeyEvent
Network Interception réseau enable, interceptRequests
Emulation Simulation de périphérique setDeviceMetricsOverride
Target Gestion des cibles getTargets, attachToTarget

Configuration de démarrage : Activer le port de débogage à distance

Configuration initiale

Pour permettre la connexion CDP, l'application Electron doit être lancée avec des paramètres spécifiques :


# Méthode 1 : Lancement direct via la ligne de commande
./votre-app-electron --remote-debugging-port=9222

# Méthode 2 : Avec des paramètres de débogage additionnels
./votre-app-electron \
  --remote-debugging-port=9222 \
  --remote-debugging-address=0.0.0.0 \
  --no-sandbox

# Méthode 3 : Pour une application packagée (Windows)
"C:\Program Files\VotreApp\VotreApp.exe" --remote-debugging-port=9222

# Méthode 4 : macOS
open -a "VotreApp" --args --remote-debugging-port=9222

Vérification de l'activation du port CDP

Pour confirmer que le port de débogage à distance est actif et fonctionnel :


# Vérifier si le port écoute
curl http://127.0.0.1:9222/json/version

# Réponse attendue :
{
  "Browser": "Chrome/120.0.6099.0",
  "Protocol-Version": "1.3",
  "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/xxxx-xxxx"
}

# Lister toutes les pages/fenêtres débogables
curl http://127.0.0.1:9222/json/list

Configuration depuis le code Electron (si vous avez accès au code source)

Il est également possible de configurer le port de débogage directement dans le code du processus principal d'Electron :


// main.js - Processus principal d'Electron
const { app, BrowserWindow } = require('electron');

// Ajoute des commutateurs de ligne de commande avant que l'application ne soit prête
app.commandLine.appendSwitch('remote-debugging-port', '9222');
app.commandLine.appendSwitch('remote-debugging-address', '0.0.0.0');

app.on('ready', () => {
  const fenetrePrincipale = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true
    }
  });
  
  fenetrePrincipale.loadFile('index.html');
  
  // Affiche l'URL WebSocket du DevTools pour la fenêtre actuelle
  const url_debug = fenetrePrincipale.webContents.debugger; // debugger est un objet pour le contrôle CDP
  console.log('CDP disponible à :', 
    `ws://127.0.0.1:9222/devtools/page/${fenetrePrincipale.webContents.id}`
  );
});

Connexion WebSocket : Établir la communication CDP

Implémentation en Python

Un client CDP léger peut être implémenté pour gérer les communications via WebSocket :


import asyncio
import json
import websockets

class ClientCDP:
    """Client CDP léger pour interagir avec une cible Electron."""
    
    def __init__(self, url_connexion: str):
        self.url_connexion = url_connexion
        self.ws_connexion = None
        self.identifiant_message = 0
        self.reponses_attendues = {}
    
    async def etablir_connexion(self):
        """Initialise la connexion WebSocket au point de débogage CDP."""
        self.ws_connexion = await websockets.connect(self.url_connexion)
        # Lance la boucle de réception des messages en arrière-plan
        asyncio.create_task(self._boucle_reception())
    
    async def _boucle_reception(self):
        """Boucle asynchrone pour recevoir continuellement les réponses CDP."""
        async for message in self.ws_connexion:
            donnees = json.loads(message)
            if 'id' in donnees:
                self.reponses_attendues[donnees['id']] = donnees
    
    async def envoyer_commande(self, methode: str, parametres: dict = None) -> dict:
        """Envoie une commande CDP et attend sa réponse."""
        self.identifiant_message += 1
        msg = {
            "id": self.identifiant_message,
            "method": methode,
            "params": parametres or {}
        }
        await self.ws_connexion.send(json.dumps(msg))
        
        # Attend la réponse correspondante
        while self.identifiant_message not in self.reponses_attendues:
            await asyncio.sleep(0.01)
        
        return self.reponses_attendues.pop(self.identifiant_message)
    
    async def fermer_connexion(self):
        """Ferme la connexion WebSocket."""
        await self.ws_connexion.close()


# Exemple d'utilisation
async def script_automatisation_principal():
    # 1. Récupération de l'URL WebSocket à partir de la liste des cibles
    import aiohttp
    async with aiohttp.ClientSession() as session:
        async with session.get('http://127.0.0.1:9222/json/list') as reponse_http:
            cibles_disponibles = await reponse_http.json()
    
    # Sélectionne la première cible de type 'page' (généralement la fenêtre principale)
    cible_page = next(t for t in cibles_disponibles if t['type'] == 'page')
    url_websocket_page = cible_page['webSocketDebuggerUrl']
    
    # 2. Connexion au client CDP
    client_cdp = ClientCDP(url_websocket_page)
    await client_cdp.etablir_connexion()
    
    # 3. Activation des domaines CDP nécessaires
    await client_cdp.envoyer_commande("Page.enable")
    await client_cdp.envoyer_commande("DOM.enable")
    await client_cdp.envoyer_commande("Runtime.enable")
    
    # 4. Exécution d'une commande pour obtenir le titre de la page
    resultat_eval = await client_cdp.envoyer_commande("Runtime.evaluate", {
        "expression": "document.title"
    })
    print(f"Titre de la page: {resultat_eval['result']['result']['value']}")
    
    await client_cdp.fermer_connexion()

asyncio.run(script_automatisation_principal())

Implémentation en Node.js (avec chrome-remote-interface)

Le module chrome-remote-interface simplifie la gestion du protocoel CDP en Node.js :


const CDP = require('chrome-remote-interface');

async function executerAutomatisation() {
  let interfaceDevTools;
  try {
    // Connexion à l'application Electron
    interfaceDevTools = await CDP({
      host: '127.0.0.1',
      port: 9222,
      target: (cibles) => {
        // Sélectionne la fenêtre principale ou la première page non-vide
        return cibles.find(t => t.type === 'page' && t.url !== 'about:blank');
      }
    });

    const { Page, DOM, Runtime } = interfaceDevTools;

    // Active les domaines essentiels
    await Promise.all([
      Page.enable(),
      DOM.enable(),
      Runtime.enable()
    ]);

    // Évalue une expression JavaScript pour récupérer le titre de la page
    const { result: resultatEvaluation } = await Runtime.evaluate({
      expression: 'document.title'
    });
    console.log('Titre de la page :', resultatEvaluation.value);

  } catch (erreur) {
    console.error('Erreur lors de l\'automatisation:', erreur);
  } finally {
    if (interfaceDevTools) {
      await interfaceDevTools.close();
    }
  }
}

executerAutomatisation().catch(console.error);

Manipulation du DOM : Localisation et interaction avec les éléments

Récupération de l'arbre DOM et propriétés d'éléments

Pour interagir avec les éléments, il faut d'abord les localiser et obtenir leurs propriétés :


class ElementNonTrouve(Exception):
    """Exception levée quand un élément spécifié par un sélecteur n'est pas trouvé."""
    pass

async def trouver_element_par_selecteur(client_cdp: ClientCDP, selecteur_css: str) -> dict:
    """Localise un élément du DOM via un sélecteur CSS."""
    # Obtient le nœud racine du document
    res_doc = await client_cdp.envoyer_commande("DOM.getDocument")
    id_noeud_racine = res_doc['result']['root']['nodeId']
    
    # Utilise le sélecteur pour interroger le DOM
    res_query = await client_cdp.envoyer_commande("DOM.querySelector", {
        "nodeId": id_noeud_racine,
        "selector": selecteur_css
    })
    
    id_noeud_trouve = res_query['result']['nodeId']
    if id_noeud_trouve == 0:
        raise ElementNonTrouve(f"Élément non trouvé : {selecteur_css}")
    
    # Récupère les informations détaillées sur l'élément
    details_noeud = await client_cdp.envoyer_commande("DOM.describeNode", {
        "nodeId": id_noeud_trouve,
        "depth": 0
    })
    
    return {
        "nodeId": id_noeud_trouve,
        "nodeName": details_noeud['result']['node']['nodeName'],
        "attributes": details_noeud['result']['node'].get('attributes', [])
    }


async def obtenir_dimensions_element(client_cdp: ClientCDP, id_noeud: int) -> dict:
    """Récupère la position et les dimensions d'un élément donné par son ID de nœud."""
    res_modele = await client_cdp.envoyer_commande("DOM.getBoxModel", {
        "nodeId": id_noeud
    })
    
    modele_boite = res_modele['result']['model']
    # Coordonnées des quatre coins de la zone de contenu [x1,y1, x2,y2, x3,y3, x4,y4]
    coords_contenu = modele_boite['content']
    
    # Calcul du point central de l'élément
    centre_x = (coords_contenu[0] + coords_contenu[2] + coords_contenu[4] + coords_contenu[6]) / 4
    centre_y = (coords_contenu[1] + coords_contenu[3] + coords_contenu[5] + coords_contenu[7]) / 4
    
    return {
        "centre": (centre_x, centre_y),
        "largeur": modele_boite['width'],
        "hauteur": modele_boite['height'],
        "contenu": coords_contenu
    }

Simulation d'actions utilisateur

Le protocole CDP permet de simuler des clics, des saisies de texte et des défilements :


async def cliquer_sur_element(client_cdp: ClientCDP, selecteur_css: str):
    """Simule un clic de souris sur l'élément spécifié."""
    element_cible = await trouver_element_par_selecteur(client_cdp, selecteur_css)
    boite_element = await obtenir_dimensions_element(client_cdp, element_cible['nodeId'])
    coord_x, coord_y = boite_element['centre']
    
    # Envoie les événements de souris (appuyé puis relâché)
    await client_cdp.envoyer_commande("Input.dispatchMouseEvent", {
        "type": "mousePressed",
        "x": coord_x,
        "y": coord_y,
        "button": "left",
        "clickCount": 1
    })
    await client_cdp.envoyer_commande("Input.dispatchMouseEvent", {
        "type": "mouseReleased",
        "x": coord_x,
        "y": coord_y,
        "button": "left",
        "clickCount": 1
    })


async def saisir_texte_dans_element(client_cdp: ClientCDP, selecteur_css: str, texte_a_saisir: str):
    """Saisit du texte dans un champ de saisie ciblé."""
    # Clique d'abord sur l'élément pour lui donner le focus
    await cliquer_sur_element(client_cdp, selecteur_css)
    await asyncio.sleep(0.1)
    
    # Efface le contenu existant du champ
    await client_cdp.envoyer_commande("Runtime.evaluate", {
        "expression": f"""
            (function() {{
                const el = document.querySelector('{selecteur_css}');
                if (el) {{
                    el.value = '';
                    el.dispatchEvent(new Event('input', {{bubbles: true}}));
                }}
            }})()
        """
    })
    
    # Simule la frappe caractère par caractère
    for char in texte_a_saisir:
        await client_cdp.envoyer_commande("Input.dispatchKeyEvent", {
            "type": "keyDown",
            "text": char,
            "key": char,
            "unmodifiedText": char
        })
        await client_cdp.envoyer_commande("Input.dispatchKeyEvent", {
            "type": "keyUp",
            "key": char
        })
        await asyncio.sleep(0.02)  # Petite pause pour simuler une saisie humaine


async def faire_defiler_vers_element(client_cdp: ClientCDP, selecteur_css: str):
    """Fait défiler la page pour rendre un élément visible."""
    await client_cdp.envoyer_commande("Runtime.evaluate", {
        "expression": f"""
            document.querySelector('{selecteur_css}')
                .scrollIntoView({{behavior: 'smooth', block: 'center'}})
        """
    })
    await asyncio.sleep(0.5)  # Accorde un court délai pour la fin du défilement

Captures d'écran et comparaison visuelle

Capture d'écran de page

Prendre des captures d'écran est essentiel pour la vérification visuelle :


import base64
from PIL import Image # Nécessite 'Pillow'
import io

async def capturer_ecran(client_cdp: ClientCDP, chemin_enregistrement: str, 
                          zone_decoupe: dict = None) -> str:
    """
    Réalise une capture d'écran de la page.
    zone_decoupe: facultatif, définit une région de découpe {x, y, width, height, scale}
    """
    parametres_capture = {
        "format": "png",
        "quality": 100
    }
    
    if zone_decoupe:
        parametres_capture["clip"] = zone_decoupe
    
    resultat = await client_cdp.envoyer_commande("Page.captureScreenshot", parametres_capture)
    
    # Décode les données d'image Base64
    donnees_image_base64 = base64.b64decode(resultat['result']['data'])
    
    # Sauvegarde le fichier image
    with open(chemin_enregistrement, 'wb') as f:
        f.write(donnees_image_base64)
    
    return chemin_enregistrement


async def capturer_element_specifique(client_cdp: ClientCDP, selecteur_css: str, 
                                  chemin_enregistrement: str) -> str:
    """Capture une capture d'écran d'un élément DOM particulier."""
    element_cible = await trouver_element_par_selecteur(client_cdp, selecteur_css)
    boite_element = await obtenir_dimensions_element(client_cdp, element_cible['nodeId'])
    
    coords_boite = boite_element['contenu']
    x_min_coords = min(coords_boite[0], coords_boite[2], coords_boite[4], coords_boite[6])
    y_min_coords = min(coords_boite[1], coords_boite[3], coords_boite[5], coords_boite[7])
    
    zone_decoupe = {
        "x": x_min_coords,
        "y": y_min_coords,
        "width": boite_element['largeur'],
        "height": boite_element['hauteur'],
        "scale": 1
    }
    
    return await capturer_ecran(client_cdp, chemin_enregistrement, zone_decoupe)

Comparaison de captures d'écran pour la régression visuelle


import numpy as np # Nécessite 'numpy'
from PIL import Image, ImageChops # Nécessite 'Pillow'

class ComparateurCapturesEcran:
    """Outil de comparaison de captures d'écran pour les tests de régression visuelle."""
    
    def __init__(self, seuil_difference_pixels: float = 0.01):
        """
        seuil_difference_pixels: Pourcentage maximal de pixels différents autorisés (ex: 0.01 = 1%)
        """
        self.seuil_difference_pixels = seuil_difference_pixels
    
    def analyser_differences(self, chemin_ref: str, chemin_actuel: str) -> dict:
        """Compare deux images de capture d'écran."""
        image_ref = Image.open(chemin_ref).convert('RGB')
        image_actuelle = Image.open(chemin_actuel).convert('RGB')
        
        # Vérification des dimensions
        if image_ref.size != image_actuelle.size:
            return {
                "correspondance": False,
                "raison": "taille_differente",
                "taille_reference": image_ref.size,
                "taille_actuelle": image_actuelle.size
            }
        
        # Calcul de la différence pixel par pixel
        difference_images = ImageChops.difference(image_ref, image_actuelle)
        tableau_differences = np.array(difference_images)
        
        # Comptage des pixels non nuls (ceux qui diffèrent)
        pixels_non_nuls = np.count_nonzero(tableau_differences.sum(axis=2))
        total_pixels_img = tableau_differences.shape[0] * tableau_differences.shape[1]
        ratio_difference = pixels_non_nuls / total_pixels_img
        
        # Calcul de l'indice de similarité structurelle (SSIM - version simplifiée)
        ssim_valeur = self._calculer_indice_ssim(
            np.array(image_ref).astype(float),
            np.array(image_actuelle).astype(float)
        )
        
        return {
            "correspondance": ratio_difference <= self.seuil_difference_pixels,
            "ratio_diff": round(ratio_difference, 6),
            "ssim_score": round(ssim_valeur, 4),
            "pixels_differents": pixels_non_nuls,
            "total_pixels": total_pixels_img
        }
    
    def _calculer_indice_ssim(self, img_un: np.ndarray, img_deux: np.ndarray) -> float:
        """Calcule un indice de similarité structurelle simplifié (SSIM)."""
        K1 = (0.01 * 255) ** 2
        K2 = (0.03 * 255) ** 2
        
        moyenne1 = img_un.mean()
        moyenne2 = img_deux.mean()
        std_dev1 = img_un.std()
        std_dev2 = img_deux.std()
        covariance = np.mean((img_un - moyenne1) * (img_deux - moyenne2))
        
        ssim_formule = ((2 * moyenne1 * moyenne2 + K1) * (2 * covariance + K2)) / \
                       ((moyenne1**2 + moyenne2**2 + K1) * (std_dev1**2 + std_dev2**2 + K2))
        
        return float(ssim_formule)

Cadre d'automatisation complet

Encapsulation du cadre

Pour des tests ou une automatisation à grande échelle, il est judicieux d'encapsuler la logique CDP dans une classe de plus haut niveau :


class PiloteElectron:
    """Cadre d'automatisation pour les applications Electron."""
    
    def __init__(self, chemin_app_electron: str, port_debug_cdp: int = 9222):
        self.chemin_app_electron = chemin_app_electron
        self.port_debug_cdp = port_debug_cdp
        self.client_cdp = None
        self.processus_app = None
    
    async def demarrer_application(self):
        """Lance l'application Electron et établit la connexion CDP."""
        import subprocess
        import time
        
        # Démarre l'application en arrière-plan
        self.processus_app = subprocess.Popen([
            self.chemin_app_electron,
            f'--remote-debugging-port={self.port_debug_cdp}',
            '--no-sandbox' # Souvent nécessaire pour l'automatisation dans des environnements CI/CD
        ])
        
        # Attend que le port CDP soit opérationnel
        for _ in range(30): # Tente pendant 30 secondes
            try:
                import aiohttp
                async with aiohttp.ClientSession() as session:
                    async with session.get(
                        f'http://127.0.0.1:{self.port_debug_cdp}/json/list'
                    ) as reponse_http:
                        if reponse_http.status == 200:
                            break
            except Exception:
                pass
            await asyncio.sleep(1)
        else:
            raise TimeoutError("Le port CDP n'est pas devenu disponible après 30 secondes.")
        
        # Se connecte à la première cible de type 'page' trouvée
        async with aiohttp.ClientSession() as session:
            async with session.get(
                f'http://127.0.0.1:{self.port_debug_cdp}/json/list'
            ) as reponse_http:
                cibles = await reponse_http.json()
        
        page_cible = next(t for t in cibles if t['type'] == 'page')
        self.client_cdp = ClientCDP(page_cible['webSocketDebuggerUrl'])
        await self.client_cdp.etablir_connexion()
        
        # Active les domaines CDP indispensables
        await self.client_cdp.envoyer_commande("Page.enable")
        await self.client_cdp.envoyer_commande("DOM.enable")
        await self.client_cdp.envoyer_commande("Runtime.enable")
    
    async def naviguer_vers_url(self, url: str):
        """Navigue la fenêtre Electron vers une URL donnée."""
        await self.client_cdp.envoyer_commande("Page.navigate", {"url": url})
        await asyncio.sleep(1)  # Attendre le chargement initial de la page
    
    async def attendre_selecteur(self, selecteur_css: str, delai_expiration: float = 10):
        """Attend qu'un élément identifié par un sélecteur CSS apparaisse dans le DOM."""
        debut_attente = time.time()
        while time.time() - debut_attente < delai_expiration:
            try:
                element_trouve = await trouver_element_par_selecteur(self.client_cdp, selecteur_css)
                if element_trouve['nodeId'] != 0:
                    return element_trouve
            except ElementNonTrouve:
                pass # L'élément n'est pas encore là
            await asyncio.sleep(0.5)
        raise TimeoutError(f"Élément non trouvé après {delai_expiration}s : {selecteur_css}")
    
    async def executer_script_javascript(self, expression_js: str) -> any:
        """Exécute une expression JavaScript dans le contexte de la page et retourne le résultat."""
        resultat = await self.client_cdp.envoyer_commande("Runtime.evaluate", {
            "expression": expression_js,
            "returnByValue": True
        })
        return resultat['result']['result'].get('value')
    
    async def arreter_application(self):
        """Ferme l'application Electron et la connexion CDP."""
        if self.client_cdp:
            await self.client_cdp.fermer_connexion()
        if self.processus_app:
            self.processus_app.terminate() # Termine le processus de l'application


# Exemple d'utilisation d'un scénario de test
async def tester_flux_connexion():
    """Scénario de test automatisé pour un processus de connexion."""
    pilote_auto = PiloteElectron("/chemin/vers/votre-app-electron") # REMPLACEZ PAR LE VRAI CHEMIN
    await pilote_auto.demarrer_application()
    
    try:
        # Attend le chargement du formulaire de connexion
        await pilote_auto.attendre_selecteur('#login-form')
        
        # Saisie du nom d'utilisateur et du mot de passe
        await saisir_texte_dans_element(pilote_auto.client_cdp, '#username', 'utilisateur_test@exemple.com')
        await saisir_texte_dans_element(pilote_auto.client_cdp, '#password', 'MotDePasseSecurise123')
        
        # Clique sur le bouton de connexion
        await cliquer_sur_element(pilote_auto.client_cdp, '#login-button')
        
        # Attend la redirection vers la page du tableau de bord
        await pilote_auto.attendre_selecteur('.dashboard-container', delai_expiration=15)
        
        # Vérifie le titre de la page pour confirmer la connexion
        titre_page = await pilote_auto.executer_script_javascript('document.title')
        assert 'Tableau de bord' in titre_page, f"Titre inattendu : {titre_page}"
        
        # Prend une capture d'écran après une connexion réussie
        await capturer_ecran(pilote_auto.client_cdp, '/tmp/connexion_reussie.png')
        
        print("✓ Le test du flux de connexion a été exécuté avec succès.")
    
    finally:
        await pilote_auto.arreter_application()

asyncio.run(tester_flux_connexion())

Pièges courants et solutions

Problème 1 : Gestion des multiples fenêtres (BrowserWindow)

Problème : Une application Electron peut avoir plusieurs BrowserWindow. Comment savoir à quelle fenêtre le client CDP est connecté ?

Solution :


┌──────────────────────────────────────────────────────────┐
│  1. Récupérer toutes les cibles via /json/list           │
│  2. Sélectionner la fenêtre correcte en fonction de son  │
│     URL ou de son titre                                  │
│  3. Si une nouvelle fenêtre apparaît, une reconnexion à  │
│     la nouvelle cible sera nécessaire                    │
│                                                          │
│  # Liste toutes les fenêtres disponibles                │
│  curl http://127.0.0.1:9222/json/list                    │
│                                                          │
│  # Exemple de réponse :                                  │
│  [                                                       │
│    {"type":"page", "url":"file:///index.html",           │
│     "title":"Fenêtre Principale", "id":"abc123"},        │
│    {"type":"page", "url":"file:///settings.html",        │
│     "title":"Paramètres", "id":"def456"},                │
│    {"type":"background_page", "url":"...",               │
│     "title":"Service Worker", "id":"ghi789"}             │
│  ]                                                       │
└──────────────────────────────────────────────────────────┘

Problème 2 : Restrictions de sécurité dues à l'intégration Node.js d'Electron

Problème : Certaines applications Electron désactivent des fonctionnalités dans webPreferences, empêchant Runtime.evaluate d'accéder aux API Node.js.

Solution : Exécuter les opérations Node.js via le canal IPC du processus principal.


async def executer_code_node_via_ipc(client_cdp: ClientCDP, code_node: str):
    """
    Exécute du code Node.js dans le processus principal d'Electron via IPC.
    Nécessite une écoute IPC configurée dans le processus principal d'Electron.
    """
    # Envoie un message IPC depuis le processus de rendu
    resultat_ipc = await client_cdp.envoyer_commande("Runtime.evaluate", {
        "expression": f"""
            (function() {{
                const {{ ipcRenderer }} = require('electron');
                return ipcRenderer.invoke('electron-auto-action', `{code_node}`);
            }})()
        """,
        "awaitPromise": True, # Attend la résolution de la promesse renvoyée par ipcRenderer.invoke
        "returnByValue": True
    })
    # Le processus principal d'Electron devrait avoir un handler :
    # ipcMain.handle('electron-auto-action', async (event, code) => {
    #   return eval(code); // Attention aux implications de sécurité de eval()
    # });
    return resultat_ipc['result']['result'].get('value')

Problème 3 : Shadow DOM et Web Components

Problème : querySelectorAll ne peut pas pénétrer le Shadow DOM.

Solution : Utiliser des combinateurs profonds ou des requêtes récursives.


import json

async def interroger_shadow_dom(client_cdp: ClientCDP, chaine_selecteurs: list) -> dict:
    """
    Permet de requêter des éléments à travers des Shadow DOM imbriqués.
    chaine_selecteurs: Liste de sélecteurs, ex: ['#element-hote', 'shadow::partie-nom', '#element-interne']
    Les sélecteurs 'shadow::' indiquent l'entrée dans un Shadow Root.
    """
    code_js = """
    (function() {
        const selecteurs_a_parcourir = MARQUEUR_SELECTEURS_DYNAMIC;
        let element_courant = document;
        
        for (let i = 0; i < selecteurs_a_parcourir.length; i++) {
            const selecteur_partiel = selecteurs_a_parcourir[i];
            if (selecteur_partiel.startsWith('shadow::')) {
                // Entrée dans le Shadow Root
                const nom_partie_ombre = selecteur_partiel.replace('shadow::', '');
                const element_hote = element_courant.querySelector(nom_partie_ombre);
                if (!element_hote || !element_hote.shadowRoot) return null;
                element_courant = element_hote.shadowRoot;
            } else {
                element_courant = element_courant.querySelector(selecteur_partiel);
                if (!element_courant) return null;
            }
        }
        return element_courant;
    })()
    """.replace("MARQUEUR_SELECTEURS_DYNAMIC", json.dumps(chaine_selecteurs))
    
    resultat_eval = await client_cdp.envoyer_commande("Runtime.evaluate", {
        "expression": code_js
    })
    return resultat_eval

Problème 4 : Fenêtres de dialogue d'upload de fichiers

Problème : Cliquer sur un bouton d'upload ouvre une fenêtre de sélection de fichier native du système d'exploitation, que CDP ne peut pas contrôler.

Solution : Utiliser DOM.setFileInputFiles pour définir directement les fichiers.


async def telecharger_fichiers(client_cdp: ClientCDP, selecteur_input: str, chemins_fichiers: list):
    """
    Contourne le dialogue système de sélection de fichiers en définissant directement
    les fichiers pour un élément input de type 'file'.
    """
    # Localise l'élément input[type=file]
    element_input_file = await trouver_element_par_selecteur(client_cdp, selecteur_input)
    
    # Définit les chemins des fichiers via CDP (chemins absolus)
    await client_cdp.envoyer_commande("DOM.setFileInputFiles", {
        "nodeId": element_input_file['nodeId'],
        "files": chemins_fichiers # Liste de chemins absolus
    })

# Exemple d'utilisation
await telecharger_fichiers(client_cdp, 'input[type="file"]', [
    '/home/utilisateur/documents/rapport.pdf',
    '/home/utilisateur/images/capture.png'
])

Problème 5 : Déconnexions WebSocket et délais d'attente


class ErreurConnexionCDP(Exception):
    """Exception personnalisée pour les problèmes de connexion CDP."""
    pass

class ClientCDPResilient(ClientCDP):
    """Client CDP avec mécanisme de reconnexion automatique."""
    
    def __init__(self, url_connexion: str, max_tentatives: int = 3):
        super().__init__(url_connexion)
        self.max_tentatives = max_tentatives
        self._est_connecte = False
    
    async def etablir_connexion(self):
        """Établit la connexion et met à jour l'état de connexion."""
        await super().etablir_connexion()
        self._est_connecte = True

    async def envoyer_commande(self, methode: str, parametres: dict = None) -> dict:
        """Envoie une commande avec un mécanisme de réessai en cas de déconnexion."""
        for tentative in range(self.max_tentatives):
            try:
                if not self._est_connecte:
                    print(f"Tentative de reconnexion au CDP ({tentative + 1}/{self.max_tentatives})...")
                    await self.etablir_connexion()
                
                return await asyncio.wait_for(
                    super().envoyer_commande(methode, parametres),
                    timeout=30.0 # Délai d'attente pour la réponse à la commande
                )
            except (websockets.ConnectionClosedOK, websockets.ConnectionClosedError, asyncio.TimeoutError) as e:
                self._est_connecte = False
                if tentative < self.max_tentatives - 1:
                    attente = 2 ** tentative # Rétroaction exponentielle
                    print(f"Connexion CDP perdue ou délai dépassé. Réessai dans {attente}s...")
                    await asyncio.sleep(attente)
                else:
                    raise ErreurConnexionCDP(
                        f"Échec de la commande après {self.max_tentatives} tentatives : {e}"
                    )

Conseils pour l'optimisation des performances


Liste de vérification pour l'optimisation des performances de l'automatisation CDP :
┌───────────────────────────────────────────────────────────────────┐
│  1. Réduire les allers-retours (Round-trip)                        │
│     · Regrouper les commandes : Utiliser Runtime.evaluate pour     │
│       exécuter plusieurs opérations en une seule fois.           │
│     · Éviter les requêtes DOM individuelles : Récupérer toutes     │
│       les données nécessaires en un seul appel JS.               │
│                                                                   │
│  2. Éviter les requêtes DOM fréquentes                            │
│     · Mettre en cache les 'nodeId', mais rafraîchir en cas de    │
│       modifications du DOM.                                      │
│     · Pour les éléments stables, préférer XPath aux sélecteurs   │
│       CSS (potentiellement plus rapide).                         │
│                                                                   │
│  3. Optimiser les captures d'écran                                │
│     · Utiliser le format JPEG (3 à 5 fois plus rapide que PNG).   │
│     · Ne capturer l'écran entier que lorsque c'est indispensable,│
│       sinon se limiter aux zones d'intérêt.                      │
│     · Réduire la résolution des captures                        │
│       (Emulation.setDeviceMetricsOverride).                      │
│                                                                   │
│  4. Parallélisation des opérations                                │
│     · Plusieurs BrowserWindows peuvent être opérées en parallèle. │
│     · Les cas de test indépendants peuvent être exécutés en       │
│       parallèle.                                                 │
│                                                                   │
│  5. Éviter les attentes inutiles                                  │
│     · Préférer waitForSelector aux délais fixes (sleep).         │
│     · Utiliser Page.loadEventFired au lieu d'attentes statiques. │
└───────────────────────────────────────────────────────────────────┘

Le protocole CDP offre un "canal officiel" pour l'automatisation des applications Electron. Il fonctionne sans dépendre des coordonnées d'écran ni de frameworks tiers, communiquant directement avec le moteur Chromium. Le seul "coût" est un niveau d'API plus bas, nécessitant l'encapsulation de méthodes d'aide. Cependant, pour les scénarios exigeant une grande stabilité et un contrôle approfondi (tels que les tests d'automatisation CI/CD ou l'automatisation des processus robotiques - RPA), cette approche reste la solution la plus fiable à ce jour.

Dans un projet réel, il est recommandé d'encapsuler le code présenté dans un SDK interne. Ainsi, les équipes se concentrent sur des actions de haut niveau (comme "cliquer sur ce bouton", "saisir ce texte", "vérifier ce résultat"), tandis que les détails de la communication CDP restent transparents pour l'utilisateur. Cela garantit à la fois la stabilité de l'automatisation et réduit la courbe d'apprentissage pour les membres de l'équipe.

Étiquettes: Electron CDP ChromeDevToolsProtocol automatisation Python

Publié le 26 octobre à 19h43