[ADD] todo qemu: build and test the mobile app, and fail the VM if it breaks

A VM was declared ready without anything proving it. The mobile option now
adds the repository to the manifest (additive, so it rides along an Odoo 18
install), runs the mobile repository's own install-android.sh — licences
accepted included — then npm ci, vite build, cap sync, gradlew assembleDebug
and npm test. A failure fails the VM: the exit code reaches the dashboard.

Two gaps in that upstream installer had to be filled: unzip and wget, which
no cloud image ships, and the SDK platform — it installs android-34 while
variables.gradle asks for compileSdk 36, so the number is read from the file
rather than frozen. Gradle writes tens of megabytes and hundreds of harmless
lines carrying the word error: that output goes to a file of its own, and the
log gets the named cause instead.

--- FR ---

Une VM était déclarée prête sans que rien ne le prouve. L'option mobile ajoute
maintenant le dépôt au manifeste (additif, donc il accompagne une installation
Odoo 18), lance l'install-android.sh du dépôt mobile lui-même — licences
acceptées comprises —, puis npm ci, vite build, cap sync, gradlew
assembleDebug et npm test. Un échec fait échouer la VM : le code de sortie
remonte au tableau de bord.

Deux manques de cet installateur amont ont dû être comblés : unzip et wget,
qu'aucune image cloud ne livre, et la plateforme SDK — il pose android-34
quand variables.gradle réclame compileSdk 36, d'où le chiffre lu dans le
fichier plutôt que figé. Gradle écrit des dizaines de mégaoctets et des
centaines de lignes anodines portant le mot error : cette sortie part dans un
fichier à part, et le journal reçoit la cause nommée.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-18 00:50:08 -04:00
parent 6efe16a991
commit 5150cfde9c
7 changed files with 661 additions and 121 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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",

View file

@ -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."""