e2e-test

Par divinevideo · divine-mobile

Exécute et débogue des tests d'intégration E2E Flutter qui testent l'application réelle contre un backend Docker local (sans mocks). À utiliser pour exécuter des tests E2E, déboguer des échecs ou travailler sur le harness local.

npx skills add https://github.com/divinevideo/divine-mobile --skill e2e-test

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.dartlaunchAppGuarded, suppression d'erreur, async-error drain
  • navigation_helpers.dart — enregistrer, login, tap tabs, attendre les widgets
  • relay_helpers.dart — publier/requêter des événements Nostr
  • db_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.

Skills similaires