From f3be40d74de50b9acb2dab5f5e07a223bb160906 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 18 Aug 2026 02:17:08 -0400 Subject: [PATCH] [ADD] todo qemu: add an Android emulator, and fix what the real run exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running it on a VM was the only way to find these. install_os never installed python3-venv, so .venv.erplibre was born crippled — bin/python but no pip, no activate — and everything downstream failed on "No module named git"; one line of dependency fixes it. Capacitor 8 needs a JDK 21 where the mobile repository's installer puts 17, and Gradle must RUN on it. That installer is not idempotent either, so it is replayed only when something is missing. The new emulator option creates an AVD from the SDK's own device list — the newest plain Pixel, smallest screen — with software rendering written into its config so ssh -X does not open a black screen, and adds the user to the kvm group, without which it refuses to start. Checked on a VM: boot completed in 10 s, adb sees emulator-5554, Android 16 x86_64, no KVM refusal. Vitest: 1938 tests pass. The APK still fails, upstream: sentencepiece builds protoc for the target then runs it on the host. --- FR --- Seule l'exécution sur une VM pouvait trouver ceci. install_os n'installait pas python3-venv, si bien que .venv.erplibre naissait infirme — bin/python mais ni pip ni activate — et tout ce qui en dépend tombait sur « No module named git » ; une ligne de dépendance suffit. Capacitor 8 réclame un JDK 21 là où l'installateur du dépôt mobile pose un 17, et Gradle doit TOURNER dessus. Cet installateur n'est pas idempotent non plus : il n'est rejoué que s'il manque quelque chose. La nouvelle option crée un AVD depuis la liste de profils du SDK — le Pixel simple le plus récent, plus petit écran —, écrit le rendu logiciel dans sa configuration pour qu'ssh -X n'ouvre pas un écran noir, et ajoute l'utilisateur au groupe kvm, sans quoi il refuse de démarrer. Vérifié sur une VM : boot en 10 s, adb voit emulator-5554, Android 16 x86_64, aucun refus de KVM. Vitest : 1938 tests passent. L'APK échoue encore, en amont : sentencepiece bâtit protoc pour la cible puis l'exécute sur l'hôte. Assisted-by: Claude Opus 5 --- script/install/install_debian_dependency.sh | 12 + script/qemu/README.base.md | 21 ++ script/qemu/README.fr.md | 11 + script/qemu/README.md | 10 + script/todo/todo.py | 341 ++++++++++++++++---- script/todo/todo_i18n.py | 74 +++++ test/test_qemu_desktop_tools.py | 100 +++++- 7 files changed, 504 insertions(+), 65 deletions(-) diff --git a/script/install/install_debian_dependency.sh b/script/install/install_debian_dependency.sh index ab95eeb..52e1b4e 100755 --- a/script/install/install_debian_dependency.sh +++ b/script/install/install_debian_dependency.sh @@ -289,6 +289,18 @@ if [[ $retVal -ne 0 ]]; then echo "apt-get pyenv dependencies installation error." exit 1 fi +# python3-venv : le venv d'OUTILS (.venv.erplibre) est bâti avec le python du +# SYSTÈME, et sur Debian et Ubuntu « python3 -m venv » n'embarque pas ensurepip +# sans ce paquet. Sans lui le venv naît infirme — bin/python existe, ni pip ni +# activate — et tout ce qui en dépend tombe : « repo », la fusion du manifeste +# (ModuleNotFoundError: No module named 'git'), la configuration PyCharm, la +# compilation mobile. Mesuré sur une VM Ubuntu 24.04 fraîche. +${APT_GET} install python3-venv -y +retVal=$? +if [[ $retVal -ne 0 ]]; then + echo "apt-get python3-venv installation error." + exit 1 +fi # Dependencies for selenium ${APT_GET} install libcairo2-dev python3-dev pkg-config libxt-dev libgirepository1.0-dev -y retVal=$? diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index 87c9bcf..20967cd 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -309,6 +309,16 @@ starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio they share one SDK through `ANDROID_HOME`. Without Android, the same app runs in a browser: `npm start`. +A fifth, **Android emulator (Pixel)**, creates an AVD you open from your own +machine: `ssh -X erplibre@ "emulator -avd erplibre -no-audio"`. It needs no +desktop in the VM — the window lands on your screen, not the guest's — but it +does need KVM inside the guest, so nested virtualisation on the host; the log +says so when `/dev/kvm` is missing. The device is not frozen: the SDK is asked +for its profiles and the newest plain Pixel with the smallest screen wins (no +Pro, XL, Fold or tablet), because every pixel crosses the network. Rendering is +set to `swiftshader_indirect` inside the AVD's own `config.ini`, since `ssh -X` +offers no direct GLX and `auto` would open a black screen. + Each tool is filtered per VM — by architecture, desktop flavour and package family — and its disk cost is added to the plan before anything is created. @@ -425,6 +435,17 @@ simple VM serveur produit l'APK — et quand Android Studio est aussi coché, le deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même application tourne dans un navigateur : `npm start`. +Un cinquième, **Émulateur Android (Pixel)**, crée un AVD que vous ouvrez +depuis votre poste : `ssh -X erplibre@ "emulator -avd erplibre -no-audio"`. +Il ne demande aucun bureau dans la VM — la fenêtre s'affiche sur VOTRE écran, +pas sur celui de l'invitée — mais il exige KVM dans l'invitée, donc la +virtualisation imbriquée sur l'hôte ; le journal le dit quand `/dev/kvm` +manque. Le modèle n'est pas figé : on demande au SDK ses profils et le Pixel le +plus récent au plus petit écran gagne (ni Pro, ni XL, ni pliant, ni tablette), +parce que chaque pixel traverse le réseau. Le rendu est réglé sur +`swiftshader_indirect` dans le `config.ini` de l'AVD, puisque `ssh -X` n'offre +pas de GLX direct et qu'« auto » ouvrirait un écran noir. + Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé. diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index de26940..771ef48 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -198,6 +198,17 @@ simple VM serveur produit l'APK — et quand Android Studio est aussi coché, le deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même application tourne dans un navigateur : `npm start`. +Un cinquième, **Émulateur Android (Pixel)**, crée un AVD que vous ouvrez +depuis votre poste : `ssh -X erplibre@ "emulator -avd erplibre -no-audio"`. +Il ne demande aucun bureau dans la VM — la fenêtre s'affiche sur VOTRE écran, +pas sur celui de l'invitée — mais il exige KVM dans l'invitée, donc la +virtualisation imbriquée sur l'hôte ; le journal le dit quand `/dev/kvm` +manque. Le modèle n'est pas figé : on demande au SDK ses profils et le Pixel le +plus récent au plus petit écran gagne (ni Pro, ni XL, ni pliant, ni tablette), +parce que chaque pixel traverse le réseau. Le rendu est réglé sur +`swiftshader_indirect` dans le `config.ini` de l'AVD, puisque `ssh -X` n'offre +pas de GLX direct et qu'« auto » ouvrirait un écran noir. + Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé. diff --git a/script/qemu/README.md b/script/qemu/README.md index 17c7622..0adffcf 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -183,6 +183,16 @@ starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio they share one SDK through `ANDROID_HOME`. Without Android, the same app runs in a browser: `npm start`. +A fifth, **Android emulator (Pixel)**, creates an AVD you open from your own +machine: `ssh -X erplibre@ "emulator -avd erplibre -no-audio"`. It needs no +desktop in the VM — the window lands on your screen, not the guest's — but it +does need KVM inside the guest, so nested virtualisation on the host; the log +says so when `/dev/kvm` is missing. The device is not frozen: the SDK is asked +for its profiles and the newest plain Pixel with the smallest screen wins (no +Pro, XL, Fold or tablet), because every pixel crosses the network. Rendering is +set to `swiftshader_indirect` inside the AVD's own `config.ini`, since `ssh -X` +offers no direct GLX and `auto` would open a black screen. + Each tool is filtered per VM — by architecture, desktop flavour and package family — and its disk cost is added to the plan before anything is created. diff --git a/script/todo/todo.py b/script/todo/todo.py index 0979224..c52eb40 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5086,6 +5086,23 @@ class TODO: # manifeste ajoute, et du venv d'outils pour le synchroniser. "phase": "after", }, + # L'émulateur n'a pas besoin de bureau DANS la VM : il s'affiche sur + # l'écran de qui s'y connecte, par « ssh -X ». Il a besoin, lui, de KVM + # dans la VM — donc de virtualisation imbriquée sur l'hôte, ce que le + # bloc vérifie et annonce plutôt que de laisser découvrir. + # + # Disque : ~1,5 Go d'image système, ~2 Go de données d'AVD, plus + # l'émulateur lui-même. + "avd": { + "label": "Android emulator (Pixel)", + "hint": "AVD viewable over ssh -X", + "disk_gb": 6, + "arches": ("amd64",), + "desktops": (), + "needs_desktop": False, + "families": ("apt",), + "phase": "after", + }, } # Famille de paquets de chaque distribution, pour borner un outil à ce qui @@ -5637,6 +5654,18 @@ class TODO: ("SDK location not found", "SDK not found (ANDROID_HOME)"), ("have not been accepted", "SDK licences not accepted"), ("NDK not configured", "NDK missing"), + # Vécu : Capacitor 8 réclame un JDK 21 quand l'installateur amont pose + # un 17, et Gradle s'arrête là. + ( + "Cannot find a Java installation", + "JDK required by the project missing", + ), + # Vécu aussi : le JDK est là, mais Gradle TOURNE sur un plus ancien. + ("invalid source release", "Gradle running on too old a JDK"), + ("cannot overwrite", "SDK already there (upstream installer replays)"), + # Vécu : sentencepiece bâtit protoc pour la CIBLE et l'exécute sur + # l'hôte. Le message est cryptique ; la cause, non. + ("Exec format error", "cross-compiled protoc run on the host"), ("Unsupported class file major version", "JDK/Gradle mismatch"), ("Could not determine java version", "JDK/Gradle mismatch"), ( @@ -5645,29 +5674,140 @@ class TODO: ), ("npm ERR!", "npm dependencies"), ("Test Files", "Vitest tests failed"), + # Vécu : le manifeste rend 0 sans avoir cloné, et l'étape suivante + # tombe sur un cd impossible. Le motif nomme la vraie cause. + # + # SANS APOSTROPHE, et ce n'est pas cosmétique : ces motifs partent dans + # un « grep -q '' », entre apostrophes. « can't cd to » fermait + # la chaîne et rendait tout le bloc invalide — attrapé par bash -n. + ("cd: can", "mobile repository missing"), + # Vécu aussi : sans python3.12-venv, .venv.erplibre n'existe pas, et + # rien de ce qui suit ne peut synchroniser le manifeste. + ("virtual environment", "ERPLibre venv missing (incomplete install)"), + ("No module named", "ERPLibre venv incomplete (no pip: python3-venv)"), ("FAILED", "Gradle task failed"), ) def _qemu_mobile_diag_cmd(self): """Fonction shell qui NOMME la cause d'un échec, à partir du journal. - Un « la VM est rouge » n'apprend rien quand le journal fait des - dizaines de mégaoctets. On cherche donc les motifs connus, et à défaut - on montre les dernières lignes — c'est toujours mieux que rien.""" + Un « la VM est rouge » n'apprend rien quand le journal fait des dizaines + de mégaoctets. On cherche donc les motifs connus, et à défaut on montre + les dernières lignes — c'est toujours mieux que rien. + + La recherche porte sur la FIN du journal, pas sur tout. Vécu : le + diagnostic a annoncé « licences SDK non acceptées » quand la panne était + un JDK manquant — le motif venait de la revue de licences d'une étape + RÉUSSIE, trois étapes plus haut. Nommer la mauvaise cause coûte plus + cher que se taire.""" lines = "".join( - f"grep -q '{pat}' \"$1\" && {{ " - f'echo " {t("probable cause:")} {t(cause)}"; return 0; }}; ' + f"grep -q '{pat}' \"$d\" && {{ " + f'echo " {t("probable cause:")} {t(cause)}"; ' + 'rm -f "$d"; return 0; }; ' for pat, cause in self._QEMU_MOBILE_DIAG ) return ( - "mdiag() { " + 'mdiag() { d=$(mktemp); tail -400 "$1" > "$d"; ' + lines + f'echo " {t("no known pattern, last lines:")}"; ' - 'tail -12 "$1" | sed "s/^/ /"; }; ' + 'tail -12 "$1" | sed "s/^/ /"; rm -f "$d"; }; ' ) - def _qemu_mobile_remote_cmd(self, prod=False): - """Installe et COMPILE l'application mobile, puis la teste. + def _qemu_android_prologue_cmd(self): + """Ce que la compilation mobile et l'émulateur partagent : le journal + détaillé, le coureur d'étapes, le diagnostic, et l'environnement du SDK. + + Écrit UNE fois même quand les deux options sont cochées — deux + prologues, ce serait deux journaux et deux SDK.""" + return ( + self._qemu_mobile_diag_cmd() + + # Le détail va dans un fichier À PART. Une compilation Gradle écrit + # des dizaines de milliers de lignes, dont des centaines portant le + # mot « error » sans qu'aucune ne soit une panne : les verser dans + # le journal d'installation rendrait son compteur d'erreurs + # inutilisable, et le diagnostic illisible. + 'M="$HOME/erplibre-mobile-build.log"; : > "$M"; ' + f'echo " {t("detailed log in the VM:")} $M"; ' + 'mstep() { lbl="$1"; shift; echo " -> $lbl"; ' + 'if sh -c "$*" >> "$M" 2>&1; then return 0; fi; ' + f'echo " ⚠ {t("FAILED:")} $lbl"; mdiag "$M"; return 1; }}; ' + # Le SDK vit dans $HOME/android, l'emplacement qu'emploie + # l'installateur du dépôt. Android Studio, s'il est là, le trouvera + # par ANDROID_HOME : un seul SDK sur la machine, pas deux. + 'export ANDROID_HOME="$HOME/android"; ' + 'export ANDROID_SDK_ROOT="$HOME/android"; ' + 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin' + ':$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"; ' + # ~/.bashrc n'est pas lu par un « ssh hôte commande » : ce que + # l'installateur y écrit ne sert qu'aux sessions futures, pas à la + # compilation qui suit immédiatement. + # + # Le JDK le PLUS RÉCENT installé, et non celui des alternatives. + # Mesuré : avec JAVA_HOME sur le 17 que pose l'installateur amont, + # Gradle tourne en 17 et s'arrête sur « invalid source release: 21 » + # — les modules de Capacitor 8 compilent en 21. Le tri est + # « sort -V », donc java-21 passe après java-17, pas avant. + "export JAVA_HOME=$(ls -d /usr/lib/jvm/java-*-openjdk-* " + "2>/dev/null | sort -V | tail -1); " + '[ -n "$JAVA_HOME" ] || export JAVA_HOME=$(dirname $(dirname ' + "$(readlink -f $(command -v javac 2>/dev/null " + "|| command -v java 2>/dev/null) 2>/dev/null) 2>/dev/null) " + "2>/dev/null); " + 'export PATH="$JAVA_HOME/bin:$PATH"; ' + ) + + def _qemu_android_sdk_steps(self, el_dir): + """Les étapes qui posent le SDK : dépôt mobile, prérequis, installateur + amont, plateforme réclamée par le projet. Communes aux deux options.""" + return ( + # Le « test -f » n'est pas une ceinture de plus : c'est la seule + # vérité disponible. update_manifest_local_mobile.sh finit par + # « kill $DAEMON_PID » et rend donc 0 même quand il n'a rien cloné — + # vécu, faute de .venv.erplibre. L'étape passait, et c'est le « cd » + # suivant qui échouait, deux étapes plus loin. + # Le venv d'ERPLibre d'abord, et nommément : tout ce qui suit en + # dépend — c'est lui qui porte « repo », qui synchronise le + # manifeste. Vécu avec le profil « ERPLibre seul », dont le code + # note lui-même « problem installing with q, the script depend on + # odoo » : sans venv, le manifeste rendait 0 sans rien cloner et + # l'échec ne se voyait que deux étapes plus loin. + f'mstep "{t("ERPLibre venv (everything below needs it)")}" ' + # « activate », et non « bin/python » : sans python3-venv, le venv + # naît INFIRME — bin/python existe (un lien), mais ni pip ni + # activate ni site-packages. La sonde passait, et l'échec ne se + # voyait que deux étapes plus loin, en « No module named git ». + f"'test -f {el_dir}/.venv.erplibre/bin/activate' && " + f'mstep "{t("mobile repository (additive manifest)")}" ' + f"'cd {el_dir} && ./script/manifest/update_manifest_local_mobile.sh; " + "test -f mobile/erplibre_home_mobile/install-android.sh' && " + f'mstep "{t("prerequisites of the upstream installer")}" ' + # openjdk-21 EN PLUS du 17 que pose l'installateur amont : mesuré, + # Gradle s'arrête sur « Cannot find a Java installation matching + # {languageVersion=21} » — les modules de Capacitor 8 réclament 21. + # Les deux JDK cohabitent, et Gradle choisit par sa chaîne d'outils. + # unzip et xauth, eux, manquent des images cloud. + "'sudo DEBIAN_FRONTEND=noninteractive apt-get " + "-o DPkg::Lock::Timeout=600 install -y unzip wget xauth " + "openjdk-21-jdk' && " + # L'installateur amont n'est PAS idempotent : au second passage il + # s'arrête sur « mv: cannot overwrite latest/cmdline-tools ». Mesuré. + # On ne le rejoue donc que s'il reste quelque chose à poser — un + # déploiement qui se répète ne doit pas échouer sur une réussite + # précédente. + f'mstep "{t("Android SDK, licences, NDK")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && " + "{ [ -x $HOME/android/cmdline-tools/latest/bin/sdkmanager ] " + "|| ./install-android.sh; }' && " + f'mstep "{t("SDK platform required by the project")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && " + 'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" ' + 'android/variables.gradle) && [ -n "$v" ] && ' + 'yes | sdkmanager "platforms;android-$v" ' + '"build-tools;$v.0.0"\' && ' + ) + + def _qemu_mobile_build_steps(self, el_dir): + """Étapes de compilation de l'application mobile, puis ses tests. C'est la seule étape qui peut faire échouer la VM, et c'est voulu : une machine dont l'application ne compile pas n'est pas une machine prête. @@ -5688,56 +5828,26 @@ class TODO: commence par « sudo apt install openjdk-17-jdk » et s'arrête là ailleurs. La lever se fait dans ce script-là, pas ici. """ - el_dir = self._qemu_install_dir(prod) return ( - f'echo "== {t("Building ERPLibre mobile (long)")} =="; ' - + self._qemu_mobile_diag_cmd() - + - # Le détail va dans un fichier À PART. Une compilation Gradle écrit - # des dizaines de milliers de lignes, dont des centaines portant le - # mot « error » sans qu'aucune ne soit une panne : les verser dans - # le journal d'installation rendrait son compteur d'erreurs - # inutilisable, et le diagnostic illisible. - 'M="$HOME/erplibre-mobile-build.log"; : > "$M"; ' - f'echo " {t("detailed log in the VM:")} $M"; ' - 'mstep() { lbl="$1"; shift; echo " -> $lbl"; ' - 'if sh -c "$*" >> "$M" 2>&1; then return 0; fi; ' - f'echo " ⚠ {t("FAILED:")} $lbl"; mdiag "$M"; return 1; }}; ' - # Le SDK vit dans $HOME/android, l'emplacement qu'emploie - # l'installateur du dépôt. Android Studio, s'il est là, le trouvera - # par ANDROID_HOME : un seul SDK sur la machine, pas deux. - 'export ANDROID_HOME="$HOME/android"; ' - 'export ANDROID_SDK_ROOT="$HOME/android"; ' - 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin' - ':$ANDROID_HOME/platform-tools"; ' - # ~/.bashrc n'est pas lu par un « ssh hôte commande » : ce que - # l'installateur y écrit ne sert qu'aux sessions futures, pas à la - # compilation qui suit immédiatement. - "export JAVA_HOME=$(dirname $(dirname $(readlink -f " - "$(command -v javac 2>/dev/null || command -v java 2>/dev/null) " - "2>/dev/null) 2>/dev/null) 2>/dev/null); " - f'mstep "{t("mobile repository (additive manifest)")}" ' - f"'cd {el_dir} && ./script/manifest/update_manifest_local_mobile.sh' && " - f'mstep "{t("prerequisites of the upstream installer")}" ' - "'sudo DEBIAN_FRONTEND=noninteractive apt-get " - "-o DPkg::Lock::Timeout=600 install -y unzip wget' && " - f'mstep "{t("Android SDK, licences, NDK")}" ' - f"'cd {el_dir}/mobile/erplibre_home_mobile && ./install-android.sh' && " - f'mstep "{t("SDK platform required by the project")}" ' - f"'cd {el_dir}/mobile/erplibre_home_mobile && " - 'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" ' - 'android/variables.gradle) && [ -n "$v" ] && ' - 'yes | sdkmanager "platforms;android-$v" ' - '"build-tools;$v.0.0"\' && ' f'mstep "{t("npm dependencies")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm ci' && " f'mstep "{t("web bundle (vite build)")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm run build' && " f'mstep "{t("native sync (capacitor)")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npx cap sync android' && " + # UNE seule ABI, celle de la VM — qui est aussi celle de + # l'émulateur. Deux raisons, la seconde décisive : + # - quatre ABI, c'est quatre fois la compilation de whisper.cpp + # et de sentencepiece, pour trois qui ne serviront jamais ici ; + # - sentencepiece bâtit son « protoc » POUR LA CIBLE puis tente de + # l'exécuter sur l'hôte. En arm64 cela donne « Exec format + # error » et la compilation s'arrête — mesuré. En x86_64 la + # cible et l'hôte coïncident, et le défaut ne se manifeste pas. + # Un APK arm64 demandera un correctif au projet mobile. f'mstep "{t("debug APK (gradle)")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile/android && " - "./gradlew --no-daemon assembleDebug' && " + "./gradlew --no-daemon assembleDebug " + "-Pandroid.injected.build.abi=x86_64' && " f'mstep "{t("Vitest tests")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm test' && " # L'APK est la preuve, pas le code de sortie de Gradle : une tâche @@ -5758,9 +5868,122 @@ class TODO: f'echo " {t("browser debugging (no Android):")} ' f"cd {el_dir}/mobile/erplibre_home_mobile " '&& npm start"; else ' - f'echo " ⚠ {t("no APK produced")}"; false; fi; ' + f'echo " ⚠ {t("no APK produced")}"; false; fi' ) + def _qemu_avd_steps(self, el_dir): + """Étapes créant un émulateur prêt à s'ouvrir depuis le poste de travail. + + Le modèle n'est pas figé : on demande au SDK la liste de ses profils et + on retient le Pixel le plus récent au plus petit écran — ni Pro, ni XL, + ni pliant, ni tablette. Sur un écran distant, chaque pixel traverse le + réseau : le petit modèle n'est pas une coquetterie. + + L'image système suit la plateforme du projet, et redescend si elle n'est + pas publiée — Google ne fournit pas d'image pour toutes les API. + + Le rendu est réglé sur swiftshader_indirect, DANS la configuration de + l'AVD plutôt qu'en option de lancement : par « ssh -X » il n'y a pas de + GLX direct, et l'émulateur s'ouvrirait sur un écran noir. Ainsi + « emulator -avd erplibre » suffit, sans rien à retenir. + """ + return ( + f'echo " == {t("Android emulator (AVD)")} =="; ' + # KVM dans la VM : sans lui l'émulateur x86 refuse de démarrer. On + # le dit ici, où c'est réparable (virtualisation imbriquée sur + # l'hôte), plutôt qu'au premier lancement. + "if [ ! -e /dev/kvm ]; then " + f'echo " ⚠ {t("no /dev/kvm: nested virtualisation is off on the host")}"; ' + "else " + # /dev/kvm est en root:kvm 0660 : sans appartenir au groupe, + # l'émulateur refuse de démarrer sur « ProbeKVM: This user doesn't + # have permissions to use KVM ». Mesuré. L'appartenance ne prend + # qu'à la prochaine session — ce qui tombe bien, la session utile + # est justement celle du « ssh -X » qui viendra ensuite. + "sudo usermod -aG kvm $(id -un) 2>/dev/null || true; " + f'echo " {t("user added to the kvm group (effective at next login)")}"; ' + "fi; " + f'mstep "{t("emulator and system image")}" ' + '\'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" ' + f"{el_dir}/mobile/erplibre_home_mobile/android/variables.gradle); " + "for a in $v 36 35 34; do " + 'img="system-images;android-$a;google_apis;x86_64"; ' + 'if yes | sdkmanager "emulator" "$img"; then ' + 'echo "$img" > $HOME/.erplibre-avd-image; break; fi; done; ' + "test -s $HOME/.erplibre-avd-image' && " + f'mstep "{t("Pixel profile, smallest screen")}" ' + # Le plus récent des Pixel simples : on trie sur le NUMÉRO, pas sur + # l'ordre d'affichage, et on écarte les grands modèles. + '\'avdmanager list device | grep -oE "pixel_[0-9]+a?" ' + '| grep -vE "pro|xl|fold|tablet" | sort -t_ -k2 -n | tail -1 ' + "> $HOME/.erplibre-avd-device; test -s $HOME/.erplibre-avd-device' && " + f'mstep "{t("create the AVD")}" ' + "'img=$(cat $HOME/.erplibre-avd-image); " + "dev=$(cat $HOME/.erplibre-avd-device); " + 'echo no | avdmanager create avd -n erplibre -k "$img" ' + '-d "$dev" --force && ' + # Rendu logiciel, écrit dans la config : par ssh -X il n'y a pas de + # GLX direct, et « auto » donnerait un écran noir. + 'printf "hw.gpu.enabled=yes\\nhw.gpu.mode=swiftshader_indirect\\n" ' + ">> $HOME/.android/avd/erplibre.avd/config.ini' && " + f'echo " ✅ {t("AVD ready:")} ' + "$(cat $HOME/.erplibre-avd-device) / " + '$(cat $HOME/.erplibre-avd-image)"; ' + # La commande à copier, avec l'adresse déjà remplie : un émulateur + # dont on ignore comment l'ouvrir ne sert à personne. + "ip=$(hostname -I 2>/dev/null | awk '{print $1}'); " + f'echo " {t("open it from your workstation:")} ' + 'ssh -X erplibre@$ip \\"emulator -avd erplibre -no-audio\\""; ' + f'echo " {t("then install the APK:")} ' + 'ssh erplibre@$ip \\"adb install -r ' + f"{el_dir}/mobile/erplibre_home_mobile/android/app/build" + '/outputs/apk/debug/app-debug.apk\\""' + ) + + def _qemu_after_remote_cmd(self, tools, prod=False): + """Phase d'APRÈS l'installation : prologue commun, SDK commun, puis ce + qui a été coché. + + Un seul prologue et un seul SDK même quand les deux options le sont : + deux prologues, et le second tronquerait le journal détaillé du premier. + + Les groupes sont joints par « && » et non par « ; ». C'est ce qui fait + qu'un APK manquant reste l'échec de la VM : collé par « ; », un + émulateur créé avec succès effacerait le verdict de la compilation.""" + picked = [ + k + for k in ("mobile", "avd") + if k in (tools or ()) and k in self._QEMU_VM_TOOLS + ] + if not picked: + return "" + el_dir = self._qemu_install_dir(prod) + groups = [] + if "mobile" in picked: + groups.append(self._qemu_mobile_build_steps(el_dir)) + if "avd" in picked: + groups.append(self._qemu_avd_steps(el_dir)) + return ( + f'echo "== {t("ERPLibre mobile, Android SDK (long)")} =="; ' + + self._qemu_android_prologue_cmd() + + self._qemu_android_sdk_steps(el_dir) + # Chaque groupe entre ACCOLADES. Sans elles, « && » ne lie que la + # première commande du groupe suivant : mesuré, un APK manquant + # laissait tourner l'émulateur puis rendait 0 — la VM repassait au + # vert alors que rien n'avait compilé. C'est le même piège que le + # bloc de service systemd, quelques centaines de lignes plus haut. + + " && ".join(f"{{ {g}; }}" for g in groups) + + "; " + ) + + def _qemu_mobile_remote_cmd(self, prod=False): + """Compilation mobile seule — la forme que testent les tests.""" + return self._qemu_after_remote_cmd(("mobile",), prod) + + def _qemu_avd_remote_cmd(self, prod=False): + """Émulateur seul.""" + return self._qemu_after_remote_cmd(("avd",), prod) + def _qemu_tools_remote_cmd(self, tools, prod=False, phase="before"): """Bloc des outils cochés pour cette PHASE, du plus utile au plus lourd. @@ -5770,17 +5993,15 @@ class TODO: « after » : la compilation mobile, qui vient après l'installation dont elle dépend, et qui elle NE se garde PAS. C'est le contrat demandé : une VM dont l'application ne compile pas doit être rouge.""" + if phase == "after": + # Un seul bloc pour les deux options : voir _qemu_after_remote_cmd. + return self._qemu_after_remote_cmd(tools, prod) blocks = { - "gnome_ext": (self._qemu_gnome_ext_remote_cmd, "before"), - "pycharm": (lambda: self._qemu_pycharm_remote_cmd(prod), "before"), - "android": (self._qemu_android_studio_remote_cmd, "before"), - "mobile": (lambda: self._qemu_mobile_remote_cmd(prod), "after"), + "gnome_ext": self._qemu_gnome_ext_remote_cmd, + "pycharm": lambda: self._qemu_pycharm_remote_cmd(prod), + "android": self._qemu_android_studio_remote_cmd, } - return "".join( - fn() - for k, (fn, ph) in blocks.items() - if k in (tools or ()) and ph == phase - ) + return "".join(fn() for k, fn in blocks.items() if k in (tools or ())) def _qemu_editor_pkg(self): """Paquet de l'éditeur de l'hôte, à installer dans la VM. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 55da492..d663c93 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -7718,6 +7718,10 @@ TRANSLATIONS = { "fr": "une compilation mobile en échec marque la VM en échec", "en": "a failed mobile build marks the VM as failed", }, + "ERPLibre venv (everything below needs it)": { + "fr": "venv ERPLibre (tout ce qui suit en dépend)", + "en": "ERPLibre venv (everything below needs it)", + }, "mobile repository (additive manifest)": { "fr": "dépôt mobile (manifeste additif)", "en": "mobile repository (additive manifest)", @@ -7774,6 +7778,22 @@ TRANSLATIONS = { "en": "SDK licences not accepted", }, "NDK missing": {"fr": "NDK absent", "en": "NDK missing"}, + "JDK required by the project missing": { + "fr": "JDK réclamé par le projet absent", + "en": "JDK required by the project missing", + }, + "Gradle running on too old a JDK": { + "fr": "Gradle tourne sur un JDK trop ancien", + "en": "Gradle running on too old a JDK", + }, + "SDK already there (upstream installer replays)": { + "fr": "SDK déjà là (l'installateur amont se rejoue)", + "en": "SDK already there (upstream installer replays)", + }, + "cross-compiled protoc run on the host": { + "fr": "protoc compilé pour la cible, exécuté sur l'hôte", + "en": "cross-compiled protoc run on the host", + }, "JDK/Gradle mismatch": { "fr": "JDK et Gradle incompatibles", "en": "JDK/Gradle mismatch", @@ -7786,6 +7806,60 @@ TRANSLATIONS = { "fr": "tests Vitest en échec", "en": "Vitest tests failed", }, + "ERPLibre venv incomplete (no pip: python3-venv)": { + "fr": "venv ERPLibre incomplet (pas de pip : python3-venv)", + "en": "ERPLibre venv incomplete (no pip: python3-venv)", + }, + "mobile repository missing": { + "fr": "dépôt mobile absent", + "en": "mobile repository missing", + }, + "ERPLibre venv missing (incomplete install)": { + "fr": "venv ERPLibre absent (installation incomplète)", + "en": "ERPLibre venv missing (incomplete install)", + }, + "Android emulator (Pixel)": { + "fr": "Émulateur Android (Pixel)", + "en": "Android emulator (Pixel)", + }, + "AVD viewable over ssh -X": { + "fr": "AVD visible par ssh -X", + "en": "AVD viewable over ssh -X", + }, + "Android emulator (AVD)": { + "fr": "Émulateur Android (AVD)", + "en": "Android emulator (AVD)", + }, + "no /dev/kvm: nested virtualisation is off on the host": { + "fr": "pas de /dev/kvm : virtualisation imbriquée coupée sur l'hôte", + "en": "no /dev/kvm: nested virtualisation is off on the host", + }, + "user added to the kvm group (effective at next login)": { + "fr": "utilisateur ajouté au groupe kvm (effectif à la prochaine session)", + "en": "user added to the kvm group (effective at next login)", + }, + "emulator and system image": { + "fr": "émulateur et image système", + "en": "emulator and system image", + }, + "Pixel profile, smallest screen": { + "fr": "profil Pixel, plus petit écran", + "en": "Pixel profile, smallest screen", + }, + "create the AVD": {"fr": "création de l'AVD", "en": "create the AVD"}, + "AVD ready:": {"fr": "AVD prêt :", "en": "AVD ready:"}, + "open it from your workstation:": { + "fr": "à ouvrir depuis votre poste :", + "en": "open it from your workstation:", + }, + "then install the APK:": { + "fr": "puis installer l'APK :", + "en": "then install the APK:", + }, + "ERPLibre mobile, Android SDK (long)": { + "fr": "ERPLibre mobile, SDK Android (long)", + "en": "ERPLibre mobile, Android SDK (long)", + }, "Gradle task failed": { "fr": "tâche Gradle en échec", "en": "Gradle task failed", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 1a318b3..ec65625 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -92,14 +92,16 @@ class TestToolDisk(unittest.TestCase): self.assertEqual( full - arm, TODO._QEMU_VM_TOOLS["android"]["disk_gb"] - + TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"], + + TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"] + + TODO._QEMU_VM_TOOLS["avd"]["disk_gb"], ) def test_a_server_only_pays_for_what_it_gets(self): """Un serveur ne porte aucun IDE, donc il n'en paie pas le disque — - mais il paie bien la compilation mobile, qu'il reçoit.""" + mais il paie bien ce qu'il reçoit : compilation mobile et émulateur.""" self.assertEqual( - TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"], + TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"] + + TODO._QEMU_VM_TOOLS["avd"]["disk_gb"], self.todo._qemu_tools_disk_gb(self.all, "amd64", "", "ubuntu"), ) @@ -262,9 +264,10 @@ class TestMobileBuild(unittest.TestCase): self.all = tuple(TODO._QEMU_VM_TOOLS) def test_it_runs_on_a_server_vm(self): - """Elle compile, elle n'affiche rien : un bureau serait du gaspillage.""" + """Elle compile, elle n'affiche rien : un bureau serait du gaspillage. + L'émulateur non plus n'en a pas besoin — il s'affiche par ssh -X.""" got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu") - self.assertEqual(["mobile"], got) + self.assertEqual(["mobile", "avd"], got) def test_it_is_bounded_to_apt(self): """install-android.sh du dépôt mobile commence par « sudo apt install @@ -362,6 +365,93 @@ class TestMobileBuild(unittest.TestCase): self.assertIn('>> "$M" 2>&1', cmd) +class TestAndroidEmulator(unittest.TestCase): + """Émulateur Android : visible depuis le poste par « ssh -X ».""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_avd_remote_cmd() + + def test_no_desktop_needed_in_the_vm(self): + """Il s'affiche sur l'écran de qui s'y connecte, pas dans la VM.""" + self.assertFalse(TODO._QEMU_VM_TOOLS["avd"]["needs_desktop"]) + self.assertIn( + "avd", self.todo._qemu_tools_for(("avd",), "amd64", "", "ubuntu") + ) + + def test_software_rendering_is_written_into_the_avd(self): + """Par « ssh -X » il n'y a pas de GLX direct : en « auto », + l'émulateur s'ouvre sur un écran noir. Le réglage va dans config.ini + pour qu'« emulator -avd erplibre » suffise.""" + self.assertIn("hw.gpu.mode=swiftshader_indirect", self.cmd) + self.assertIn("config.ini", self.cmd) + + def test_xauth_is_installed(self): + """Sans xauth dans la VM, « ssh -X » n'ouvre aucun affichage — et le + paquet manque des images cloud.""" + self.assertIn("xauth", self.cmd) + + def test_it_says_when_kvm_is_missing(self): + """Un émulateur x86 sans KVM refuse de démarrer : le dire là où c'est + réparable, sur l'hôte, plutôt qu'au premier lancement.""" + self.assertIn("/dev/kvm", self.cmd) + + def test_the_pixel_is_chosen_at_runtime(self): + """« le plus récent, le plus petit écran » se demande au SDK : figer un + modèle le rendrait faux à la prochaine génération.""" + self.assertIn("avdmanager list device", self.cmd) + self.assertIn("pixel_", self.cmd) + self.assertIn("pro|xl|fold|tablet", self.cmd) + self.assertIn("sort -t_ -k2 -n", self.cmd) + + def test_the_system_image_falls_back(self): + """Google ne publie pas d'image pour toutes les API : on descend.""" + self.assertIn("for a in $v 36 35 34", self.cmd) + + def test_it_prints_the_command_to_open_it(self): + """Un émulateur dont on ignore comment l'ouvrir ne sert à personne.""" + self.assertIn("ssh -X erplibre@$ip", self.cmd) + self.assertIn("adb install -r", self.cmd) + + def test_one_prologue_and_one_sdk_for_both_options(self): + """Deux prologues, et le second tronquerait le journal du premier.""" + both = self.todo._qemu_after_remote_cmd(("mobile", "avd")) + self.assertEqual(1, both.count("mstep() {")) + self.assertEqual(1, both.count('M="$HOME/erplibre-mobile-build.log"')) + + def test_the_emulator_cannot_mask_a_build_failure(self): + """ÉPROUVÉ, pas relu. Sans accolades autour de chaque groupe, « && » ne + lie que la première commande du suivant : mesuré sur une VM, un APK + manquant laissait tourner l'émulateur puis rendait 0 — la VM repassait + au vert alors que rien n'avait compilé.""" + both = self.todo._qemu_after_remote_cmd(("mobile", "avd")) + # On neutralise les étapes : seul le CHAÎNAGE est en cause ici. + stub = 'mstep() { echo " -> $1"; return 0; }; mdiag() { :; }; ' + tail = both[both.index('{ mstep "') :] + res = subprocess.run( + ["bash", "-c", "set -e; " + stub + tail], + capture_output=True, + text=True, + ) + self.assertNotEqual(0, res.returncode, res.stdout) + # Et l'émulateur ne doit PAS avoir été touché. + self.assertNotIn("Pixel", res.stdout) + + def test_valid_shell_in_every_combination(self): + for tools in (("mobile",), ("avd",), ("mobile", "avd")): + cmd = self.todo._qemu_after_remote_cmd(tools) + res = subprocess.run( + ["bash", "-n"], input=cmd, text=True, capture_output=True + ) + self.assertEqual(0, res.returncode, f"{tools}: {res.stderr}") + + def test_no_diagnostic_pattern_carries_an_apostrophe(self): + """Ces motifs partent dans un « grep -q '' » : une apostrophe + fermait la chaîne et rendait tout le bloc invalide. Vécu.""" + for pattern, _cause in TODO._QEMU_MOBILE_DIAG: + self.assertNotIn("'", pattern, pattern) + + class TestPycharmCommunity(unittest.TestCase): """PyCharm doit s'ouvrir sans compte : c'est toute la différence entre une VM utilisable au premier démarrage et une VM qui demande une licence."""