diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index f99f1dd..87c9bcf 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -292,8 +292,25 @@ written in 195 s, and the install then adds its exclusions to the `.iml`. When Xvfb is unavailable or the IDE does not get there in five minutes, the log says so and the install carries on. -Each tool is filtered per VM — by architecture and by desktop flavour — and -its disk cost is added to the plan before anything is created. +A fourth one needs no desktop at all: **ERPLibre mobile (build)**. It adds +the mobile repository to the manifest (which is additive, so it coexists with +an Odoo 18 install), runs the repository's own `install-android.sh` — JDK 17, +command-line tools, SDK licences accepted, NDK, whisper.cpp and sentencepiece +— then builds: `npm ci`, `vite build`, `cap sync`, `gradlew assembleDebug`, +and finally `npm test`. **A failed build fails the VM**: the exit code reaches +the dashboard, and the log names the probable cause (disk full, missing SDK +platform, JDK/Gradle mismatch, unaccepted licences…) instead of leaving a +40 MB Gradle log to read. The heavy output goes to +`~/erplibre-mobile-build.log` inside the VM so the install log stays readable. + +It is bounded to apt-based distributions, because that upstream installer +starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio +— a plain server VM builds the APK — and when Android Studio is also ticked +they share one SDK through `ANDROID_HOME`. Without Android, the same app runs +in a browser: `npm start`. + +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. ## Main options @@ -390,8 +407,26 @@ l'installation y ajoute ensuite ses exclusions dans le `.iml`. Sans Xvfb, ou si l'IDE n'y arrive pas en cinq minutes, le journal le dit et l'installation continue. -Chaque outil est filtré VM par VM — architecture et saveur de bureau — et sa -place disque s'ajoute au plan avant que rien ne soit créé. +Un quatrième ne demande aucun bureau : **ERPLibre mobile (compilation)**. Il +ajoute le dépôt mobile au manifeste — additif, donc il cohabite avec une +installation Odoo 18 —, lance l'`install-android.sh` du dépôt lui-même (JDK 17, +outils en ligne de commande, licences SDK acceptées, NDK, whisper.cpp et +sentencepiece), puis compile : `npm ci`, `vite build`, `cap sync`, +`gradlew assembleDebug`, et enfin `npm test`. **Une compilation en échec fait +échouer la VM** : le code de sortie remonte au tableau de bord, et le journal +NOMME la cause probable (disque plein, plateforme SDK absente, JDK et Gradle +incompatibles, licences non acceptées…) au lieu de laisser 40 Mo de journal +Gradle à relire. Le détail va dans `~/erplibre-mobile-build.log`, dans la VM, +pour que le journal d'installation reste lisible. + +Il est borné aux distributions apt, parce que cet installateur amont commence +par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une +simple VM serveur produit l'APK — et quand Android Studio est aussi coché, les +deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même +application tourne dans un navigateur : `npm start`. + +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éé. ## Principales options diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index fc9240c..de26940 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -180,8 +180,26 @@ l'installation y ajoute ensuite ses exclusions dans le `.iml`. Sans Xvfb, ou si l'IDE n'y arrive pas en cinq minutes, le journal le dit et l'installation continue. -Chaque outil est filtré VM par VM — architecture et saveur de bureau — et sa -place disque s'ajoute au plan avant que rien ne soit créé. +Un quatrième ne demande aucun bureau : **ERPLibre mobile (compilation)**. Il +ajoute le dépôt mobile au manifeste — additif, donc il cohabite avec une +installation Odoo 18 —, lance l'`install-android.sh` du dépôt lui-même (JDK 17, +outils en ligne de commande, licences SDK acceptées, NDK, whisper.cpp et +sentencepiece), puis compile : `npm ci`, `vite build`, `cap sync`, +`gradlew assembleDebug`, et enfin `npm test`. **Une compilation en échec fait +échouer la VM** : le code de sortie remonte au tableau de bord, et le journal +NOMME la cause probable (disque plein, plateforme SDK absente, JDK et Gradle +incompatibles, licences non acceptées…) au lieu de laisser 40 Mo de journal +Gradle à relire. Le détail va dans `~/erplibre-mobile-build.log`, dans la VM, +pour que le journal d'installation reste lisible. + +Il est borné aux distributions apt, parce que cet installateur amont commence +par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une +simple VM serveur produit l'APK — et quand Android Studio est aussi coché, les +deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même +application tourne dans un navigateur : `npm start`. + +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éé. ## Principales options diff --git a/script/qemu/README.md b/script/qemu/README.md index 63a5eca..17c7622 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -166,8 +166,25 @@ written in 195 s, and the install then adds its exclusions to the `.iml`. When Xvfb is unavailable or the IDE does not get there in five minutes, the log says so and the install carries on. -Each tool is filtered per VM — by architecture and by desktop flavour — and -its disk cost is added to the plan before anything is created. +A fourth one needs no desktop at all: **ERPLibre mobile (build)**. It adds +the mobile repository to the manifest (which is additive, so it coexists with +an Odoo 18 install), runs the repository's own `install-android.sh` — JDK 17, +command-line tools, SDK licences accepted, NDK, whisper.cpp and sentencepiece +— then builds: `npm ci`, `vite build`, `cap sync`, `gradlew assembleDebug`, +and finally `npm test`. **A failed build fails the VM**: the exit code reaches +the dashboard, and the log names the probable cause (disk full, missing SDK +platform, JDK/Gradle mismatch, unaccepted licences…) instead of leaving a +40 MB Gradle log to read. The heavy output goes to +`~/erplibre-mobile-build.log` inside the VM so the install log stays readable. + +It is bounded to apt-based distributions, because that upstream installer +starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio +— a plain server VM builds the APK — and when Android Studio is also ticked +they share one SDK through `ANDROID_HOME`. Without Android, the same app runs +in a browser: `npm start`. + +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. ## Main options diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 2e1e8f6..6e0d3f4 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -294,7 +294,7 @@ def build_spec(vms, domains, form): "ssh_key": form["ssh_key"], "timezone": form.get("timezone", ""), "desktop": form.get("desktop", ""), - "desktop_tools": tuple(form.get("desktop_tools") or ()), + "vm_tools": tuple(form.get("vm_tools") or ()), "python_provider": form.get("python_provider", ""), "app_store": form.get("app_store", "deb"), "install": form["install"], @@ -368,10 +368,13 @@ def run_deploy_form(ctx, run_app: bool = True): # Outils de développement d'une VM graphique : [(clé, libellé, indice)] et # leurs contraintes, toutes décrites dans todo.py — le formulaire ne fait # que les afficher et rendre les cases cochées. - desktop_tools = list(ctx.get("desktop_tools") or []) - tool_disk = dict(ctx.get("desktop_tool_disk") or {}) - tool_arches = dict(ctx.get("desktop_tool_arches") or {}) - tool_desktops = dict(ctx.get("desktop_tool_desktops") or {}) + vm_tools = list(ctx.get("vm_tools") or []) + tool_disk = dict(ctx.get("vm_tool_disk") or {}) + tool_arches = dict(ctx.get("vm_tool_arches") or {}) + tool_desktops = dict(ctx.get("vm_tool_desktops") or {}) + tool_needs_desktop = dict(ctx.get("vm_tool_needs_desktop") or {}) + tool_families = dict(ctx.get("vm_tool_families") or {}) + distro_family = dict(ctx.get("distro_family") or {}) # Architectures pour lesquelles mise publie un binaire. mise_arches = set(ctx.get("mise_arches") or ()) # [(clé, libellé)] des magasins d'applications, et les distributions qui @@ -663,14 +666,13 @@ def run_deploy_form(ctx, run_app: bool = True): for i, (_k, label) in enumerate(app_stores): yield RadioButton(label, value=i == 0) yield Static("", id="storewarn") - if desktop_tools: + if vm_tools: # Une case par outil, et non une liste déroulante : ils # sont indépendants, et chacun se prend ou se laisse. yield Static( - t("Development tools (graphical VMs):"), - classes="grouptitle", + t("Development tools:"), classes="grouptitle" ) - for key, label, hint in desktop_tools: + for key, label, hint in vm_tools: gb = tool_disk.get(key, 0) yield Checkbox( f"{label} +{gb} Go — {hint}", @@ -839,7 +841,7 @@ def run_deploy_form(ctx, run_app: bool = True): # Le bureau pèse sur le disque de la VM QUI LE PORTE, et d'elle # seule : un supplément commun mentait dès que les types # différaient d'une machine à l'autre. - tools = self._desktop_tools() + tools = self._vm_tools() for row in self.rows: if row["vm"].get("desktop"): row["disk_gb"] += desktop_disk @@ -866,10 +868,10 @@ def run_deploy_form(ctx, run_app: bool = True): self._render_store() self._render_tools() - def _desktop_tools(self): + def _vm_tools(self): """Clés des outils cochés, dans l'ordre de la liste.""" picked = [] - for key, _label, _hint in desktop_tools: + for key, _label, _hint in vm_tools: try: if self.query_one(f"#f_tool_{key}", Checkbox).value: picked.append(key) @@ -881,43 +883,46 @@ def run_deploy_form(ctx, run_app: bool = True): """Outils qu'une VM donnée recevra vraiment. Même filtre que todo.py côté déploiement : une VM ARM ne verra - jamais Android Studio, une VM Cinnamon jamais les extensions - GNOME, et un serveur aucun des trois.""" - if not vm.get("desktop"): - return [] + jamais Android Studio, une VM Cinnamon jamais les extensions GNOME, + un serveur aucun des IDE — mais un serveur reçoit bien la + compilation mobile, qui n'a rien à afficher, et une distribution + sans apt ne la reçoit pas, son installateur n'existant que là.""" out = [] for key in tools: arches = tool_arches.get(key) or () desks = tool_desktops.get(key) or () + fams = tool_families.get(key) or () + if tool_needs_desktop.get(key) and not vm.get("desktop"): + continue if arches and vm["arch"] not in arches: continue - if desks and vm["desktop"] not in desks: + if desks and vm.get("desktop") not in desks: + continue + if fams and distro_family.get(vm["distro"], "") not in fams: continue out.append(key) return out def _render_tools(self): - """Grise les cases sans VM graphique, et NOMME ce qui sera écarté. + """Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et + NOMME ce qui sera écarté. - Cocher Android Studio sur un parc ARM ne produit rien : le dire ici + Une case par outil, et non un blocage en bloc : sur un parc de + serveurs les IDE se grisent, la compilation mobile reste offerte. + Cocher Android Studio sur un parc ARM ne produit rien — le dire ici évite de le découvrir dans le journal d'installation.""" - if not desktop_tools: + if not vm_tools: return - graphical = [vm for vm in self.vms if vm.get("desktop")] - for key, _label, _hint in desktop_tools: - self.query_one(f"#f_tool_{key}", Checkbox).disabled = ( - not graphical + for key, _label, _hint in vm_tools: + usable = any( + self._tools_for_vm(vm, (key,)) for vm in self.vms ) - if not graphical: - self.query_one("#toolwarn", Static).update( - f" {t('No graphical VM: these tools are not installed.')}" - ) - return - picked = self._desktop_tools() + self.query_one(f"#f_tool_{key}", Checkbox).disabled = not usable + picked = self._vm_tools() skipped = sorted( { vm["name"] - for vm in graphical + for vm in self.vms for k in picked if k not in self._tools_for_vm(vm, picked) } @@ -1835,7 +1840,7 @@ def run_deploy_form(ctx, run_app: bool = True): or ctx.get("timezone") or "", "desktop": self._default_desktop(), - "desktop_tools": self._desktop_tools(), + "vm_tools": self._vm_tools(), "python_provider": self._python_provider(), "app_store": self._app_store(), "install": install, diff --git a/script/todo/todo.py b/script/todo/todo.py index 3de71fc..d7999e4 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5032,13 +5032,16 @@ class TODO: # QU'EN x86_64 (vérifié — toutes les variantes aarch64 de l'URL rendent 404, # et le product-info.json de l'archive ne déclare qu'une cible # « Linux/amd64 »). JetBrains, lui, publie bien une archive aarch64. - _QEMU_DESKTOP_TOOLS = { + _QEMU_VM_TOOLS = { "pycharm": { "label": "PyCharm", "hint": "Python IDE, opens the ERPLibre checkout", "disk_gb": 5, "arches": ("amd64", "arm64"), "desktops": (), + "needs_desktop": True, + "families": (), + "phase": "before", }, "android": { "label": "Android Studio", @@ -5046,6 +5049,9 @@ class TODO: "disk_gb": 8, "arches": ("amd64",), "desktops": (), + "needs_desktop": True, + "families": (), + "phase": "before", }, "gnome_ext": { "label": "GNOME extensions", @@ -5053,45 +5059,93 @@ class TODO: "disk_gb": 1, "arches": (), "desktops": ("gnome",), + "needs_desktop": True, + "families": (), + "phase": "before", + }, + # Le seul outil qui ne demande PAS de bureau : il compile, il n'affiche + # rien. Une VM serveur le prend, une VM graphique aussi — et sur + # celle-ci le SDK est partagé avec Android Studio plutôt que doublé. + # + # « families » le borne à apt, et ce n'est pas un choix : l'installateur + # du dépôt mobile, install-android.sh, commence par + # « sudo apt install openjdk-17-jdk ». Ailleurs il s'arrête là. Lever + # cette limite se fait dans CE script-là, pas ici. + # + # Disque : ~1,5 Go de SDK et plateformes, ~2,5 Go de NDK, whisper.cpp + # et sentencepiece clonés, node_modules, et les artefacts Gradle. + "mobile": { + "label": "ERPLibre mobile (build)", + "hint": "APK debug + Vitest, validates the VM", + "disk_gb": 12, + "arches": ("amd64",), + "desktops": (), + "needs_desktop": False, + "families": ("apt",), + # APRÈS l'installation : le build a besoin du dépôt mobile, que le + # manifeste ajoute, et du venv d'outils pour le synchroniser. + "phase": "after", }, } + # Famille de paquets de chaque distribution, pour borner un outil à ce qui + # sait l'installer. + _QEMU_DISTRO_FAMILY = { + "ubuntu": "apt", + "debian": "apt", + "fedora": "dnf", + "almalinux": "dnf", + "rocky": "dnf", + "opensuse": "zypper", + "arch": "pacman", + } + @classmethod - def _qemu_desktop_tool_choices(cls): + def _qemu_vm_tool_choices(cls): """[(clé, libellé, indice)] pour le formulaire et l'invite en ligne.""" return [ (key, t(spec["label"]), t(spec["hint"])) - for key, spec in cls._QEMU_DESKTOP_TOOLS.items() + for key, spec in cls._QEMU_VM_TOOLS.items() ] @classmethod - def _qemu_tools_for(cls, tools, arch, desktop): + def _qemu_tools_for(cls, tools, arch, desktop, distro="", phase=""): """Outils RÉELLEMENT applicables à cette VM. Un outil demandé pour tout le parc ne convient pas forcément à chaque - machine : Android Studio n'existe qu'en x86_64, et les extensions GNOME - n'ont pas de sens sous Cinnamon. Filtrer ici plutôt que dans la commande - distante évite d'annoncer une installation qui ne se fera pas.""" - if not desktop: - return [] + machine : Android Studio n'existe qu'en x86_64, les extensions GNOME + n'ont pas de sens sous Cinnamon, et la compilation mobile ne sait + s'installer que sur les distributions apt. Filtrer ici plutôt que dans + la commande distante évite d'annoncer une installation qui ne se fera + pas. + + `phase` restreint au moment d'exécution : « before » avant le clone, + « after » après l'installation. Vide, les deux sont rendus.""" out = [] for key in tools or (): - spec = cls._QEMU_DESKTOP_TOOLS.get(key) + spec = cls._QEMU_VM_TOOLS.get(key) if not spec: continue + if spec["needs_desktop"] and not desktop: + continue if spec["arches"] and arch not in spec["arches"]: continue if spec["desktops"] and desktop not in spec["desktops"]: continue + family = cls._QEMU_DISTRO_FAMILY.get(distro, "") + if spec["families"] and distro and family not in spec["families"]: + continue + if phase and spec["phase"] != phase: + continue out.append(key) return out @classmethod - def _qemu_tools_disk_gb(cls, tools, arch, desktop): + def _qemu_tools_disk_gb(cls, tools, arch, desktop, distro=""): """Go à ajouter au disque pour les outils applicables à cette VM.""" return sum( - cls._QEMU_DESKTOP_TOOLS[k]["disk_gb"] - for k in cls._qemu_tools_for(tools, arch, desktop) + cls._QEMU_VM_TOOLS[k]["disk_gb"] + for k in cls._qemu_tools_for(tools, arch, desktop, distro) ) # Archive officielle JetBrains, et non un paquet de distribution : aucun ne @@ -5555,16 +5609,164 @@ class TODO: + self._qemu_gnome_ext_site_cmd() ) - def _qemu_tools_remote_cmd(self, tools, prod=False): - """Bloc d'installation des outils cochés, dans l'ordre du plus utile au - plus lourd. Chacun se garde lui-même : aucun ne fait échouer les - autres, ni l'installation d'ERPLibre.""" + # Diagnostic de la compilation mobile : motif rencontré dans le journal + # détaillé -> cause nommée. Du plus précis au plus général, le premier qui + # correspond gagne. + # + # Cette liste est faite pour GRANDIR. Une compilation Android échoue de + # cent façons, et le journal fait des dizaines de mégaoctets : sans cette + # traduction, « la VM est rouge » n'apprend rien et il faut tout rouvrir. + # Chaque panne rencontrée sur une VM mérite d'y laisser sa ligne. + _QEMU_MOBILE_DIAG = ( + ("No space left on device", "disk full"), + ("Failed to find target with hash string", "SDK platform missing"), + ("SDK location not found", "SDK not found (ANDROID_HOME)"), + ("have not been accepted", "SDK licences not accepted"), + ("NDK not configured", "NDK missing"), + ("Unsupported class file major version", "JDK/Gradle mismatch"), + ("Could not determine java version", "JDK/Gradle mismatch"), + ( + "Could not resolve all files for configuration", + "Gradle dependency unreachable (network?)", + ), + ("npm ERR!", "npm dependencies"), + ("Test Files", "Vitest tests failed"), + ("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.""" + lines = "".join( + f"grep -q '{pat}' \"$1\" && {{ " + f'echo " {t("probable cause:")} {t(cause)}"; return 0; }}; ' + for pat, cause in self._QEMU_MOBILE_DIAG + ) + return ( + "mdiag() { " + + lines + + f'echo " {t("no known pattern, last lines:")}"; ' + 'tail -12 "$1" | sed "s/^/ /"; }; ' + ) + + def _qemu_mobile_remote_cmd(self, prod=False): + """Installe et COMPILE l'application mobile, puis la teste. + + 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. + Le code de sortie remonte donc jusqu'au tableau de bord. + + Le dépôt mobile porte son propre installateur Android — JDK, outils en + ligne de commande, licences acceptées, plateformes, NDK, whisper.cpp et + sentencepiece. On l'appelle plutôt que de le réécrire : une seconde + implémentation dériverait de la première sans prévenir. Deux choses lui + manquent pourtant, et on les ajoute ici : + - unzip et wget, qu'il suppose présents et qu'aucune image cloud ne + livre ; + - la plateforme que le projet réclame VRAIMENT. Son installateur pose + android-34 quand android/variables.gradle demande compileSdk 36 ; + plutôt que de figer 36 ici, on lit le chiffre dans le fichier. + + L'étape est bornée à apt (voir _QEMU_VM_TOOLS) : cet installateur + 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' && " + f'mstep "{t("debug APK (gradle)")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile/android && " + "./gradlew --no-daemon assembleDebug' && " + 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 + # peut rendre 0 sans avoir rien produit. + f"apk=$(ls {el_dir}/mobile/erplibre_home_mobile/android/app/build" + "/outputs/apk/debug/*.apk 2>/dev/null | head -1); " + 'if [ -n "$apk" ]; then ' + f'echo " ✅ {t("APK built:")} $apk"; ' + # Capacitor sert la même application dans un navigateur : sur une + # VM graphique, c'est la voie de déverminage sans Android ni + # émulateur. On la NOMME plutôt que d'imposer Chromium — sur + # Ubuntu il n'existe qu'en snap, et snapd est justement coupé sur + # ces VM. Le navigateur du bureau fait l'affaire. + # + # DANS la branche de succès, et c'est tout l'enjeu : placé après le + # « fi », cet echo devenait la dernière commande du bloc et rendait + # 0 — une VM sans APK repassait au vert. + 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; ' + ) + + 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. + + « before » : posé avant le clone. Chaque outil s'y garde lui-même — + aucun ne fait échouer les autres, ni l'installation d'ERPLibre. + + « 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.""" blocks = { - "gnome_ext": self._qemu_gnome_ext_remote_cmd, - "pycharm": lambda: self._qemu_pycharm_remote_cmd(prod), - "android": self._qemu_android_studio_remote_cmd, + "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"), } - return "".join(blocks[k]() for k in blocks if k in (tools or ())) + return "".join( + fn() + for k, (fn, ph) in blocks.items() + if k in (tools or ()) and ph == phase + ) def _qemu_editor_pkg(self): """Paquet de l'éditeur de l'hôte, à installer dans la VM. @@ -5691,7 +5893,12 @@ class TODO: # apt pendant l'installation. En PROD on ne touche à rien : les # correctifs de sécurité automatiques doivent rester actifs. no_auto_upgrade = self._qemu_no_auto_upgrade(prod, app_store) - tools_cmd = self._qemu_tools_remote_cmd(tools, prod) + tools_cmd = self._qemu_tools_remote_cmd(tools, prod, "before") + # La compilation mobile vient APRÈS l'installation : elle a besoin du + # dépôt, du venv d'outils qui synchronise le manifeste, et de node que + # « make install_os » installe. Liée par « && » et NON gardée, pour que + # son échec soit celui de la VM. + after_cmd = self._qemu_tools_remote_cmd(tools, prod, "after") # Entre le clone et le make : PyCharm ouvre le dépôt une fois pour en # écrire le .idea, que l'installation configurera juste après. Le # groupe rend toujours 0 — l'étape est un bonus, pas une condition. @@ -5810,6 +6017,7 @@ class TODO: f"{self.ERPLIBRE_GIT_URL} /opt/erplibre; " "sudo chown -R $(id -un):$(id -gn) /opt/erplibre; fi; " f"cd /opt/erplibre && {open_step}{final_cmd}" + + (f" && {{ {after_cmd} }}" if after_cmd else "") ) if prod else ( @@ -5818,6 +6026,7 @@ class TODO: f"git clone --branch {shlex.quote(branch)} " f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; " f"cd ~/git/erplibre && {open_step}{final_cmd}" + + (f" && {{ {after_cmd} }}" if after_cmd else "") ) ) ) @@ -5832,14 +6041,14 @@ class TODO: desktop=False, python_provider="", app_store="deb", - desktop_tools=(), + vm_tools=(), ): """Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre le dashboard Textual. Quitter le dashboard n'arrête pas les installs. `ip_map` : IP déjà résolues (sinon on résout ici, EN PARALLÈLE). `final_cmd` : commande d'install selon le profil choisi. `prod` : install /opt/erplibre + service SELinux confiné. - `desktop_tools` : outils cochés pour tout le parc, filtrés machine par + `vm_tools` : outils cochés pour tout le parc, filtrés machine par machine (Android Studio n'existe qu'en x86_64, les extensions GNOME n'ont pas de sens sous Cinnamon).""" from script.todo.qemu_install_monitor import ( @@ -5894,7 +6103,7 @@ class TODO: # reste est commun : ils dépendent de l'architecture de la # machine et de sa saveur de bureau, que seule cette boucle # connaît. - if desk_map or branch_map or cmd_map or desktop_tools: + if desk_map or branch_map or cmd_map or vm_tools: # Le bureau de CETTE VM : sa saveur propre si la carte en # donne une, sinon celle du parc. Prendre « rien » quand la # carte est vide privait de bureau toute VM dont seule la @@ -5910,7 +6119,7 @@ class TODO: vm_desktop, python_provider, app_store, - self._qemu_tools_for(desktop_tools, a, vm_desktop), + self._qemu_tools_for(vm_tools, a, vm_desktop, d), ) vms.append(entry) else: @@ -5956,12 +6165,12 @@ class TODO: desktop=False, python_provider="", app_store="deb", - desktop_tools=(), + vm_tools=(), ): """Clone ERPLibre (branche donnée) dans la VM puis exécute la commande d'install du profil choisi (streamé). `ip` : IP déjà résolue ; `final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné ; - `desktop_tools` : outils de développement cochés.""" + `vm_tools` : outils de développement cochés.""" if ip is None: ip = self._qemu_vm_ip(name) if not ip: @@ -5978,6 +6187,15 @@ class TODO: f"{t('SSH not reachable, ERPLibre install skipped.')}" ) return + # Distribution et architecture de CETTE VM : les outils s'y filtrent + # (Android Studio n'existe qu'en x86_64, la compilation mobile qu'en + # apt). Sans module lisible on ne filtre plus sur la distribution + # plutôt que d'écarter à tort. + try: + mod = self._qemu_import_module() + vm_distro, _v, vm_arch = self._qemu_vm_meta(name, mod) + except Exception: + vm_distro, vm_arch = "", self._qemu_vm_arch(name) remote = self._qemu_erplibre_remote_cmd( branch, final_cmd, @@ -5985,10 +6203,8 @@ class TODO: desktop, python_provider, app_store, - # « or amd64 » comme _qemu_vm_meta : une architecture indéterminée - # ne doit pas faire disparaître silencieusement Android Studio. self._qemu_tools_for( - desktop_tools, self._qemu_vm_arch(name) or "amd64", desktop + vm_tools, vm_arch or "amd64", desktop, vm_distro or "" ), ) ssh_opts = ( @@ -6424,16 +6640,16 @@ class TODO: print( f" {t('VM type:')} {t('Graphical (server + desktop):')} {label}" ) - tools = spec.get("desktop_tools") or () + tools = spec.get("vm_tools") or () if tools: # Les Go sont dits ici parce que c'est le dernier écran avant de # créer les disques : un IDE de plus, c'est un disque plus grand, # et cette page est celle qu'on relit pour s'en apercevoir. named = ", ".join( - f"{t(self._QEMU_DESKTOP_TOOLS[k]['label'])} " - f"(+{self._QEMU_DESKTOP_TOOLS[k]['disk_gb']} Go)" + f"{t(self._QEMU_VM_TOOLS[k]['label'])} " + f"(+{self._QEMU_VM_TOOLS[k]['disk_gb']} Go)" for k in tools - if k in self._QEMU_DESKTOP_TOOLS + if k in self._QEMU_VM_TOOLS ) print(f" {t('Development tools:')} {named}") prov = spec.get("python_provider") @@ -6463,7 +6679,7 @@ class TODO: desktop=False, prod=False, install_cmd="", - desktop_tools=(), + vm_tools=(), ): """Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu dry-run ET le déploiement réel).""" @@ -6520,7 +6736,7 @@ class TODO: # Studio, c'est l'archive téléchargée PUIS son contenu déplié. Compté # ici plutôt qu'au petit bonheur, sinon l'installation se termine sur un # disque plein après une heure. - extra += self._qemu_tools_disk_gb(desktop_tools, arch, desktop) + extra += self._qemu_tools_disk_gb(vm_tools, arch, desktop, d) if extra: bigger = self._parse_disk_gb(disk) + extra parts += ["--disk-size", f"{bigger}G"] @@ -6559,7 +6775,7 @@ class TODO: # quelle cible make le remettra à jour. prod=bool(install and install.get("prod")), install_cmd=(install or {}).get("cmd") or "", - desktop_tools=spec.get("desktop_tools") or (), + vm_tools=spec.get("vm_tools") or (), ) # ---------------------------------------------------------------- # @@ -6803,16 +7019,23 @@ class TODO: self._qemu_host_timezone() ), "snap_distros": self.QEMU_SNAP_DISTROS, - "desktop_tools": self._qemu_desktop_tool_choices(), - "desktop_tool_disk": { - k: v["disk_gb"] for k, v in self._QEMU_DESKTOP_TOOLS.items() + "vm_tools": self._qemu_vm_tool_choices(), + "vm_tool_disk": { + k: v["disk_gb"] for k, v in self._QEMU_VM_TOOLS.items() }, - "desktop_tool_arches": { - k: v["arches"] for k, v in self._QEMU_DESKTOP_TOOLS.items() + "vm_tool_arches": { + k: v["arches"] for k, v in self._QEMU_VM_TOOLS.items() }, - "desktop_tool_desktops": { - k: v["desktops"] for k, v in self._QEMU_DESKTOP_TOOLS.items() + "vm_tool_desktops": { + k: v["desktops"] for k, v in self._QEMU_VM_TOOLS.items() }, + "vm_tool_needs_desktop": { + k: v["needs_desktop"] for k, v in self._QEMU_VM_TOOLS.items() + }, + "vm_tool_families": { + k: v["families"] for k, v in self._QEMU_VM_TOOLS.items() + }, + "distro_family": dict(self._QEMU_DISTRO_FAMILY), "desktop_suffixes": self._qemu_desktop_suffixes(), "desktops": [ (k, v["label"]) for k, v in self._QEMU_DESKTOP.items() @@ -7128,27 +7351,44 @@ class TODO: return self.QEMU_APP_STORES[int(answer) - 1][0] return "deb" - def _qemu_ask_desktop_tools(self, vms): + def _qemu_ask_vm_tools(self, vms): """Outils de développement des VM graphiques : liste à cocher. - Ne se pose QUE si au moins une VM porte un bureau — sur un serveur, un - IDE graphique n'a rien pour s'afficher. La réponse vaut pour tout le - parc et sera filtrée machine par machine. + Ne montre que ce qu'au moins une VM du parc peut recevoir : les IDE + graphiques disparaissent d'un parc de serveurs, où ils n'auraient rien + pour s'afficher, et la compilation mobile reste offerte — elle compile, + elle n'affiche pas. La réponse vaut pour tout le parc et sera filtrée + machine par machine. Saisie par numéros séparés par des espaces ou des virgules, « tous » - pour tout cocher, vide pour rien : trois questions oui/non de plus + pour tout cocher, vide pour rien : quatre questions oui/non de plus alourdiraient une séquence d'invites déjà longue.""" - graphical = [vm for vm in vms if vm.get("desktop")] - if not graphical: + choices = [ + c + for c in self._qemu_vm_tool_choices() + if any( + self._qemu_tools_for( + (c[0],), + vm.get("arch", "amd64"), + vm.get("desktop", ""), + vm.get("distro", ""), + ) + for vm in vms + ) + ] + if not choices: return () - choices = self._qemu_desktop_tool_choices() - print(f"\n{t('Development tools for the graphical VMs:')}") + print(f"\n{t('Development tools:')}") for i, (_key, label, hint) in enumerate(choices, 1): print(f" [{i}] {label} — {hint}") gb = ", ".join( - f"{label} +{self._QEMU_DESKTOP_TOOLS[key]['disk_gb']} Go" + f"{label} +{self._QEMU_VM_TOOLS[key]['disk_gb']} Go" for key, label, _hint in choices ) + # Le mobile fait échouer la VM quand l'application ne compile pas : + # c'est le but, mais il vaut mieux le savoir avant de cocher. + if any(k == "mobile" for k, _l, _h in choices): + print(f" ⚠ {t('a failed mobile build marks the VM as failed')}") print(f" {t('Disk needed:')} {gb}") answer = input( f"{t('Numbers separated by spaces, [all], blank = none:')} " @@ -7261,7 +7501,7 @@ class TODO: _vm.setdefault("desktop", desktop) _vm["name"] = vm_name(_vm["name"], _vm.get("desktop"), suffixes) app_store = self._qemu_ask_app_store(vms) - desktop_tools = self._qemu_ask_desktop_tools(vms) + vm_tools = self._qemu_ask_vm_tools(vms) python_provider = self._qemu_ask_python_provider( [vm["arch"] for vm in vms] ) @@ -7342,7 +7582,7 @@ class TODO: "timezone": timezone, "locale": locale, "desktop": desktop, - "desktop_tools": desktop_tools, + "vm_tools": vm_tools, "python_provider": python_provider, "app_store": app_store, "install": install, @@ -7428,7 +7668,7 @@ class TODO: app_store = spec.get("app_store") or "deb" # Outils de développement : cochés une fois pour tout le parc, puis # filtrés machine par machine (architecture, saveur de bureau). - desktop_tools = tuple(spec.get("desktop_tools") or ()) + vm_tools = tuple(spec.get("vm_tools") or ()) # Branche par VM : « » sur une VM veut dire « celle du formulaire ». branch_map = { vm["name"]: (vm.get("branch") or install_branch or "") @@ -7520,7 +7760,7 @@ class TODO: desktop=desktop_map, python_provider=python_provider, app_store=app_store, - desktop_tools=desktop_tools, + vm_tools=vm_tools, ) else: print( @@ -7538,7 +7778,7 @@ class TODO: desktop=desktop_map.get(name, ""), python_provider=python_provider, app_store=app_store, - desktop_tools=desktop_tools, + vm_tools=vm_tools, ) # Sommaire TOTAL (déploiement + résolution IP + ssh_config + install diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 7ec8eee..0b8770d 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -7688,6 +7688,104 @@ TRANSLATIONS = { "fr": "pas de Xvfb ici : ouvrir PyCharm à la main", "en": "no Xvfb here, open PyCharm by hand", }, + "ERPLibre mobile (build)": { + "fr": "ERPLibre mobile (compilation)", + "en": "ERPLibre mobile (build)", + }, + "APK debug + Vitest, validates the VM": { + "fr": "APK debug + Vitest, valide la VM", + "en": "APK debug + Vitest, validates the VM", + }, + "Building ERPLibre mobile (long)": { + "fr": "Compilation d'ERPLibre mobile (long)", + "en": "Building ERPLibre mobile (long)", + }, + "detailed log in the VM:": { + "fr": "journal détaillé dans la VM :", + "en": "detailed log in the VM:", + }, + "FAILED:": {"fr": "ÉCHEC :", "en": "FAILED:"}, + "probable cause:": {"fr": "cause probable :", "en": "probable cause:"}, + "no known pattern, last lines:": { + "fr": "aucun motif connu, dernières lignes :", + "en": "no known pattern, last lines:", + }, + "a failed mobile build marks the VM as failed": { + "fr": "une compilation mobile en échec marque la VM en échec", + "en": "a failed mobile build marks the VM as failed", + }, + "mobile repository (additive manifest)": { + "fr": "dépôt mobile (manifeste additif)", + "en": "mobile repository (additive manifest)", + }, + "prerequisites of the upstream installer": { + "fr": "prérequis de l'installateur amont", + "en": "prerequisites of the upstream installer", + }, + "Android SDK, licences, NDK": { + "fr": "SDK Android, licences, NDK", + "en": "Android SDK, licences, NDK", + }, + "SDK platform required by the project": { + "fr": "plateforme SDK réclamée par le projet", + "en": "SDK platform required by the project", + }, + "npm dependencies": { + "fr": "dépendances npm", + "en": "npm dependencies", + }, + "web bundle (vite build)": { + "fr": "bundle web (vite build)", + "en": "web bundle (vite build)", + }, + "native sync (capacitor)": { + "fr": "synchronisation native (capacitor)", + "en": "native sync (capacitor)", + }, + "debug APK (gradle)": { + "fr": "APK debug (gradle)", + "en": "debug APK (gradle)", + }, + "Vitest tests": {"fr": "tests Vitest", "en": "Vitest tests"}, + "APK built:": {"fr": "APK construit :", "en": "APK built:"}, + "browser debugging (no Android):": { + "fr": "déverminage au navigateur (sans Android) :", + "en": "browser debugging (no Android):", + }, + "no APK produced": { + "fr": "aucun APK produit", + "en": "no APK produced", + }, + "disk full": {"fr": "disque plein", "en": "disk full"}, + "SDK platform missing": { + "fr": "plateforme SDK absente", + "en": "SDK platform missing", + }, + "SDK not found (ANDROID_HOME)": { + "fr": "SDK introuvable (ANDROID_HOME)", + "en": "SDK not found (ANDROID_HOME)", + }, + "SDK licences not accepted": { + "fr": "licences SDK non acceptées", + "en": "SDK licences not accepted", + }, + "NDK missing": {"fr": "NDK absent", "en": "NDK missing"}, + "JDK/Gradle mismatch": { + "fr": "JDK et Gradle incompatibles", + "en": "JDK/Gradle mismatch", + }, + "Gradle dependency unreachable (network?)": { + "fr": "dépendance Gradle injoignable (réseau ?)", + "en": "Gradle dependency unreachable (network?)", + }, + "Vitest tests failed": { + "fr": "tests Vitest en échec", + "en": "Vitest tests failed", + }, + "Gradle task failed": { + "fr": "tâche Gradle en échec", + "en": "Gradle task failed", + }, "project created, the install will configure it": { "fr": "projet créé, l'installation va le configurer", "en": "project created, the install will configure it", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index bf37b60..1c279ed 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -20,23 +20,27 @@ from script.todo.todo import TODO # noqa: E402 class TestToolFiltering(unittest.TestCase): def setUp(self): self.todo = TODO.__new__(TODO) - self.all = tuple(TODO._QEMU_DESKTOP_TOOLS) + self.all = tuple(TODO._QEMU_VM_TOOLS) def test_a_server_gets_no_graphical_tool(self): - """Un IDE sans bureau n'a rien pour s'afficher.""" - self.assertEqual([], self.todo._qemu_tools_for(self.all, "amd64", "")) + """Un IDE sans bureau n'a rien pour s'afficher. La compilation mobile, + elle, reste : elle compile, elle n'affiche pas.""" + got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu") + for graphical in ("pycharm", "android", "gnome_ext"): + self.assertNotIn(graphical, got) def test_android_studio_is_x86_64_only(self): """Google ne publie aucune archive Linux aarch64 : toutes les variantes de l'URL rendent 404, et product-info.json ne déclare que Linux/amd64. """ self.assertIn( - "android", self.todo._qemu_tools_for(self.all, "amd64", "gnome") + "android", + self.todo._qemu_tools_for(self.all, "amd64", "gnome", "ubuntu"), ) for arch in ("arm64", "s390x"): self.assertNotIn( "android", - self.todo._qemu_tools_for(self.all, arch, "gnome"), + self.todo._qemu_tools_for(self.all, arch, "gnome", "ubuntu"), arch, ) @@ -44,45 +48,59 @@ class TestToolFiltering(unittest.TestCase): for arch in ("amd64", "arm64"): self.assertIn( "pycharm", - self.todo._qemu_tools_for(self.all, arch, "gnome"), + self.todo._qemu_tools_for(self.all, arch, "gnome", "ubuntu"), arch, ) self.assertNotIn( - "pycharm", self.todo._qemu_tools_for(self.all, "s390x", "gnome") + "pycharm", + self.todo._qemu_tools_for(self.all, "s390x", "gnome", "ubuntu"), ) def test_gnome_extensions_only_under_gnome(self): self.assertIn( - "gnome_ext", self.todo._qemu_tools_for(self.all, "amd64", "gnome") + "gnome_ext", + self.todo._qemu_tools_for(self.all, "amd64", "gnome", "ubuntu"), ) self.assertNotIn( "gnome_ext", - self.todo._qemu_tools_for(self.all, "amd64", "cinnamon"), + self.todo._qemu_tools_for(self.all, "amd64", "cinnamon", "ubuntu"), ) def test_unknown_key_is_ignored(self): self.assertEqual( - [], self.todo._qemu_tools_for(("nope",), "amd64", "gnome") + [], + self.todo._qemu_tools_for(("nope",), "amd64", "gnome", "ubuntu"), ) class TestToolDisk(unittest.TestCase): def setUp(self): self.todo = TODO.__new__(TODO) - self.all = tuple(TODO._QEMU_DESKTOP_TOOLS) + self.all = tuple(TODO._QEMU_VM_TOOLS) def test_disk_follows_the_filtering(self): - """Une VM qui ne recevra pas Android Studio ne doit pas se voir gonfler - son disque de ses 8 Go.""" - full = self.todo._qemu_tools_disk_gb(self.all, "amd64", "gnome") - arm = self.todo._qemu_tools_disk_gb(self.all, "arm64", "gnome") + """Une VM qui ne recevra pas un outil ne doit pas se voir gonfler son + disque de sa taille. En arm64 il en manque DEUX : Android Studio, que + Google ne publie qu'en x86_64, et la compilation mobile, qui en + dépend.""" + full = self.todo._qemu_tools_disk_gb( + self.all, "amd64", "gnome", "ubuntu" + ) + arm = self.todo._qemu_tools_disk_gb( + self.all, "arm64", "gnome", "ubuntu" + ) self.assertEqual( - full - arm, TODO._QEMU_DESKTOP_TOOLS["android"]["disk_gb"] + full - arm, + TODO._QEMU_VM_TOOLS["android"]["disk_gb"] + + TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"], ) - def test_a_server_costs_nothing(self): + 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.""" self.assertEqual( - 0, self.todo._qemu_tools_disk_gb(self.all, "amd64", "") + TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"], + self.todo._qemu_tools_disk_gb(self.all, "amd64", "", "ubuntu"), ) def test_the_deploy_command_grows_the_disk(self): @@ -91,7 +109,7 @@ class TestToolDisk(unittest.TestCase): spec = { "ssh_key": "", "desktop": "gnome", - "desktop_tools": self.all, + "vm_tools": self.all, "install": { "branch": "develop", "prod": False, @@ -116,7 +134,7 @@ class TestToolDisk(unittest.TestCase): 20 + TODO.ERPLIBRE_EXTRA_DISK_GB + TODO.QEMU_DESKTOP_EXTRA_DISK_GB - + sum(s["disk_gb"] for s in TODO._QEMU_DESKTOP_TOOLS.values()) + + sum(s["disk_gb"] for s in TODO._QEMU_VM_TOOLS.values()) ) self.assertEqual(f"{expected}G", size) @@ -124,7 +142,7 @@ class TestToolDisk(unittest.TestCase): class TestToolRemoteCommand(unittest.TestCase): def setUp(self): self.todo = TODO.__new__(TODO) - self.all = tuple(TODO._QEMU_DESKTOP_TOOLS) + self.all = tuple(TODO._QEMU_VM_TOOLS) def _sh_ok(self, script): """Le shell accepte-t-il ce script ? « bash -n » ne l'exécute pas.""" @@ -224,6 +242,115 @@ class TestToolDiscoverability(unittest.TestCase): self.assertIn(".idea", cmd) +class TestMobileBuild(unittest.TestCase): + """Compilation ERPLibre mobile : la seule étape qui peut faire échouer la + VM, et la seule qui n'exige pas de bureau.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + 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.""" + got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu") + self.assertEqual(["mobile"], got) + + def test_it_is_bounded_to_apt(self): + """install-android.sh du dépôt mobile commence par « sudo apt install + openjdk-17-jdk » : ailleurs il s'arrête là.""" + for distro in ("fedora", "rocky", "opensuse", "arch"): + self.assertNotIn( + "mobile", + self.todo._qemu_tools_for(self.all, "amd64", "gnome", distro), + distro, + ) + self.assertIn( + "mobile", + self.todo._qemu_tools_for(self.all, "amd64", "gnome", "debian"), + ) + + def test_it_coexists_with_android_studio(self): + """Combinaison croisée : la VM graphique reçoit les deux, et un seul + SDK — celui de $HOME/android, que ANDROID_HOME désigne.""" + got = self.todo._qemu_tools_for(self.all, "amd64", "gnome", "ubuntu") + self.assertIn("android", got) + self.assertIn("mobile", got) + self.assertIn("ANDROID_HOME", self.todo._qemu_mobile_remote_cmd()) + + def test_it_runs_after_the_install_not_before(self): + """Elle a besoin du dépôt, du venv qui synchronise le manifeste, et de + node que « make install_os » installe.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "", "", "deb", ("mobile",) + ) + self.assertLess( + script.index("make install_os"), + script.index("erplibre-mobile-build.log"), + ) + + def test_a_failed_build_fails_the_vm(self): + """Contrat explicite : « pour que ce soit bon », l'app doit compiler. + Le bloc est donc lié par « && » et n'est PAS gardé, à la différence des + outils graphiques.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "", "", "deb", ("mobile",) + ) + tail = script[script.index("erplibre-mobile-build.log") :] + self.assertNotIn("|| true", tail) + self.assertNotIn("|| echo", tail) + + def test_the_build_covers_apk_and_tests(self): + cmd = self.todo._qemu_mobile_remote_cmd() + for step in ( + "update_manifest_local_mobile.sh", + "./install-android.sh", + "npm ci", + "npm run build", + "npx cap sync android", + "./gradlew --no-daemon assembleDebug", + "npm test", + ): + self.assertIn(step, cmd, step) + + def test_the_platform_comes_from_the_project(self): + """L'installateur amont pose android-34, variables.gradle demande + compileSdk 36 : on lit le chiffre plutôt que de le figer.""" + cmd = self.todo._qemu_mobile_remote_cmd() + self.assertIn("android/variables.gradle", cmd) + self.assertIn("platforms;android-$v", cmd) + self.assertNotIn("platforms;android-36", cmd) + + def test_the_apk_is_the_proof(self): + """Une tâche Gradle peut rendre 0 sans rien produire.""" + cmd = self.todo._qemu_mobile_remote_cmd() + self.assertIn("outputs/apk/debug/*.apk", cmd) + + def test_no_apk_means_non_zero(self): + """Éprouvé plutôt que relu : sans APK, le bloc DOIT rendre non nul. + Une ligne d'information placée après le « fi » suffisait à rendre 0 et + à faire repasser la VM au vert.""" + cmd = self.todo._qemu_mobile_remote_cmd() + tail = cmd[cmd.index("apk=$(ls") :] + res = subprocess.run( + ["bash", "-c", "set -e\n" + tail], capture_output=True, text=True + ) + self.assertNotEqual(0, res.returncode, res.stdout) + + def test_every_failure_names_a_cause(self): + """Un journal de dizaines de mégaoctets ne se relit pas : le diagnostic + doit dire pourquoi.""" + cmd = self.todo._qemu_mobile_remote_cmd() + for pattern, _cause in TODO._QEMU_MOBILE_DIAG: + self.assertIn(pattern, cmd, pattern) + self.assertIn('tail -12 "$1"', cmd) + + def test_heavy_output_stays_out_of_the_install_log(self): + """Des centaines de lignes Gradle portant le mot « error » sans être + des pannes rendraient le compteur du tableau de bord inutilisable.""" + cmd = self.todo._qemu_mobile_remote_cmd() + self.assertIn('>> "$M" 2>&1', cmd) + + 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."""