[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:
parent
7452c581db
commit
24531f85ff
9 changed files with 594 additions and 71 deletions
0
script/mobile/__init__.py
Normal file
0
script/mobile/__init__.py
Normal file
167
script/mobile/check_bundle_transfer.py
Executable file
167
script/mobile/check_bundle_transfer.py
Executable 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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
282
test/test_mobile_bundle_transfer.py
Normal file
282
test/test_mobile_bundle_transfer.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue