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.