From 24531f85fff8d6d228b3930ee3ecfcd0d904b1e3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 20 Aug 2026 03:51:14 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20script=20mobile:=20transf=C3=A9rer=20le?= =?UTF-8?q?s=20d=C3=A9p=C3=B4ts=20ERPLibre=20dans=20l'APK,=20et=20le=20v?= =?UTF-8?q?=C3=A9rifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le contournement a vécu : les dépôts n'étaient plus embarqués du tout, l'APK était refusé pour ses 123 678 entrées quand un ZIP en tient 65 535. Ils entrent désormais en packs — tranches de 4 Mo et un index par dépôt disant où trouver chaque fichier — ce qui ramène le compte à 391 entrées sans rien perdre du contenu. Le côté application est dans le dépôt mobile ; ce commit porte la vérification et retire le contournement. Mesuré sur une VM : 139 dépôts, 116 156 fichiers, APK de 282 Mo à 3 002 entrées, et 20 fichiers relus depuis les packs identiques octet pour octet à leur source. L'installation le vérifie et échoue sinon : une application qui ne porte pas le code qu'elle doit montrer n'est pas celle demandée. --- EN --- The stopgap has served its time: the repositories were not embedded at all, and the APK was refused for its 123,678 entries where a ZIP holds 65,535. They now enter as packs — 4 MB slices and one index per repository saying where each file lives — which brings the count to 391 entries without losing any content. The app side lives in the mobile repository; this commit carries the verification and drops the workaround. Measured on a VM: 139 repositories, 116,156 files, a 282 MB APK with 3,002 entries, and 20 files read back from the packs identical byte for byte to their source. The install verifies it and fails otherwise: an app that does not carry the code it must show is not the one that was asked for. Assisted-by: Claude Opus 5 --- script/mobile/__init__.py | 0 script/mobile/check_bundle_transfer.py | 167 +++++++++++++++ script/qemu/README.base.md | 49 +++-- script/qemu/README.fr.md | 26 ++- script/qemu/README.md | 23 +- script/todo/todo.py | 44 ++-- script/todo/todo_i18n.py | 4 + test/test_mobile_bundle_transfer.py | 282 +++++++++++++++++++++++++ test/test_qemu_desktop_tools.py | 70 ++++-- 9 files changed, 594 insertions(+), 71 deletions(-) create mode 100644 script/mobile/__init__.py create mode 100755 script/mobile/check_bundle_transfer.py create mode 100644 test/test_mobile_bundle_transfer.py diff --git a/script/mobile/__init__.py b/script/mobile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/script/mobile/check_bundle_transfer.py b/script/mobile/check_bundle_transfer.py new file mode 100755 index 0000000..44e8c96 --- /dev/null +++ b/script/mobile/check_bundle_transfer.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le transfert des dépôts ERPLibre dans l'application mobile a-t-il eu lieu ? + +L'application embarque le code des dépôts du manifeste pour les parcourir hors +ligne. Ils y entrent sous forme de PACKS : un APK est un ZIP borné à 65535 +entrées, et ces dépôts pèsent plus de 120 000 fichiers — un fichier par source +faisait échouer la compilation sur « Too many zip entries ». Chaque dépôt a donc +un `index.json` qui dit, pour chaque fichier, dans quelle tranche il se trouve, +à quel offset et sur quelle longueur. + +Ce script VÉRIFIE ce transfert, et il est fait pour être exécuté par +l'installation comme à la main : + + ./script/mobile/check_bundle_transfer.py [racine_du_dépôt_mobile] + +Il échoue quand le transfert est vide, quand une tranche manque, ou quand un +index promet des octets que sa tranche n'a pas — trois pannes qu'un simple +« la compilation a réussi » ne dit pas. +""" + +import argparse +import json +import random +import sys +from pathlib import Path + +# En dessous, ce n'est plus un transfert : c'est un bundle vide qu'on aurait pris +# pour bon. Le seul dépôt odoo en porte près de 40 000 à lui seul. +MIN_FILES = 1000 +# Échantillon relu octet pour octet. Tout relire prendrait des minutes pour ne +# rien apprendre de plus : une tranche fausse l'est dès le premier extrait. +SAMPLE = 20 +SEED = 7 + + +def _read_json(path: Path): + return json.loads(path.read_text(encoding="utf-8")) + + +def pack_path(repo_dir: Path, chunk: int) -> Path: + """Nom de tranche, tel que l'écrit le plugin de compilation.""" + return repo_dir / ("pack-%03d.bin" % chunk) + + +def read_from_pack(repo_dir: Path, entry: dict) -> bytes: + """Relit un fichier depuis sa tranche. Lève si l'index et le pack mentent.""" + chunk = entry["chunk"] + path = pack_path(repo_dir, chunk) + size = entry.get("size", 0) + offset = entry.get("offset", 0) + if not path.is_file(): + raise FileNotFoundError(f"tranche absente : {path.name}") + if offset + size > path.stat().st_size: + raise ValueError( + f"{path.name} fait {path.stat().st_size} o, l'index y demande" + f" {size} o à {offset}" + ) + with open(path, "rb") as fh: + fh.seek(offset) + data = fh.read(size) + if len(data) != size: + raise ValueError(f"{path.name} : {len(data)} o lus au lieu de {size}") + return data + + +def check( + mobile_root: Path, workspace: Path = None, min_files: int = None +) -> dict: + """Vérifie le transfert et rend un compte-rendu. + + `workspace` : racine du checkout ERPLibre. Fournie, un échantillon est + comparé OCTET POUR OCTET à la source — c'est la seule vérification qui + prouve un transfert fidèle, et non seulement cohérent. + """ + # Résolu à l'APPEL, et non dans la signature : un défaut lié à la + # définition ne suit pas la constante si un appelant la change. + min_files = MIN_FILES if min_files is None else min_files + base = mobile_root / "dist" / "repos" + manifest = base / "manifest.json" + if not manifest.is_file(): + raise FileNotFoundError( + f"aucun transfert : {manifest} est absent (npm run build ?)" + ) + repos = _read_json(manifest) + report = { + "repos": len(repos), + "files": 0, + "packs": 0, + "checked": 0, + "compared": 0, + } + sample_pool = [] + for proj in repos: + repo_dir = base / proj["slug"] + index = repo_dir / "index.json" + if not index.is_file(): + raise FileNotFoundError(f"{proj['slug']} : index.json absent") + entries = _read_json(index) + files = [ + e + for e in entries + if e.get("type") == "file" and e.get("chunk") is not None + ] + report["files"] += len(files) + report["packs"] += len(list(repo_dir.glob("pack-*.bin"))) + sample_pool += [(proj, repo_dir, e) for e in files if e.get("size")] + + if report["files"] < min_files: + raise ValueError( + f"transfert trop maigre : {report['files']} fichiers pour" + f" {report['repos']} dépôts (au moins {min_files} attendus)" + ) + + random.seed(SEED) + for proj, repo_dir, entry in random.sample( + sample_pool, min(SAMPLE, len(sample_pool)) + ): + data = read_from_pack(repo_dir, entry) + report["checked"] += 1 + if workspace is None: + continue + src = workspace / proj["path"] / entry["path"] + if not src.is_file(): + continue + if data != src.read_bytes(): + raise ValueError( + f"{proj['slug']} : {entry['path']} diffère de la source" + ) + report["compared"] += 1 + return report + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "mobile_root", + nargs="?", + default="mobile/erplibre_home_mobile", + help="racine du dépôt mobile (défaut : mobile/erplibre_home_mobile)", + ) + parser.add_argument( + "--workspace", + default="", + help="racine ERPLibre, pour comparer un échantillon à la source", + ) + args = parser.parse_args() + root = Path(args.mobile_root) + ws = Path(args.workspace) if args.workspace else None + try: + rep = check(root, ws) + except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc: + print(f" ⚠ transfert des dépôts : {exc}") + return 1 + extra = ( + f", {rep['compared']} comparés à la source" if rep["compared"] else "" + ) + print( + f" {rep['repos']} dépôts, {rep['files']} fichiers en" + f" {rep['packs']} tranches ({rep['checked']} relus{extra})" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index 757ae02..8ccca1f 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -305,14 +305,21 @@ machine's RAM, swap and oom-kill count, because a memory cause is proven and not assumed), or too many asset files for one APK. The heavy output goes to `~/erplibre-mobile-build.log` inside the VM so the install log stays readable. -That last cause no longer stops the build. The mobile repo bundles the manifest -repositories into its assets — 122 684 files, for 337 that are the application -— and an APK is a ZIP, capped at 65535 entries: `Too many zip entries 123678`. -The build therefore points `ERPLIBRE_MANIFEST_PATH`, the lever that repo -documents, at an empty manifest, and the plugin says so: `0 repos`. Measured: -`dist` drops from 123 019 files to 336, and the APK comes out at 59 MB with -2 472 entries. Set the variable yourself and the repositories come back — the -default is a stopgap until they fit under the ZIP ceiling. +That last cause is fixed rather than avoided. The app carries the manifest +repositories so their code can be browsed offline, and an APK is a ZIP capped at +65535 entries — one file per source asked for 123 678 and the build stopped +there. Those files now enter as **packs**: 4 MB slices, plus an `index.json` per +repository saying which slice holds a file, at which offset and length. The +reader asks for a byte range, and falls back to the whole slice when the WebView +server ignores `Range` — 4 MB at worst, which is why the slices are bounded. +Raster images are left out: addon screenshots, in a browser that shows text. + +Measured on a VM: 139 repositories, 116 156 files in 391 slices, an APK of +282 MB with **3 002 entries**, and 20 files read back from the packs identical +byte for byte to their source. The install verifies that transfer with +`script/mobile/check_bundle_transfer.py`, which also runs on its own, and a +failed transfer fails the VM — an app that does not carry the code it is meant +to show is not the app that was asked for. It is bounded to apt-based distributions, because that upstream installer starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio @@ -469,15 +476,23 @@ ou trop de fichiers d'assets pour un APK. Le détail va dans `~/erplibre-mobile-build.log`, dans la VM, pour que le journal d'installation reste lisible. -Cette dernière cause n'arrête plus la compilation. Le dépôt mobile empaquette -les dépôts du manifeste dans ses assets — 122 684 fichiers, pour 337 qui sont -l'application — et un APK est un ZIP, borné à 65535 entrées : -`Too many zip entries 123678`. La compilation pointe donc -`ERPLIBRE_MANIFEST_PATH`, le levier que ce dépôt documente, sur un manifeste -vide, et le plugin l'annonce : `0 repos`. Mesuré : `dist` passe de 123 019 -fichiers à 336, et l'APK sort à 59 Mo et 2 472 entrées. Posez la variable -vous-même et les dépôts reviennent — le défaut est une mesure d'attente, le -temps qu'ils tiennent sous le plafond du ZIP. +Cette dernière cause est corrigée, et non contournée. L'application embarque les +dépôts du manifeste pour en parcourir le code hors ligne, et un APK est un ZIP +borné à 65535 entrées — un fichier par source en réclamait 123 678, et la +compilation s'arrêtait là. Ces fichiers y entrent désormais en **packs** : +des tranches de 4 Mo, plus un `index.json` par dépôt qui dit dans quelle tranche +se trouve un fichier, à quel offset et sur quelle longueur. La lecture demande +un intervalle d'octets, et retombe sur la tranche entière quand le serveur du +WebView ignore `Range` — 4 Mo au pire, et c'est pour cela que les tranches sont +bornées. Les images matricielles restent dehors : des captures d'écran +d'addons, dans un navigateur qui montre du texte. + +Mesuré sur une VM : 139 dépôts, 116 156 fichiers en 391 tranches, un APK de +282 Mo à **3 002 entrées**, et 20 fichiers relus depuis les packs identiques +octet pour octet à leur source. L'installation vérifie ce transfert avec +`script/mobile/check_bundle_transfer.py`, qui s'exécute aussi seul, et un +transfert manqué fait échouer la VM — une application qui ne porte pas le code +qu'elle est censée montrer n'est pas l'application demandée. 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 diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index 212dbbd..2f3d995 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -195,15 +195,23 @@ ou trop de fichiers d'assets pour un APK. Le détail va dans `~/erplibre-mobile-build.log`, dans la VM, pour que le journal d'installation reste lisible. -Cette dernière cause n'arrête plus la compilation. Le dépôt mobile empaquette -les dépôts du manifeste dans ses assets — 122 684 fichiers, pour 337 qui sont -l'application — et un APK est un ZIP, borné à 65535 entrées : -`Too many zip entries 123678`. La compilation pointe donc -`ERPLIBRE_MANIFEST_PATH`, le levier que ce dépôt documente, sur un manifeste -vide, et le plugin l'annonce : `0 repos`. Mesuré : `dist` passe de 123 019 -fichiers à 336, et l'APK sort à 59 Mo et 2 472 entrées. Posez la variable -vous-même et les dépôts reviennent — le défaut est une mesure d'attente, le -temps qu'ils tiennent sous le plafond du ZIP. +Cette dernière cause est corrigée, et non contournée. L'application embarque les +dépôts du manifeste pour en parcourir le code hors ligne, et un APK est un ZIP +borné à 65535 entrées — un fichier par source en réclamait 123 678, et la +compilation s'arrêtait là. Ces fichiers y entrent désormais en **packs** : +des tranches de 4 Mo, plus un `index.json` par dépôt qui dit dans quelle tranche +se trouve un fichier, à quel offset et sur quelle longueur. La lecture demande +un intervalle d'octets, et retombe sur la tranche entière quand le serveur du +WebView ignore `Range` — 4 Mo au pire, et c'est pour cela que les tranches sont +bornées. Les images matricielles restent dehors : des captures d'écran +d'addons, dans un navigateur qui montre du texte. + +Mesuré sur une VM : 139 dépôts, 116 156 fichiers en 391 tranches, un APK de +282 Mo à **3 002 entrées**, et 20 fichiers relus depuis les packs identiques +octet pour octet à leur source. L'installation vérifie ce transfert avec +`script/mobile/check_bundle_transfer.py`, qui s'exécute aussi seul, et un +transfert manqué fait échouer la VM — une application qui ne porte pas le code +qu'elle est censée montrer n'est pas l'application demandée. 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 diff --git a/script/qemu/README.md b/script/qemu/README.md index b03f336..3ce5c61 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -179,14 +179,21 @@ machine's RAM, swap and oom-kill count, because a memory cause is proven and not assumed), or too many asset files for one APK. The heavy output goes to `~/erplibre-mobile-build.log` inside the VM so the install log stays readable. -That last cause no longer stops the build. The mobile repo bundles the manifest -repositories into its assets — 122 684 files, for 337 that are the application -— and an APK is a ZIP, capped at 65535 entries: `Too many zip entries 123678`. -The build therefore points `ERPLIBRE_MANIFEST_PATH`, the lever that repo -documents, at an empty manifest, and the plugin says so: `0 repos`. Measured: -`dist` drops from 123 019 files to 336, and the APK comes out at 59 MB with -2 472 entries. Set the variable yourself and the repositories come back — the -default is a stopgap until they fit under the ZIP ceiling. +That last cause is fixed rather than avoided. The app carries the manifest +repositories so their code can be browsed offline, and an APK is a ZIP capped at +65535 entries — one file per source asked for 123 678 and the build stopped +there. Those files now enter as **packs**: 4 MB slices, plus an `index.json` per +repository saying which slice holds a file, at which offset and length. The +reader asks for a byte range, and falls back to the whole slice when the WebView +server ignores `Range` — 4 MB at worst, which is why the slices are bounded. +Raster images are left out: addon screenshots, in a browser that shows text. + +Measured on a VM: 139 repositories, 116 156 files in 391 slices, an APK of +282 MB with **3 002 entries**, and 20 files read back from the packs identical +byte for byte to their source. The install verifies that transfer with +`script/mobile/check_bundle_transfer.py`, which also runs on its own, and a +failed transfer fails the VM — an app that does not carry the code it is meant +to show is not the app that was asked for. It is bounded to apt-based distributions, because that upstream installer starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio diff --git a/script/todo/todo.py b/script/todo/todo.py index 870a2a4..ca88ff4 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -6443,34 +6443,28 @@ class TODO: "else sudo rm -f /swapfile-erplibre 2>/dev/null; " f'echo " {t("no swap could be added; build may run short")}"; ' "fi; fi; " - f'echo " {t("manifest repos not bundled (ZIP entry limit)")}"; ' f'mstep "{t("npm dependencies")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm ci' && " - # L'empaquetage des dépôts du manifeste est DÉSACTIVÉ, et c'est - # une mesure d'attente. Il verse 122 684 fichiers dans les assets — - # des dépôts Odoo entiers, pour 337 qui sont l'application — et un - # APK est un ZIP : « Too many zip entries 123678 (MAX=65535) », la - # compilation s'arrête là. Les mêmes fichiers épuisaient aussi les - # watches inotify de l'IDE. - # - # Le levier est celui que le dépôt mobile documente lui-même - # (doc/SERVICES.md) : ERPLIBRE_MANIFEST_PATH. On le pointe sur un - # manifeste VIDE — « ces dépôts-là : aucun » — et le plugin - # l'annonce, « 0 repos ». Mesuré : dist passe de 123 019 à 336 - # fichiers, l'APK sort à 59 Mo et 2 472 entrées. - # - # Qui veut les dépôts pose la variable lui-même : elle est - # respectée. À retirer quand le projet mobile saura les empaqueter - # sous la limite du ZIP. - # Chaîné par « && » comme le reste : un « ; » ici laisserait la - # compilation web démarrer alors que « npm ci » vient d'échouer. - "printf '' " - '> "$HOME/.erplibre-empty-manifest.xml" && ' f'mstep "{t("web bundle (vite build)")}" ' - f"'cd {el_dir}/mobile/erplibre_home_mobile && " - "ERPLIBRE_MANIFEST_PATH=" - '"${ERPLIBRE_MANIFEST_PATH:-$HOME/.erplibre-empty-manifest.xml}" ' - "npm run build' && " + f"'cd {el_dir}/mobile/erplibre_home_mobile && npm run build' && " + # Le transfert des dépôts du manifeste DANS l'application est + # vérifié, et son compte-rendu se lit dans le journal + # d'installation — d'où l'appel HORS mstep, qui enverrait la sortie + # dans le journal détaillé de la VM. + # + # Ces dépôts entrent en PACKS, et c'est ce qui rend la chose + # possible : un APK est un ZIP borné à 65535 entrées, quand les + # 139 dépôts pèsent plus de 116 000 fichiers. Un fichier par source + # donnait « Too many zip entries 123678 (MAX=65535) » et rien du + # tout ; regroupés, ils tiennent en 391 tranches — mesuré, avec + # 3 002 entrées dans l'APK. + # + # Lié par « && » : un transfert vide fait échouer la VM, au même + # titre qu'un APK manquant. Une application qui ne porte pas le code + # qu'elle est censée montrer n'est pas l'application demandée. + f'echo " -> {t("repo transfer into the app")}" && ' + f"(cd {el_dir} && ./script/mobile/check_bundle_transfer.py" + f" --workspace {el_dir}) && " f'mstep "{t("native sync (capacitor)")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npx cap sync android' && " # UNE seule ABI, celle de la VM — qui est aussi celle de diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 742aae3..0806bb7 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2282,6 +2282,10 @@ TRANSLATIONS = { "fr": "Absent ? Installez virt-viewer :", "en": "Missing? Install virt-viewer:", }, + "repo transfer into the app": { + "fr": "transfert des dépôts dans l'application", + "en": "repo transfer into the app", + }, "graphical session started": { "fr": "session graphique démarrée", "en": "graphical session started", diff --git a/test/test_mobile_bundle_transfer.py b/test/test_mobile_bundle_transfer.py new file mode 100644 index 0000000..a89e490 --- /dev/null +++ b/test/test_mobile_bundle_transfer.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Transfert des dépôts ERPLibre dans l'application mobile. + +L'application embarque le code des dépôts du manifeste pour le parcourir hors +ligne. Ils y entrent en PACKS, et c'est ce qui rend la chose possible : un APK +est un ZIP borné à 65535 entrées, quand les 139 dépôts pèsent plus de 116 000 +fichiers. Un fichier par source donnait « Too many zip entries 123678 +(MAX=65535) » — la compilation s'arrêtait là, et l'application ne portait rien. + +Regroupés en tranches de 4 Mo, ces fichiers tiennent en 391 entrées. Mesuré sur +la VM : 3 002 entrées dans l'APK, 282 Mo, et 20 fichiers relus depuis les packs +identiques octet pour octet à leur source. + +Ce que ces tests vérifient : qu'un transfert vide, tronqué ou incohérent est +DIT, et non pris pour bon. Les trois pannes correspondantes ont chacune leur +fixture. +""" + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from script.mobile import check_bundle_transfer as cbt # noqa: E402 + +# Contenu des sources factices : le nom du fichier -> ses octets. +SOURCES = { + "odoo/release.py": b"version_info = (18, 0)\n", + "odoo/api.py": b"def method():\n return 1\n", + "addons/sale/i18n/fr.po": b'msgid "x"\nmsgstr "y"\n', + "README.md": b"# ERPLibre\n", +} + + +def build_bundle( + tmp: Path, + sources=None, + *, + with_workspace=True, + break_pack=False, + drop_pack=False, + drop_index=False, + no_manifest=False, +): + """Fabrique un faux bundle, et la source qui va avec. + + Les avaries sont paramétrées plutôt que codées en dur : chaque test nomme + celle qu'il éprouve, et la fixture reste unique.""" + sources = SOURCES if sources is None else sources + mobile = tmp / "mobile" / "erplibre_home_mobile" + repos = mobile / "dist" / "repos" + slug = "github-com-ERPLibre-odoo" + repo_dir = repos / slug + repo_dir.mkdir(parents=True) + if not no_manifest: + (repos / "manifest.json").write_text( + json.dumps( + [ + { + "url": "https://github.com/ERPLibre/odoo", + "name": "odoo", + "path": "odoo18.0/odoo", + "slug": slug, + "revision": "18.0", + } + ] + ) + ) + index = [{"path": "odoo", "type": "dir"}] + blob = b"" + items = list(sources.items()) + for pos, (rel, data) in enumerate(items): + # L'avarie ne touche que la DERNIÈRE entrée : gonfler toutes les + # tailles décalerait chaque lecture et ferait échouer la comparaison + # avant le contrôle de bornes — ce n'est pas la panne qu'on éprouve. + last = pos == len(items) - 1 + index.append( + { + "path": rel, + "type": "file", + "chunk": 0, + "offset": len(blob), + "size": len(data) + (7 if (break_pack and last) else 0), + } + ) + blob += data + if not drop_index: + (repo_dir / "index.json").write_text(json.dumps(index)) + if not drop_pack: + (repo_dir / "pack-000.bin").write_bytes(blob) + if with_workspace: + for rel, data in sources.items(): + src = tmp / "odoo18.0/odoo" / rel + src.parent.mkdir(parents=True, exist_ok=True) + src.write_bytes(data) + return mobile + + +class TestAGoodTransfer(unittest.TestCase): + def test_it_counts_repos_files_and_packs(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + rep = cbt.check(mobile, tmp, min_files=1) + self.assertEqual(1, rep["repos"]) + self.assertEqual(len(SOURCES), rep["files"]) + self.assertEqual(1, rep["packs"]) + + def test_it_reads_the_files_back_from_the_pack(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + rep = cbt.check(mobile, tmp, min_files=1) + self.assertEqual(len(SOURCES), rep["checked"]) + + def test_it_compares_them_to_the_source(self): + """La seule vérification qui prouve un transfert FIDÈLE, et pas + seulement cohérent.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + rep = cbt.check(mobile, tmp, min_files=1) + self.assertEqual(len(SOURCES), rep["compared"]) + + def test_without_a_workspace_it_still_reads_the_packs(self): + """Hors du checkout, la comparaison n'est pas possible ; la lecture, + elle, l'est toujours.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, with_workspace=False) + rep = cbt.check(mobile, None, min_files=1) + self.assertEqual(len(SOURCES), rep["checked"]) + self.assertEqual(0, rep["compared"]) + + def test_read_from_pack_returns_the_exact_bytes(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + repo_dir = mobile / "dist/repos/github-com-ERPLibre-odoo" + index = json.loads((repo_dir / "index.json").read_text()) + entry = next( + e for e in index if e["path"] == "addons/sale/i18n/fr.po" + ) + got = cbt.read_from_pack(repo_dir, entry) + self.assertEqual(SOURCES["addons/sale/i18n/fr.po"], got) + + +class TestTheThreeFailures(unittest.TestCase): + """Vide, tronqué, incohérent : trois pannes qu'un « build OK » ne dit pas.""" + + def test_no_manifest_names_the_build(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, no_manifest=True) + with self.assertRaises(FileNotFoundError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("build", str(ctx.exception)) + + def test_a_repo_without_index_is_named(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, drop_index=True) + with self.assertRaises(FileNotFoundError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("odoo", str(ctx.exception)) + + def test_a_missing_pack_is_named(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, drop_pack=True) + with self.assertRaises(FileNotFoundError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("pack-000.bin", str(ctx.exception)) + + def test_an_index_that_promises_too_much_is_refused(self): + """Index et pack d'une compilation différente : le message doit nommer + la tranche et les tailles, pas rendre un octet manquant en silence.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, break_pack=True) + with self.assertRaises(ValueError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("pack-000.bin", str(ctx.exception)) + + def test_an_empty_transfer_is_refused(self): + """C'est le cas qui a existé pendant un temps : le bundle compilait, + sans un seul dépôt dedans. « Réussi » ne voulait rien dire.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, sources={"a.py": b"x\n"}) + with self.assertRaises(ValueError) as ctx: + cbt.check(mobile, tmp) # seuil par défaut + self.assertIn("maigre", str(ctx.exception)) + + def test_a_file_that_differs_from_the_source_is_named(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + (tmp / "odoo18.0/odoo/README.md").write_bytes(b"autre chose\n") + with self.assertRaises(ValueError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("README.md", str(ctx.exception)) + + +class TestTheThreshold(unittest.TestCase): + def test_the_default_threshold_rules_out_an_empty_bundle(self): + """Le seul dépôt odoo en porte près de 40 000 : mille est un plancher + qu'un vrai transfert dépasse de deux ordres de grandeur.""" + self.assertGreaterEqual(cbt.MIN_FILES, 1000) + + def test_the_sample_is_deterministic(self): + """Une graine fixe : deux exécutions lisent les MÊMES fichiers, donc un + échec est reproductible.""" + self.assertIsInstance(cbt.SEED, int) + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + first = cbt.check(mobile, tmp, min_files=1) + second = cbt.check(mobile, tmp, min_files=1) + self.assertEqual(first, second) + + +class TestTheCommandLine(unittest.TestCase): + def test_it_says_the_counts_and_returns_zero(self): + import io + import contextlib + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, sources=SOURCES) + argv = [ + "check_bundle_transfer.py", + str(mobile), + "--workspace", + str(tmp), + ] + buf = io.StringIO() + with unittest.mock.patch.object(sys, "argv", argv), mock_min(1): + with contextlib.redirect_stdout(buf): + code = cbt.main() + self.assertEqual(0, code) + self.assertIn("dépôts", buf.getvalue()) + + def test_a_failure_is_one_line_not_a_traceback(self): + """Le message part dans un journal d'installation : une trace Python y + serait illisible, et la cause noyée.""" + import io + import contextlib + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + argv = ["check_bundle_transfer.py", str(tmp / "nulle-part")] + buf = io.StringIO() + with unittest.mock.patch.object(sys, "argv", argv): + with contextlib.redirect_stdout(buf): + code = cbt.main() + self.assertEqual(1, code) + self.assertIn("⚠", buf.getvalue()) + self.assertNotIn("Traceback", buf.getvalue()) + + +import contextlib as _contextlib # noqa: E402 +import unittest.mock # noqa: E402 + + +@_contextlib.contextmanager +def mock_min(value): + """Abaisse le plancher le temps d'un test de ligne de commande.""" + old = cbt.MIN_FILES + cbt.MIN_FILES = value + try: + yield + finally: + cbt.MIN_FILES = old + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index a71a0be..b115f9f 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -334,7 +334,11 @@ class TestMobileBuild(unittest.TestCase): "sudo() { return 0; }\n" ) - def _run_steps(self, fail_on="RIEN", apk=False): + def _run_steps(self, fail_on="RIEN", apk=False, transfer_ok=True): + """Joue les étapes mobiles avec un « mstep » et un vérificateur de + transfert bouchonnés. Le vérificateur est un VRAI fichier dans l'arbre + d'essai : c'est ainsi qu'on éprouve le chaînage, code de sortie + compris.""" import os import tempfile @@ -344,6 +348,13 @@ class TestMobileBuild(unittest.TestCase): (apk_dir / "outputs/apk/debug").mkdir(parents=True) if apk: (apk_dir / "outputs/apk/debug/app-debug.apk").write_text("x") + checker = el / "script/mobile/check_bundle_transfer.py" + checker.parent.mkdir(parents=True, exist_ok=True) + checker.write_text( + "#!/bin/bash\necho ' 139 depots'\n" + + ("exit 0\n" if transfer_ok else "exit 1\n") + ) + checker.chmod(0o755) steps = self.todo._qemu_mobile_build_steps(str(el)) return subprocess.run( ["bash", "-c", (self.HARNESS % fail_on) + steps], @@ -368,23 +379,58 @@ class TestMobileBuild(unittest.TestCase): self.assertNotIn("vite build", res.stdout) self.assertNotIn("gradle", res.stdout) - def test_the_manifest_repos_are_not_bundled(self): - """122 684 fichiers d'assets pour 337 qui sont l'application, et un APK - est un ZIP borné à 65 535 entrées. Le levier est celui que le dépôt - mobile documente : ERPLIBRE_MANIFEST_PATH, pointé sur un manifeste - vide. Mesuré : dist passe de 123 019 à 336 fichiers.""" + def test_the_manifest_repos_are_bundled_again(self): + """Le contournement a vécu : les dépôts entrent maintenant en PACKS, et + rien ne neutralise plus le manifeste. Mesuré sur la VM : 139 dépôts, + 116 156 fichiers en 391 tranches, 3 002 entrées dans l'APK — là où un + fichier par source en demandait 123 678 pour une limite de 65 535.""" steps = self.todo._qemu_mobile_build_steps("/tmp/el") - self.assertIn("ERPLIBRE_MANIFEST_PATH=", steps) - self.assertIn("", steps) - # La variable posée par l'appelant gagne : qui veut les dépôts les a. - self.assertIn("${ERPLIBRE_MANIFEST_PATH:-", steps) + self.assertNotIn("ERPLIBRE_MANIFEST_PATH", steps) + self.assertNotIn("empty-manifest", steps) - def test_the_empty_manifest_is_written_before_the_bundle(self): + def test_the_transfer_is_verified_after_the_bundle(self): + """Une application qui ne porte pas le code qu'elle est censée montrer + n'est pas l'application demandée : le transfert se vérifie.""" steps = self.todo._qemu_mobile_build_steps("/tmp/el") + self.assertIn("check_bundle_transfer.py", steps) self.assertLess( - steps.index("erplibre-empty-manifest.xml"), steps.index("npm run build"), + steps.index("check_bundle_transfer.py"), ) + self.assertLess( + steps.index("check_bundle_transfer.py"), + steps.index("cap sync"), + ) + + def test_the_transfer_is_compared_to_the_source(self): + """« --workspace » : c'est la comparaison octet pour octet qui prouve un + transfert FIDÈLE, et pas seulement cohérent.""" + steps = self.todo._qemu_mobile_build_steps("/tmp/el") + self.assertIn("--workspace /tmp/el", steps) + + def test_a_failed_transfer_fails_the_vm(self): + """Une application qui ne porte pas le code qu'elle doit montrer n'est + pas l'application demandée. Mesuré au code de sortie, et non à la + présence d'un « && » dans le texte.""" + res = self._run_steps(apk=True, transfer_ok=False) + self.assertNotEqual(0, res.returncode, res.stdout[-300:]) + self.assertNotIn("gradle", res.stdout) + + def test_a_good_transfer_lets_the_build_go_on(self): + res = self._run_steps(apk=True, transfer_ok=True) + self.assertEqual(0, res.returncode, res.stdout[-300:]) + self.assertIn("139 depots", res.stdout) + + def test_the_transfer_line_is_read_in_the_install_log(self): + """Hors mstep, à dessein : mstep renvoie la sortie dans le journal + détaillé de la VM, et c'est le compte des dépôts qu'on veut voir dans + celui de l'installation. Le bouchon imprime une ligne : elle doit + remonter jusqu'à la sortie.""" + res = self._run_steps(apk=True) + self.assertIn("139 depots", res.stdout) + head = self.todo._qemu_mobile_build_steps("/tmp/el") + head = head[: head.index("check_bundle_transfer.py")] + self.assertNotIn("mstep", head[-160:]) def test_a_missing_apk_fails_even_when_gradle_returns_zero(self): """L'APK est la preuve, pas le code de sortie de Gradle : une tâche peut