[FIX] script mobile: transférer les dépôts ERPLibre dans l'APK, et le vérifier

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
This commit is contained in:
Mathieu Benoit 2026-08-20 03:51:14 -04:00
parent 7452c581db
commit 24531f85ff
9 changed files with 594 additions and 71 deletions

View file

View file

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

View file

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

View file

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

View file

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

View file

@ -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 '<manifest></manifest>' "
'> "$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

View file

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

View file

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

View file

@ -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("<manifest></manifest>", 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