Tester une application mobile de A à Z — crashes, UI, accessibilité, performance, résilience réseau — prend des heures en mode manuel. J'ai construit un système autonome qui le fait seul, en 15 à 25 minutes, sur Android et iOS simultanément, sans intervention humaine.
>Le problème
Sur une app Flutter de gestion immobilière, chaque release mobilisait 2 à 3 heures de tests manuels sur deux plateformes. Les régressions passaient au travers. Les bugs d'accessibilité n'étaient jamais détectés. Les rapports étaient informels et non reproductibles.
Il fallait un système reproductible, exhaustif, qui tourne sans intervention humaine et qui produise des rapports exploitables.
>La solution : un swarm de 6 agents IA
Le système repose sur Ruflo v3.5 (orchestrateur multi-agents) et Claude Code (exécution + vision IA). À chaque session, 6 agents spécialisés sont spawned simultanément avec une topologie hiérarchique anti-drift :
- ▸QA Lead — coordination globale et ordonnancement des phases
- ▸Android Tester — contrôle l'émulateur Pixel 9 Pro XL via ADB
- ▸iOS Tester — contrôle le simulateur iPhone 17 via idb
- ▸Bug Analyst — déduplication et priorisation des bugs
- ▸UX Reviewer — annotation des screenshots
- ▸Report Writer — génération des 3 formats de rapport
NOTEAndroid et iOS tournent en parallèle, jamais séquentiellement. Le swarm maintient un contexte partagé en mémoire HNSW pour que chaque agent accède aux données des autres en temps réel.
>Phase 0 — Initialisation du swarm
En un seul message, le swarm est initialisé, le contexte de session stocké en mémoire partagée, et les 6 agents spawned. La règle absolue : tout s'exécute simultanément dans un seul appel.
// Init swarm hiérarchique
ruflo.swarm_init({
topology: "hierarchical",
maxAgents: 6,
strategy: "specialized",
name: "mobile-qa-swarm",
});
// Spawn des 6 agents simultanément
ruflo.agent_spawn({ type: "coordinator", name: "QA Lead" });
ruflo.agent_spawn({ type: "tester", name: "Android Tester" });
ruflo.agent_spawn({ type: "tester", name: "iOS Tester" });
ruflo.agent_spawn({ type: "analyst", name: "Bug Analyst" });
ruflo.agent_spawn({ type: "reviewer", name: "UX Reviewer" });
ruflo.agent_spawn({ type: "architect", name: "Report Writer" });>Phase 1 — Démarrage des appareils
Les deux agents testeurs démarrent leurs appareils en parallèle. Si un appareil échoue à booter, l'erreur est capturée en mémoire et la session continue sur la plateforme disponible — aucun blocage.
# Android — attendre le boot complet avant de continuer
adb wait-for-device
until [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; do
sleep 3
done
adb shell settings put secure accessibility_enabled 1
# iOS — UDID explicite, plus fiable que 'booted'
xcrun simctl boot {DEVICE_UDID}
xcrun simctl bootstatus {DEVICE_UDID} -b
open -a Simulator⚠ BREAKINGSur iOS 26 bêta, certaines commandes xcrun simctlpeuvent échouer silencieusement. Toujours capturer stderr et stocker l'erreur en mémoire partagée avant de continuer.
>Phase 2 — Analyse statique du code source
Avant même de lancer l'app, Claude Code analyse le code source Flutter avec ses outils natifs (Grep, Glob, Read). L'objectif : détecter les bugs structurels sans exécuter une seule ligne de code.
Patterns recherchés dans l'ensemble du codebase Flutter/Dart :
- ▸Appels Dio sans
try/catchexplicite - ▸
setState()appelé sans vérificationif (!mounted) return - ▸
print()laissés en production (à remplacer pardebugPrint) - ▸Timers sans
.cancel()dansdispose() - ▸Formulaires sans
validator:sur lesTextFormField - ▸Strings hardcodées non passées par le système i18n
- ▸WebSockets non fermés dans
dispose()
Le résultat est un JSON structuré avec chaque bug statique référencé par fichier et numéro de ligne, stocké immédiatement en mémoire partagée.
{
"framework": "flutter",
"static_bugs": [
{
"id": "STATIC-001",
"severity": "high",
"type": "missing_error_handling",
"file": "lib/core/services/payment_repository.dart",
"line": 44,
"description": "Appel Dio sans gestion d'erreur explicite"
}
],
"risk_areas": ["paiement", "inspection_signature", "bail_creation"]
}>Phase 3 — Exploration autonome (boucle vision IA)
C'est le cœur du système. Chaque agent tourne une boucle de 20 actions maximum par plateforme, en 4 étapes répétées jusqu'à épuisement du budget ou action done.
Étape A — Capture d'état
# Android — screenshot + arbre UI filtré
adb exec-out screencap -p > /tmp/screen_android_{N}.png
adb shell uiautomator dump /sdcard/ui.xml
adb pull /sdcard/ui.xml /tmp/ui_android_{N}.xml
# Filtrer à 8 000 caractères max pour contrôler les tokens Claude
python3 -c "
import xml.etree.ElementTree as ET
tree = ET.parse('/tmp/ui_android_{N}.xml')
nodes = [n for n in tree.iter()
if n.get('clickable') == 'true' or n.get('focusable') == 'true']
print('\n'.join([ET.tostring(n, encoding='unicode') for n in nodes[:60]])[:8000])
"
# iOS — idb obligatoire (xcrun simctl io ne supporte pas l'arbre UI)
xcrun simctl io {DEVICE_UDID} screenshot /tmp/screen_ios_{N}.png
idb ui describe-all --udid {DEVICE_UDID} 2>/dev/null | python3 -c "
import sys, json
data = json.load(sys.stdin)
print(json.dumps(data.get('elements', [])[:50], indent=2))
"Étape B — Analyse Claude Vision
Le screenshot et l'arbre UI tronqué sont envoyés à Claude avec un prompt strict. Le modèle retourne un JSON valide avec les bugs détectés et la prochaine action recommandée.
{
"screen_name": "RentalsScreen",
"bugs": [{
"id": "ANDROID-007",
"severity": "high",
"type": "truncation",
"title": "Montant FCFA tronqué sur petits écrans",
"description": "Le montant '1 500 000 FCFA' est coupé à '1 500 0...' dans la liste",
"reproduction": "Naviguer vers Baux > liste des baux actifs",
"location": { "x": 320, "y": 445, "element": "ListTile montant" }
}],
"next_action": {
"type": "tap",
"x": 540,
"y": 892,
"reasoning": "Explorer le détail du bail pour vérifier l'affichage complet"
}
}NOTELes critères de bug sont définis dans le prompt système : chevauchements UI, contraste insuffisant (<4.5:1), boutons trop petits (<44pt iOS / <48dp Android), labels d'accessibilité manquants, spinners infinis.
Étape C — Exécution de l'action
Sur iOS, xcrun simctl ui ne supporte pas tap/swipe sur Xcode 15+. idb (Facebook iOS Dev Bridge) est indispensable — à démarrer une seule fois avant la session.
# Démarrer idb_companion une seule fois
idb_companion --udid {DEVICE_UDID} --grpc-port 10882 &
idb connect 127.0.0.1 10882
# TAP
adb shell input tap {x} {y} # Android
idb ui tap --udid {DEVICE_UDID} {x} {y} # iOS
# SWIPE
adb shell input swipe {x1} {y1} {x2} {y2} 300 # Android
idb ui swipe --udid {DEVICE_UDID} {x1} {y1} {x2} {y2} # iOS
# SAISIE TEXTE
adb shell input text '{text}' # Android
idb ui text --udid {DEVICE_UDID} '{text}' # iOS>Phase 4 — Scénarios de test guidés
8 scénarios critiques sont exécutés sur les deux plateformes en parallèle. Le Bug Analyst vérifie chaque assertion par analyse visuelle du screenshot et retourne un résultat JSON structuré.
| # | Scénario | Focus |
|---|---|---|
| 1 | Connexion valide / invalide | Validation formulaire, absence d'erreurs 429 |
| 2 | Navigation complète (drawer) | Tous les écrans accessibles, aucun crash |
| 3 | Création d'un contrat (wizard) | Formulaire multi-étapes, validation numérique FCFA |
| 4 | Résilience réseau | Mode avion → reprise WebSocket automatique |
| 5 | Switch de rôle | Isolation des données entre espaces utilisateur |
| 6 | Accessibilité + grande police | TalkBack / VoiceOver, texte au maximum |
| 7 | Changement de langue FR ↔ EN | Aucune chaîne non traduite acceptable |
| 8 | Dashboard sans rate limiting | 3 allers-retours en < 30 s, aucun HTTP 429 |
>Phase 5 — Déduplication des bugs
Le Bug Analyst récupère tous les bugs depuis la mémoire partagée et applique ces règles dans l'ordre :
- ▸Même bug Android + iOS → un seul bug avec
platforms: ["android", "ios"] - ▸Bug cross-platform → sévérité escaladée d'un niveau (medium → high, high → critical)
- ▸Même type sur plusieurs écrans → regroupé avec
affected_screens: [...] - ▸Bug statique confirmé dynamiquement → entrées fusionnées
>Phase 6 — Annotation des screenshots
Chaque screenshot de bug est annoté automatiquement avec un rectangle rouge et un badge ID via Pillow (Python), puis sauvegardé dansqa-reports/screenshots/.
from PIL import Image, ImageDraw
def annotate_screenshot(img_path: str, bug: dict) -> str:
img = Image.open(img_path)
draw = ImageDraw.Draw(img)
x, y = bug["location"]["x"], bug["location"]["y"]
draw.rectangle([x - 5, y - 5, x + 120, y + 50], outline="red", width=3)
draw.rectangle([x - 5, y - 28, x + 90, y - 5], fill="red")
draw.text((x + 2, y - 24), bug["id"], fill="white")
output = img_path.replace(".png", f"_bug_{bug['id']}.png")
img.save(output, format="PNG")
return output>Phase 7 — Rapports en 3 formats
- ▸rapport-qa.md — Markdown lisible pour l'équipe et les PR comments GitHub
- ▸rapport-qa.json — JSON structuré pour les pipelines CI/CD et dashboards
- ▸alerte-slack.json — Payload webhook Slack envoyé automatiquement si des bugs critiques sont détectés
# Envoi automatique si bugs critiques détectés
curl -X POST -H 'Content-type: application/json' \
--data @./qa-reports/alerte-slack.json \
"$SLACK_WEBHOOK_URL">Intégration CI/CD — GitHub Actions
Le prompt peut être lancé automatiquement à chaque PR ou sur cron, sans émulateur physique — l'émulateur Android est géré parreactivecircus/android-emulator-runner.
name: Mobile QA Autonome
on:
pull_request:
branches: [main, develop]
schedule:
- cron: '0 2 * * 1-5' # Lundi–vendredi à 2h
jobs:
qa-audit:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Start Android Emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 35
arch: x86_64
- name: Run QA Agent
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
claude --dangerously-skip-permissions \
"Exécute le plan QA mobile. App : ${{ vars.APP_BUNDLE_ID }}.
Rapports dans ./qa-reports/."
- name: Post report on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const report = require('fs').readFileSync('./qa-reports/rapport-qa.md', 'utf8')
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: report
})>Optimisation des coûts API
- ▸Triage Haiku — premier OUI/NON sur chaque screenshot avec
claude-haiku-4-5-20251001, puis Sonnet uniquement si un bug est probable. Économie ~60%. - ▸Prompt caching — le system prompt est identique pour tous les appels d'analyse. Ajouter
cache_control: ephemeralsur le system block. Économie ~40%. - ▸Budget réduit — 20 actions au lieu de 50 pour les sessions de régression rapide avant release.
| Mode | Durée | Coût API |
|---|---|---|
| Audit complet (Android + iOS) | 15–25 min | $0.40–$0.80 |
| Android uniquement | 8–12 min | $0.15–$0.35 |
| Flow spécifique | 4–7 min | $0.05–$0.15 |
| Régression rapide | 6–10 min | $0.10–$0.25 |
>Résultats observés
Sur 5 sessions de test, le système a réduit le temps de QA de 2 à 3 heures à 15–25 minutes, avec une couverture supérieure. Les bugs les plus fréquemment détectés automatiquement :
- ▸Montants monétaires tronqués dans les listes (formatage spécifique à la locale)
- ▸Spinners infinis sans timeout sur les endpoints lents
- ▸Labels d'accessibilité manquants sur les FAB et icônes de navigation
- ▸Confusion de navigation dans les structures de drawer complexes (40+ écrans)
- ▸Bugs cross-platform détectés uniquement par la déduplication automatique
Récupérer le prompt complet
Le prompt et le guide README sont disponibles directement — copiez-les ou téléchargez-les pour les utiliser dans Claude Code.
>Stack technique
- ▸Ruflo v3.5 — orchestration swarm, mémoire partagée HNSW, hooks intelligents
- ▸Claude Code + claude-sonnet-4-6 — exécution des agents et vision IA
- ▸ADB — contrôle émulateur Android (Pixel 9 Pro XL)
- ▸idb / idb_companion (Facebook) — contrôle simulateur iOS (
xcrun simctl uine supporte pas tap/swipe sur Xcode 15+) - ▸Pillow (Python) — annotation automatique des screenshots
- ▸Flutter / Dart — framework de l'application cible