Test d'intégration E2E
Objectif : exécuter l'app réelle contre un vrai backend local, de bout en bout.
OAuth, les subscriptions relay, et les uploads de médias frappent tous des services Docker locaux — aucun mock nulle part. Les tests vivent dans
mobile/integration_test/, le backend dans local_stack/.
Lancer un test
Deux terminaux, depuis mobile/ :
# Terminal 1 — émulateur
mise run emulator
# Terminal 2 — tests
mise run e2e_test # Tous les tests auth
mise run e2e_test integration_test/auth/auth_journey_test.dart # Un seul test
e2e_test démarre la stack Docker, exécute patrol, capture une
timeline fusionnée docker+logcat+app dans test_reports/*.jsonl, et
affiche le chemin du XML de test natif + des extraits d'erreurs quand l'APK
échoue à s'installer. N'appelle jamais patrol test directement — tu perdrais
la timeline et les diagnostics.
Stack
| Service | Port | Objectif |
|---|---|---|
| Keycast | 43000 | OAuth + NIP-46 signer |
| FunnelCake Relay | 47777 | Nostr relay (WebSocket) |
| FunnelCake API | 47777 | REST API, sous /api/ sur le même proxy |
| Blossom | 43003 | Serveur de médias |
| Postgres | 15432 | DB Keycast |
| Invite | 43004 | divine-invite-darshan (Viceroy) |
L'app les atteint à 10.0.2.2 depuis l'émulateur. Le cleartext vers
les hosts loopback est autorisé dans tous les types de build sur les deux platforms.
Ne démarre que ce que ton flux a besoin
La plupart des services sont hors de propos pour un test donné, et un échec de local_up
sur l'un d'eux ne signifie pas que tu es bloqué. Un flux invite-only a besoin de invite seul : une nsec générée localement signe sur l'appareil, donc
pas de Keycast, et onboarding_mode=open signifie pas de porte d'invitation. Vérifie
ce qui est vraiment healthy avant de déboguer un service que tu n'appelles jamais :
docker compose -f local_stack/docker-compose.yml ps
mise run local_up # Démarrer (exécute automatiquement local_setup sur les worktrees neufs)
mise run local_up_cached # Pareil, mais réutilise les images en cache (hors ligne / rate-limited)
mise run local_down # Arrêter
mise run local_reset # Effacer les données + redémarrer
mise run local_status # Santé
Si local_up échoue seulement à e2e-seed et que les services dont ton test a vraiment besoin sont healthy (les tests auth n'ont pas besoin de l'indexer),
contourne le seed :
bash ../local_stack/profile.sh integration_test/<ton_test>.dart
N'importe quel échec de local_up affiche le statut par service, les logs de
ce qui est down, et cette même commande de contournement. Définis E2E_TEST_PATH
avant la run et il affiche la commande pour ton test :
E2E_TEST_PATH=integration_test/auth/auth_journey_test.dart mise run local_up
Conflits de ports
up.sh pré-test chaque port hôte dans docker-compose.yml avant
de démarrer quoi que ce soit. Cette machine exécute plusieurs projets compose, et
des conteneurs de test obsolètes vieux de jours sont le cas normal, donc les collisions
sont routinières. Le check nomme le service, le port, et le détenteur :
port 43000 wanted by service "keycast"
held by container "funnelcake-test-clickhouse-sim" — compose project "funnelcake-test"
remedy: docker rm -f funnelcake-test-clickhouse-sim
port 45173 wanted by service "keycast"
held by a host process (not a container), listening on: 127.0.0.1:45173
find it: sudo lsof -nP -iTCP:45173 -sTCP:LISTEN
Les ports déjà publiés par nos propres conteneurs ne sont pas des conflits —
up.sh est idempotent. L'erreur daemon brute qu'il remplace (Bind for 0.0.0.0:16380 failed: port is already allocated) ne nommait ni le
service ni le détenteur.
Les sockets listening proviennent de ss sur Linux et lsof sur macOS, et
la ligne find it: nomme lequel des deux la machine a. Sans aucun installé la run le dit
et retombe aux ports tenus par conteneurs seuls, que docker ps rapporte sans aucun des deux outils — et
un conteneur obsolète est le coupable habituel de toute façon.
bash local_stack/test_stack_scripts.sh couvre ces chemins contre un
docker/ss/lsof stubé, donc il n'a besoin ni du daemon ni de ports libres.
Startup races
Les conteneurs démarrent parfois avant que le DNS embarqué de Docker connaisse
l'alias d'une dépendance : funnelcake-migrate meurt avec dial tcp: lookup funnelcake-clickhouse on 127.0.0.11:53: no such host, ou keycast
brûle ses tentatives de connexion DB sur Temporary failure in name resolution. Les deux réussissent sur une retry inchangée. up.sh réexécute
le up entier (idempotent — il redémarre ce qui est mort) jusqu'à 3 tentatives,
5s d'écart, uniquement quand il voit une signature de résolution de nom dans la
sortie compose ou dans les logs des conteneurs échoués. Un clash de port ou une
mauvaise image échouent directement plutôt que retry inutilement.
Exécuter un backend construit localement
Compose tire ghcr.io/divinevideo/divine-invite-darshan:e2e. Pour
tester une branche backend non fusionnée, la construis et la tagge avec ce nom pour que
compose utilise l'image locale sans pull :
docker build -f Dockerfile.local -t divine-invite-darshan:local .
docker tag divine-invite-darshan:local ghcr.io/divinevideo/divine-invite-darshan:e2e
invite est l'un des rares services sans pull_policy: always, donc
un simple mise run local_up garde ton tag. Utilise local_up_cached si
tu as surchargé un service qui fait pull à chaque démarrage.
Piège cross-repo : kv-store-data.json
Le service invite (divine-invite-darshan) lit un kv-store-data.json gitignored.
Un worktree frais de ce repo ne l'a pas, et sans lui la suite entière du service d'invitation échoue. Seed-le :
printf '{}' > kv-store-data.json
Émulateur
mise run emulator # Lancement normal (auto-détecte DISPLAY)
mise run emulator_headless # Offscreen, pas de fenêtre
mise run emulator_wipe # -wipe-data (stockage épuisé)
Override AVD : AVD_NAME=<nom> mise run emulator. Utilise toujours
-gpu host — swiftshader ne peut pas rendre les frames media_kit.
Saute la réinstall par run avec PATROL_NO_UNINSTALL=true mise run e2e_test ... quand tu itères vite et que l'APK n'a pas changé.
Le coût du débogage d'état obsolète est le tien.
Buffer les logs de flux auth : adb logcat -G 16M (par défaut 256 KB tourne
en plein flux).
Épuisement du stockage
Pas seulement un problème de Patrol — flutter run le rencontre aussi, et l'erreur
est à l'install, pas à la build :
java.io.IOException: Requested internal only, but not enough space
Un APK debug est ~289 MB et a besoin de vraie marge au-dessus de ça.
adb shell pm trim-caches 1G libère souvent pas assez ; mise run emulator_wipe (emulator.sh --wipe) est généralement le fix plus rapide.
Patterns
Lancer l'app
pumpAndSettle plante à cause de timers polling persistants. Utilise
launchAppGuarded (depuis test_setup.dart) avec suppression d'erreur
et une boucle pump manuelle :
final originalOnError = suppressSetStateErrors();
final originalErrorBuilder = saveErrorWidgetBuilder();
launchAppGuarded(app.main);
for (var i = 0; i < 60; i++) {
await tester.pump(const Duration(milliseconds: 250));
if (find.text('Welcome').evaluate().isNotEmpty) break;
}
restoreErrorWidgetBuilder(originalErrorBuilder);
restoreErrorHandler(originalOnError);
drainAsyncErrors(tester);
Async publish → relay query
L'UI navigue avant que publish/upload se termine. Poll le relay :
for (var i = 0; i < 120; i++) {
await tester.pump(const Duration(milliseconds: 500));
events = await queryRelay(filter);
if (events.isNotEmpty) break;
}
Onboarding sheets bloquant l'UI
Les nouvelles bottom sheets peuvent couvrir le widget cible :
for (var i = 0; i < 20; i++) {
await tester.pump(const Duration(milliseconds: 250));
final gotIt = find.text('Got it!');
if (gotIt.evaluate().isNotEmpty) {
await tester.tap(gotIt);
break;
}
}
Faux positifs de Patrol
Patrol bundle chaque fichier d'un répertoire cible dans un APK. Quand le fichier B
s'exécute, le fichier A s'affiche comme markers "not requested" [E] dans logcat.
Fais confiance seulement aux lignes finales ✅/❌.
Liaison d'URL NIP-98
Le service invite rejette une requête signée dont le tag u ne
correspond pas à l'URL que le serveur a vu : auth_invalid_binding, HTTP 401. Le
client doit signer la même URL de base qu'il appelle. Signer
http://10.0.2.2:43004 en appelant http://localhost:43004 échoue ;
signer et appeler le même host fonctionne. Si tu bascules l'émulateur
entre 10.0.2.2 et un localhost forwardé, bascule l'URL de base de signing avec.
Caching d'erreur Provider
Les Providers utilisant requireIdentity (ou des getters non-nullable similaires)
plantent au cold start et Riverpod cache l'erreur pour toujours. Utilise
l'accessor nullable (currentIdentity) et handle null.
Ancestor Material
TextField dans une overlay/transition sans Scaffold a besoin de :
Material(color: Colors.transparent, child: TextField(...))
Helpers
integration_test/helpers/ :
test_setup.dart—launchAppGuarded, suppression d'erreur, async-error drainnavigation_helpers.dart— enregistrer, login, tap tabs, attendre les widgetsrelay_helpers.dart— publier/requêter des événements Nostrdb_helpers.dart— Postgres (tokens de vérification, refresh tokens)http_helpers.dart— Keycast API (vérifier email, mot de passe oublié)constants.dart— ports +appPackage
Débogage
Ne pipe jamais une commande longue via tail/head
flutter run ... | tail -40 # FAUX
flutter run ... > /tmp/run.log 2>&1 # puis lis/grep le fichier
Deux défaillances séparées. Le pipe bufferise jusqu'à ce que la commande se termine, donc
tu regardes un écran blanc et perds tout si tu le tues. Et le statut de sortie du
pipeline est celui de tail, donc une run échouée rapporte le succès.
Redirige vers un fichier et lis-le à la place.
# Logs de service
docker compose -f local_stack/docker-compose.yml logs keycast --tail=50
docker compose -f local_stack/docker-compose.yml logs blossom | grep -v 'path=/'
# Auth trace
adb logcat -d | grep 'flutter.*\[AUTH\]' | grep -v 'Router redirect'
# Dernière timeline fusionnée
ls mobile/test_reports/*.jsonl
La timeline est où les défaillances cross-service montrent vraiment. Un test peut échouer en teardown d'une erreur async non-traitée contre un service qui est down — le résumé de Patrol dit seulement que le test a échoué, tandis que la timeline nomme l'URL qui a été refusée. Lis-la avant d'écrire une défaillance comme flaky.
rg -o '.{0,60}logout.{0,50}' mobile/test_reports/<run>.jsonl
Si patrol rapporte Total: 0 avec Gradle exit 1, le runner
auto-affiche le chemin du XML de test natif + des extraits de défaillance — c'est
une défaillance d'install APK, pas un test manquant. Libère l'espace avec
adb shell pm trim-caches 1G ou mise run emulator_wipe.