[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
"""Menu de l'émulateur Android : démarrage, fenêtre ou non, tunnel adb.
|
|
|
|
|
|
|
|
|
|
Ce qui se vérifie ici sans VM tient à ce qui a réellement cassé sur la VM de
|
|
|
|
|
preuve : une commande privée de son chemin absolu (« emulator: command not
|
|
|
|
|
found », un ssh non interactif ne lisant aucun rc), un second émulateur sur le
|
|
|
|
|
même AVD (« Running multiple emulators »), une clé d'hôte refusée sur une IP
|
|
|
|
|
recyclée, et un tunnel visant une adresse que l'émulateur n'écoute pas.
|
|
|
|
|
|
|
|
|
|
Le tunnel est le point délicat : l'émulateur n'écoute que sur le 127.0.0.1 de
|
|
|
|
|
la VM (« ss -ltn » dans l'invité ; l'hyperviseur reçoit un refus sur
|
|
|
|
|
IP_VM:5555). Une redirection vers l'IP de la VM ne peut donc PAS aboutir, et
|
|
|
|
|
seul un dernier saut dans la VM place « localhost » au bon endroit.
|
|
|
|
|
"""
|
|
|
|
|
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
import os
|
[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
import socket
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
from unittest import mock
|
|
|
|
|
|
|
|
|
|
sys.argv = ["todo.py"]
|
|
|
|
|
from script.todo.todo import TODO # noqa: E402
|
2026-08-24 04:17:56 -04:00
|
|
|
from script.todo.todo_i18n import t # noqa: E402
|
[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_ok(stdout="", returncode=0, stderr=""):
|
|
|
|
|
return subprocess.CompletedProcess(
|
|
|
|
|
args=[], returncode=returncode, stdout=stdout, stderr=stderr
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSshOptions(unittest.TestCase):
|
|
|
|
|
"""La politique de clés d'hôte n'est PAS la même selon la provenance."""
|
|
|
|
|
|
|
|
|
|
def test_local_vm_tolerates_a_recycled_host_key(self):
|
|
|
|
|
"""Une IP libvirt est réattribuée d'un déploiement au suivant : la clé
|
|
|
|
|
change sous la même adresse et ssh refuse — « Host key verification
|
|
|
|
|
failed », vécu dans ce menu même."""
|
|
|
|
|
opts = TODO._qemu_ssh_opts("virsh")
|
|
|
|
|
self.assertIn("StrictHostKeyChecking=no", opts)
|
|
|
|
|
self.assertIn("UserKnownHostsFile=/dev/null", opts)
|
|
|
|
|
|
|
|
|
|
def test_a_configured_host_keeps_its_own_key_policy(self):
|
|
|
|
|
"""Un hôte de ~/.ssh/config appartient à l'utilisateur : sa clé est un
|
|
|
|
|
garde-fou, et le désarmer en son nom serait une décision volée."""
|
|
|
|
|
opts = TODO._qemu_ssh_opts("ssh_config")
|
|
|
|
|
self.assertNotIn("StrictHostKeyChecking=no", opts)
|
|
|
|
|
self.assertNotIn("UserKnownHostsFile=/dev/null", opts)
|
|
|
|
|
self.assertIn("BatchMode=yes", opts)
|
|
|
|
|
|
|
|
|
|
def test_both_refuse_to_hang_on_a_password_prompt(self):
|
|
|
|
|
for src in ("virsh", "ssh_config"):
|
|
|
|
|
self.assertIn("BatchMode=yes", TODO._qemu_ssh_opts(src), src)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSshTarget(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.todo = TODO.__new__(TODO)
|
|
|
|
|
|
|
|
|
|
def test_a_configured_host_is_named_as_is(self):
|
|
|
|
|
"""C'est l'entrée ssh_config qui porte le ProxyJump : la réécrire à la
|
|
|
|
|
main reviendrait à deviner la chaîne de sauts."""
|
|
|
|
|
self.todo._qemu_resolve_ips = lambda *a, **k: {}
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self.todo._qemu_ssh_target("test-vm_02+proof", "ssh_config"),
|
|
|
|
|
"test-vm_02+proof",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_local_vm_is_reached_by_ip_as_erplibre(self):
|
|
|
|
|
self.todo._qemu_resolve_ips = lambda names, labels=None: {
|
|
|
|
|
"vm-a": "192.168.123.81"
|
|
|
|
|
}
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self.todo._qemu_ssh_target("vm-a", "virsh"),
|
|
|
|
|
"erplibre@192.168.123.81",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_no_ip_yields_no_target_rather_than_a_broken_one(self):
|
|
|
|
|
"""Une VM éteinte n'a pas d'IP. Rendre « erplibre@None » enverrait ssh
|
|
|
|
|
résoudre un nom absurde au lieu de le dire."""
|
|
|
|
|
self.todo._qemu_resolve_ips = lambda names, labels=None: {}
|
|
|
|
|
self.assertEqual(self.todo._qemu_ssh_target("vm-a", "virsh"), "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEmulatorRunning(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.todo = TODO.__new__(TODO)
|
|
|
|
|
|
|
|
|
|
def test_counts_what_pgrep_reports(self):
|
|
|
|
|
with mock.patch("subprocess.run", return_value=_run_ok("1\n")):
|
|
|
|
|
self.assertEqual(self.todo._qemu_emulator_running("h"), 1)
|
|
|
|
|
|
|
|
|
|
def test_reads_the_last_line_so_a_warning_does_not_fool_it(self):
|
|
|
|
|
"""ssh écrit ses avertissements sur stdout dans certains cas ; le compte
|
|
|
|
|
est la DERNIÈRE ligne."""
|
|
|
|
|
with mock.patch(
|
|
|
|
|
"subprocess.run", return_value=_run_ok("Warning: added key\n0\n")
|
|
|
|
|
):
|
|
|
|
|
self.assertEqual(self.todo._qemu_emulator_running("h"), 0)
|
|
|
|
|
|
|
|
|
|
def test_an_unreachable_vm_is_unknown_not_zero(self):
|
|
|
|
|
"""Zéro voudrait dire « libre » et autoriserait un second émulateur sur
|
|
|
|
|
le même AVD. L'inconnu se distingue donc du vide."""
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=OSError):
|
|
|
|
|
self.assertEqual(self.todo._qemu_emulator_running("h"), -1)
|
|
|
|
|
with mock.patch("subprocess.run", return_value=_run_ok("bavardage\n")):
|
|
|
|
|
self.assertEqual(self.todo._qemu_emulator_running("h"), -1)
|
|
|
|
|
|
|
|
|
|
|
[FIX] script todo: ne plus annoncer un émulateur qui n'a pas démarré
Trois défauts vus en conduisant le menu sur de vraies VM. « setsid » détache,
donc son code de retour vaut 0 même quand rien ne se lance : le menu disait
« Démarré » sur une VM sans SDK, dont le journal disait « not found ». Le
démarrage attend maintenant de VOIR le processus, et à défaut cite le journal.
Une sonde préalable lit binaire et AVD d'un coup : une VM déployée sans cocher
l'outil est le cas normal, pas une panne. Vérifié sur deux VM réelles — outillée
(True), migration (« aucun binaire emulator »), sans rien y démarrer.
Et tout ce qui n'était pas « 2 » démarrait l'émulateur : un « n » de travers
suffisait. Au passage, « Choice » n'était traduit dans aucun des trois menus
qui l'affichent.
--- EN ---
Three defects found while driving the menu against real VMs. setsid detaches,
so its exit code is 0 even when nothing launches: the menu said "Started" on a
VM with no SDK, whose log said "not found". Starting now waits to SEE the
process, and quotes the log when it never appears.
A prior probe reads binary and AVD in one go: a VM deployed without ticking the
tool is the normal case, not a failure. Verified on two real VMs — tooled
(True), migration ("no emulator binary") — without starting anything there.
And anything that was not "2" started the emulator; a stray "n" was enough.
Along the way, "Choice" was untranslated in all three menus showing it.
Assisted-by: Claude Opus 5
2026-08-19 03:46:12 -04:00
|
|
|
class TestEmulatorReady(unittest.TestCase):
|
|
|
|
|
"""Une seule lecture répond aux deux questions : binaire, puis AVD."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.todo = TODO.__new__(TODO)
|
|
|
|
|
|
|
|
|
|
def test_a_complete_vm_is_ready(self):
|
|
|
|
|
with mock.patch("subprocess.run", return_value=_run_ok("")):
|
|
|
|
|
self.assertEqual(self.todo._qemu_emulator_ready("h"), (True, ""))
|
|
|
|
|
|
|
|
|
|
def test_the_missing_piece_is_named(self):
|
|
|
|
|
for probe, word in (("NO_SDK\n", "SDK"), ("NO_AVD\n", "AVD")):
|
|
|
|
|
with mock.patch("subprocess.run", return_value=_run_ok(probe)):
|
|
|
|
|
ready, why = self.todo._qemu_emulator_ready("h")
|
|
|
|
|
self.assertFalse(ready, probe)
|
|
|
|
|
self.assertIn(word, why, probe)
|
|
|
|
|
|
|
|
|
|
def test_the_sdk_is_reported_before_the_avd(self):
|
|
|
|
|
"""Sans SDK, l'absence d'AVD n'est qu'une conséquence : nommer la cause
|
|
|
|
|
évite d'envoyer l'utilisateur créer un AVD qu'il ne peut pas créer."""
|
|
|
|
|
with mock.patch(
|
|
|
|
|
"subprocess.run", return_value=_run_ok("NO_SDK\nNO_AVD\n")
|
|
|
|
|
):
|
|
|
|
|
_, why = self.todo._qemu_emulator_ready("h")
|
|
|
|
|
self.assertIn("SDK", why)
|
|
|
|
|
|
|
|
|
|
def test_an_unreachable_vm_is_not_declared_ready(self):
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=OSError):
|
|
|
|
|
ready, why = self.todo._qemu_emulator_ready("h")
|
|
|
|
|
self.assertFalse(ready)
|
|
|
|
|
self.assertTrue(why)
|
|
|
|
|
|
|
|
|
|
|
[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
class _MenuCase(unittest.TestCase):
|
|
|
|
|
"""Socle commun : une VM locale, des réponses scriptées, aucun vrai ssh."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.todo = TODO.__new__(TODO)
|
|
|
|
|
self.todo._ssh_config_hosts = lambda: []
|
|
|
|
|
self.todo._qemu_list_domains = lambda: ["vm-a"]
|
|
|
|
|
self.todo._qemu_resolve_ips = lambda names, labels=None: {
|
|
|
|
|
"vm-a": "192.168.123.81"
|
|
|
|
|
}
|
|
|
|
|
self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True))
|
|
|
|
|
self.calls = []
|
|
|
|
|
|
[FIX] script todo: régler l'écran de l'émulateur au lancement, densité comprise
Écrire hw.lcd.* dans le config.ini de l'AVD ne servait à rien : l'émulateur
réécrit ce fichier depuis le profil du téléphone au premier démarrage, et l'AVD
repartait en 1080x2400 densité 420 — quatre fois les pixels voulus. Constaté sur
la VM, où le réglage était censé s'appliquer depuis des semaines.
La taille passe donc au lancement, et la DENSITÉ avec elle. C'est ce point qui
surprend, et il est mesuré sur une charge identique : 540x1140 en densité 420 est
PIRE que le plein écran — 81 ms de médiane contre 40, 57 % d'images en retard
contre 37, tout étant rendu énorme. En densité 240 : 38 ms, 32 %, et le 99e
centile tombe de 950 ms à 250.
« -no-snapshot-save » vient avec : ce menu propose de tuer l'émulateur par
pkill, et le lancement suivant mourait alors sur « A snapshot operation is
pending ». Vérifié après un pkill : plus aucun FATAL.
--- EN ---
Writing hw.lcd.* into the AVD's config.ini did nothing: the emulator rewrites
that file from the phone profile on first boot, and the AVD came back at
1080x2400 density 420 — four times the intended pixels. Found on the VM, where
the setting was supposed to have applied for weeks.
The size therefore moves to launch time, and the DENSITY with it. That is the
surprising part, measured on an identical workload: 540x1140 at density 420 is
WORSE than the full screen — 81 ms median against 40, 57 % janky frames against
37, everything rendered huge. At density 240: 38 ms, 32 %, and the 99th
percentile drops from 950 ms to 250.
"-no-snapshot-save" comes along: this menu offers to kill the emulator with
pkill, and the next start then died on "A snapshot operation is pending".
Checked after a pkill: no FATAL left.
Assisted-by: Claude Opus 5
2026-08-21 21:49:44 -04:00
|
|
|
def _play(
|
|
|
|
|
self,
|
|
|
|
|
answers,
|
|
|
|
|
running=0,
|
|
|
|
|
start_rc=0,
|
|
|
|
|
port_taken=False,
|
|
|
|
|
probe="",
|
|
|
|
|
running_after=1,
|
|
|
|
|
log="rien",
|
|
|
|
|
):
|
[FIX] script todo: ne plus annoncer un émulateur qui n'a pas démarré
Trois défauts vus en conduisant le menu sur de vraies VM. « setsid » détache,
donc son code de retour vaut 0 même quand rien ne se lance : le menu disait
« Démarré » sur une VM sans SDK, dont le journal disait « not found ». Le
démarrage attend maintenant de VOIR le processus, et à défaut cite le journal.
Une sonde préalable lit binaire et AVD d'un coup : une VM déployée sans cocher
l'outil est le cas normal, pas une panne. Vérifié sur deux VM réelles — outillée
(True), migration (« aucun binaire emulator »), sans rien y démarrer.
Et tout ce qui n'était pas « 2 » démarrait l'émulateur : un « n » de travers
suffisait. Au passage, « Choice » n'était traduit dans aucun des trois menus
qui l'affichent.
--- EN ---
Three defects found while driving the menu against real VMs. setsid detaches,
so its exit code is 0 even when nothing launches: the menu said "Started" on a
VM with no SDK, whose log said "not found". Starting now waits to SEE the
process, and quotes the log when it never appears.
A prior probe reads binary and AVD in one go: a VM deployed without ticking the
tool is the normal case, not a failure. Verified on two real VMs — tooled
(True), migration ("no emulator binary") — without starting anything there.
And anything that was not "2" started the emulator; a stray "n" was enough.
Along the way, "Choice" was untranslated in all three menus showing it.
Assisted-by: Claude Opus 5
2026-08-19 03:46:12 -04:00
|
|
|
"""Joue le menu avec des réponses données ; rend (sortie, commandes).
|
|
|
|
|
|
|
|
|
|
« running » est le compte AVANT le démarrage, « running_after » celui
|
|
|
|
|
d'après : c'est cette distinction qui dit si l'émulateur a réellement
|
|
|
|
|
pris, le code de retour d'un « setsid » détaché ne valant rien.
|
|
|
|
|
"""
|
|
|
|
|
state = {"started": False}
|
[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
|
|
|
|
|
def fake_run(cmd, *a, **k):
|
|
|
|
|
self.calls.append(cmd)
|
|
|
|
|
joined = " ".join(cmd)
|
|
|
|
|
if "pgrep -c qemu-system" in joined:
|
[FIX] script todo: ne plus annoncer un émulateur qui n'a pas démarré
Trois défauts vus en conduisant le menu sur de vraies VM. « setsid » détache,
donc son code de retour vaut 0 même quand rien ne se lance : le menu disait
« Démarré » sur une VM sans SDK, dont le journal disait « not found ». Le
démarrage attend maintenant de VOIR le processus, et à défaut cite le journal.
Une sonde préalable lit binaire et AVD d'un coup : une VM déployée sans cocher
l'outil est le cas normal, pas une panne. Vérifié sur deux VM réelles — outillée
(True), migration (« aucun binaire emulator »), sans rien y démarrer.
Et tout ce qui n'était pas « 2 » démarrait l'émulateur : un « n » de travers
suffisait. Au passage, « Choice » n'était traduit dans aucun des trois menus
qui l'affichent.
--- EN ---
Three defects found while driving the menu against real VMs. setsid detaches,
so its exit code is 0 even when nothing launches: the menu said "Started" on a
VM with no SDK, whose log said "not found". Starting now waits to SEE the
process, and quotes the log when it never appears.
A prior probe reads binary and AVD in one go: a VM deployed without ticking the
tool is the normal case, not a failure. Verified on two real VMs — tooled
(True), migration ("no emulator binary") — without starting anything there.
And anything that was not "2" started the emulator; a stray "n" was enough.
Along the way, "Choice" was untranslated in all three menus showing it.
Assisted-by: Claude Opus 5
2026-08-19 03:46:12 -04:00
|
|
|
n = running_after if state["started"] else running
|
|
|
|
|
return _run_ok(f"{n}\n")
|
|
|
|
|
if "NO_SDK" in joined:
|
|
|
|
|
return _run_ok(probe)
|
[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
if "setsid" in joined:
|
[FIX] script todo: ne plus annoncer un émulateur qui n'a pas démarré
Trois défauts vus en conduisant le menu sur de vraies VM. « setsid » détache,
donc son code de retour vaut 0 même quand rien ne se lance : le menu disait
« Démarré » sur une VM sans SDK, dont le journal disait « not found ». Le
démarrage attend maintenant de VOIR le processus, et à défaut cite le journal.
Une sonde préalable lit binaire et AVD d'un coup : une VM déployée sans cocher
l'outil est le cas normal, pas une panne. Vérifié sur deux VM réelles — outillée
(True), migration (« aucun binaire emulator »), sans rien y démarrer.
Et tout ce qui n'était pas « 2 » démarrait l'émulateur : un « n » de travers
suffisait. Au passage, « Choice » n'était traduit dans aucun des trois menus
qui l'affichent.
--- EN ---
Three defects found while driving the menu against real VMs. setsid detaches,
so its exit code is 0 even when nothing launches: the menu said "Started" on a
VM with no SDK, whose log said "not found". Starting now waits to SEE the
process, and quotes the log when it never appears.
A prior probe reads binary and AVD in one go: a VM deployed without ticking the
tool is the normal case, not a failure. Verified on two real VMs — tooled
(True), migration ("no emulator binary") — without starting anything there.
And anything that was not "2" started the emulator; a stray "n" was enough.
Along the way, "Choice" was untranslated in all three menus showing it.
Assisted-by: Claude Opus 5
2026-08-19 03:46:12 -04:00
|
|
|
state["started"] = True
|
[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
return _run_ok(returncode=start_rc, stderr="boum")
|
[FIX] script todo: ne plus annoncer un émulateur qui n'a pas démarré
Trois défauts vus en conduisant le menu sur de vraies VM. « setsid » détache,
donc son code de retour vaut 0 même quand rien ne se lance : le menu disait
« Démarré » sur une VM sans SDK, dont le journal disait « not found ». Le
démarrage attend maintenant de VOIR le processus, et à défaut cite le journal.
Une sonde préalable lit binaire et AVD d'un coup : une VM déployée sans cocher
l'outil est le cas normal, pas une panne. Vérifié sur deux VM réelles — outillée
(True), migration (« aucun binaire emulator »), sans rien y démarrer.
Et tout ce qui n'était pas « 2 » démarrait l'émulateur : un « n » de travers
suffisait. Au passage, « Choice » n'était traduit dans aucun des trois menus
qui l'affichent.
--- EN ---
Three defects found while driving the menu against real VMs. setsid detaches,
so its exit code is 0 even when nothing launches: the menu said "Started" on a
VM with no SDK, whose log said "not found". Starting now waits to SEE the
process, and quotes the log when it never appears.
A prior probe reads binary and AVD in one go: a VM deployed without ticking the
tool is the normal case, not a failure. Verified on two real VMs — tooled
(True), migration ("no emulator binary") — without starting anything there.
And anything that was not "2" started the emulator; a stray "n" was enough.
Along the way, "Choice" was untranslated in all three menus showing it.
Assisted-by: Claude Opus 5
2026-08-19 03:46:12 -04:00
|
|
|
if "tail -5" in joined:
|
|
|
|
|
return _run_ok(log)
|
[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
return _run_ok()
|
|
|
|
|
|
|
|
|
|
it = iter(answers)
|
[REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.
--- EN ---
This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.
Assisted-by: Claude Opus 5
2026-09-24 13:30:31 -04:00
|
|
|
with (
|
|
|
|
|
mock.patch("subprocess.run", side_effect=fake_run),
|
|
|
|
|
mock.patch("builtins.input", lambda *a: next(it)),
|
|
|
|
|
mock.patch.object(
|
|
|
|
|
TODO, "_port_in_use", staticmethod(lambda p: port_taken)
|
|
|
|
|
),
|
|
|
|
|
mock.patch("script.todo.todo.time.sleep", lambda *a: None),
|
|
|
|
|
mock.patch(
|
|
|
|
|
"sys.stdout", new_callable=__import__("io").StringIO
|
|
|
|
|
) as out,
|
|
|
|
|
):
|
[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
self.todo._qemu_emulator_menu()
|
|
|
|
|
return out.getvalue(), self.calls
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _started(calls):
|
|
|
|
|
return [c for c in calls if any("setsid" in x for x in c)]
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _tunnels(calls):
|
|
|
|
|
return [c for c in calls if "-f" in c and "-N" in c]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEmulatorMenu(_MenuCase):
|
|
|
|
|
def test_no_target_at_all_says_so_without_touching_ssh(self):
|
|
|
|
|
self.todo._qemu_list_domains = lambda: []
|
|
|
|
|
out, calls = self._play([])
|
|
|
|
|
self.assertIn("~/.ssh/config", out)
|
|
|
|
|
self.assertEqual(calls, [])
|
|
|
|
|
|
|
|
|
|
def test_an_off_vm_is_reported_before_any_start(self):
|
|
|
|
|
self.todo._qemu_resolve_ips = lambda names, labels=None: {}
|
|
|
|
|
out, calls = self._play(["1"])
|
|
|
|
|
self.assertIn("IP", out.upper())
|
|
|
|
|
self.assertEqual(self._started(calls), [])
|
|
|
|
|
|
|
|
|
|
def test_a_running_emulator_is_seen_and_refusing_stops_there(self):
|
|
|
|
|
"""Deux émulateurs sur un même AVD, et le second meurt sur « Running
|
|
|
|
|
multiple emulators with the same AVD » — vécu deux fois. On le dit
|
|
|
|
|
AVANT, et un refus ne démarre rien."""
|
|
|
|
|
out, calls = self._play(["1", "n"], running=1)
|
|
|
|
|
self.assertIn("pkill -f", out)
|
|
|
|
|
# « [q]emu » : la classe empêche le pkill de se trouver lui-même.
|
|
|
|
|
self.assertIn("[q]emu-system", out)
|
|
|
|
|
self.assertEqual(self._started(calls), [])
|
|
|
|
|
|
|
|
|
|
def test_accepting_closes_the_other_one_then_starts(self):
|
|
|
|
|
out, calls = self._play(["1", "o", "1", "n"], running=1)
|
|
|
|
|
killed = [c for c in calls if any("pkill" in x for x in c)]
|
|
|
|
|
self.assertTrue(killed, "aucun pkill envoyé")
|
|
|
|
|
self.assertTrue(self._started(calls), "rien démarré après fermeture")
|
|
|
|
|
|
|
|
|
|
def test_the_window_choice_is_delegated_to_the_workstation(self):
|
|
|
|
|
"""L'écran appartient au poste : cette commande ne peut pas partir de
|
|
|
|
|
l'hyperviseur, qui n'a aucun affichage à lui prêter."""
|
|
|
|
|
out, calls = self._play(["1", "2"])
|
|
|
|
|
self.assertIn("ssh -XC", out)
|
|
|
|
|
self.assertEqual(self._started(calls), [])
|
|
|
|
|
self.assertEqual(self._tunnels(calls), [])
|
|
|
|
|
|
|
|
|
|
def test_the_headless_start_carries_what_the_vm_needs(self):
|
|
|
|
|
out, calls = self._play(["1", "1", "n"])
|
|
|
|
|
started = self._started(calls)
|
|
|
|
|
self.assertEqual(len(started), 1)
|
|
|
|
|
cmd = started[0][-1]
|
|
|
|
|
# Chemin absolu : « ssh hôte 'commande' » ne lit ni ~/.profile ni
|
|
|
|
|
# ~/.bashrc, et « emulator » seul rend « command not found » — vécu.
|
|
|
|
|
self.assertIn("$HOME/android/emulator/emulator", cmd)
|
|
|
|
|
self.assertIn("-no-window", cmd)
|
|
|
|
|
# sg kvm : sans le groupe, l'émulateur n'a pas /dev/kvm et renonce.
|
|
|
|
|
self.assertIn("sg kvm", cmd)
|
|
|
|
|
# setsid : il doit survivre à la fermeture de ce ssh.
|
|
|
|
|
self.assertIn("setsid -f", cmd)
|
|
|
|
|
self.assertIn("/tmp/erplibre-emulator.log", cmd)
|
|
|
|
|
|
[FIX] script todo: régler l'écran de l'émulateur au lancement, densité comprise
Écrire hw.lcd.* dans le config.ini de l'AVD ne servait à rien : l'émulateur
réécrit ce fichier depuis le profil du téléphone au premier démarrage, et l'AVD
repartait en 1080x2400 densité 420 — quatre fois les pixels voulus. Constaté sur
la VM, où le réglage était censé s'appliquer depuis des semaines.
La taille passe donc au lancement, et la DENSITÉ avec elle. C'est ce point qui
surprend, et il est mesuré sur une charge identique : 540x1140 en densité 420 est
PIRE que le plein écran — 81 ms de médiane contre 40, 57 % d'images en retard
contre 37, tout étant rendu énorme. En densité 240 : 38 ms, 32 %, et le 99e
centile tombe de 950 ms à 250.
« -no-snapshot-save » vient avec : ce menu propose de tuer l'émulateur par
pkill, et le lancement suivant mourait alors sur « A snapshot operation is
pending ». Vérifié après un pkill : plus aucun FATAL.
--- EN ---
Writing hw.lcd.* into the AVD's config.ini did nothing: the emulator rewrites
that file from the phone profile on first boot, and the AVD came back at
1080x2400 density 420 — four times the intended pixels. Found on the VM, where
the setting was supposed to have applied for weeks.
The size therefore moves to launch time, and the DENSITY with it. That is the
surprising part, measured on an identical workload: 540x1140 at density 420 is
WORSE than the full screen — 81 ms median against 40, 57 % janky frames against
37, everything rendered huge. At density 240: 38 ms, 32 %, and the 99th
percentile drops from 950 ms to 250.
"-no-snapshot-save" comes along: this menu offers to kill the emulator with
pkill, and the next start then died on "A snapshot operation is pending".
Checked after a pkill: no FATAL left.
Assisted-by: Claude Opus 5
2026-08-21 21:49:44 -04:00
|
|
|
def test_the_start_carries_the_shared_flags(self):
|
|
|
|
|
"""Une seule autorité pour ces drapeaux : le lancement du menu, la
|
|
|
|
|
commande fenêtrée et celle de l'étape AVD doivent dire la même chose.
|
|
|
|
|
La densité va avec la résolution — 540x1140 en densité 420 est mesuré
|
|
|
|
|
PIRE que le plein écran."""
|
|
|
|
|
_, calls = self._play(["1", "1", "n"])
|
|
|
|
|
cmd = self._started(calls)[0][-1]
|
|
|
|
|
for flag in (
|
|
|
|
|
"-skin 540x1140",
|
|
|
|
|
"qemu.sf.lcd_density=240",
|
|
|
|
|
"-no-snapshot-save",
|
|
|
|
|
"-gpu swangle",
|
|
|
|
|
):
|
|
|
|
|
self.assertIn(flag, cmd, flag)
|
|
|
|
|
|
|
|
|
|
def test_the_windowed_command_carries_them_too(self):
|
|
|
|
|
"""Qui ouvre la fenêtre depuis son poste doit avoir le même écran que
|
|
|
|
|
qui passe par scrcpy — sinon la comparaison ne veut rien dire."""
|
|
|
|
|
out, _ = self._play(["1", "2"])
|
|
|
|
|
self.assertIn("-skin 540x1140", out)
|
|
|
|
|
self.assertIn("qemu.sf.lcd_density=240", out)
|
|
|
|
|
|
[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
def test_the_start_command_is_valid_shell(self):
|
|
|
|
|
"""Une apostrophe ou un guillemet de trop, et la VM répond par une
|
|
|
|
|
erreur de syntaxe — déjà rencontré dans ce même fichier."""
|
|
|
|
|
_, calls = self._play(["1", "1", "n"])
|
|
|
|
|
cmd = self._started(calls)[0][-1]
|
|
|
|
|
res = subprocess.run(
|
|
|
|
|
["bash", "-n"], input=cmd, capture_output=True, text=True
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(res.returncode, 0, res.stderr)
|
|
|
|
|
|
|
|
|
|
def test_a_failed_start_does_not_offer_a_tunnel_to_nothing(self):
|
|
|
|
|
out, calls = self._play(["1", "1"], start_rc=1)
|
|
|
|
|
self.assertIn("boum", out)
|
|
|
|
|
self.assertEqual(self._tunnels(calls), [])
|
|
|
|
|
|
[FIX] script todo: ne plus annoncer un émulateur qui n'a pas démarré
Trois défauts vus en conduisant le menu sur de vraies VM. « setsid » détache,
donc son code de retour vaut 0 même quand rien ne se lance : le menu disait
« Démarré » sur une VM sans SDK, dont le journal disait « not found ». Le
démarrage attend maintenant de VOIR le processus, et à défaut cite le journal.
Une sonde préalable lit binaire et AVD d'un coup : une VM déployée sans cocher
l'outil est le cas normal, pas une panne. Vérifié sur deux VM réelles — outillée
(True), migration (« aucun binaire emulator »), sans rien y démarrer.
Et tout ce qui n'était pas « 2 » démarrait l'émulateur : un « n » de travers
suffisait. Au passage, « Choice » n'était traduit dans aucun des trois menus
qui l'affichent.
--- EN ---
Three defects found while driving the menu against real VMs. setsid detaches,
so its exit code is 0 even when nothing launches: the menu said "Started" on a
VM with no SDK, whose log said "not found". Starting now waits to SEE the
process, and quotes the log when it never appears.
A prior probe reads binary and AVD in one go: a VM deployed without ticking the
tool is the normal case, not a failure. Verified on two real VMs — tooled
(True), migration ("no emulator binary") — without starting anything there.
And anything that was not "2" started the emulator; a stray "n" was enough.
Along the way, "Choice" was untranslated in all three menus showing it.
Assisted-by: Claude Opus 5
2026-08-19 03:46:12 -04:00
|
|
|
def test_a_vm_without_the_sdk_is_diagnosed_before_anything_else(self):
|
|
|
|
|
"""Une VM déployée sans cocher l'outil est le cas NORMAL. Le menu le
|
|
|
|
|
dit avant même de demander la fenêtre — mesuré sur une VM de migration,
|
[FIX] script todo: régler l'écran de l'émulateur au lancement, densité comprise
Écrire hw.lcd.* dans le config.ini de l'AVD ne servait à rien : l'émulateur
réécrit ce fichier depuis le profil du téléphone au premier démarrage, et l'AVD
repartait en 1080x2400 densité 420 — quatre fois les pixels voulus. Constaté sur
la VM, où le réglage était censé s'appliquer depuis des semaines.
La taille passe donc au lancement, et la DENSITÉ avec elle. C'est ce point qui
surprend, et il est mesuré sur une charge identique : 540x1140 en densité 420 est
PIRE que le plein écran — 81 ms de médiane contre 40, 57 % d'images en retard
contre 37, tout étant rendu énorme. En densité 240 : 38 ms, 32 %, et le 99e
centile tombe de 950 ms à 250.
« -no-snapshot-save » vient avec : ce menu propose de tuer l'émulateur par
pkill, et le lancement suivant mourait alors sur « A snapshot operation is
pending ». Vérifié après un pkill : plus aucun FATAL.
--- EN ---
Writing hw.lcd.* into the AVD's config.ini did nothing: the emulator rewrites
that file from the phone profile on first boot, and the AVD came back at
1080x2400 density 420 — four times the intended pixels. Found on the VM, where
the setting was supposed to have applied for weeks.
The size therefore moves to launch time, and the DENSITY with it. That is the
surprising part, measured on an identical workload: 540x1140 at density 420 is
WORSE than the full screen — 81 ms median against 40, 57 % janky frames against
37, everything rendered huge. At density 240: 38 ms, 32 %, and the 99th
percentile drops from 950 ms to 250.
"-no-snapshot-save" comes along: this menu offers to kill the emulator with
pkill, and the next start then died on "A snapshot operation is pending".
Checked after a pkill: no FATAL left.
Assisted-by: Claude Opus 5
2026-08-21 21:49:44 -04:00
|
|
|
où le démarrage détaché rendait 0 et le journal disait « not found ».
|
|
|
|
|
"""
|
[FIX] script todo: ne plus annoncer un émulateur qui n'a pas démarré
Trois défauts vus en conduisant le menu sur de vraies VM. « setsid » détache,
donc son code de retour vaut 0 même quand rien ne se lance : le menu disait
« Démarré » sur une VM sans SDK, dont le journal disait « not found ». Le
démarrage attend maintenant de VOIR le processus, et à défaut cite le journal.
Une sonde préalable lit binaire et AVD d'un coup : une VM déployée sans cocher
l'outil est le cas normal, pas une panne. Vérifié sur deux VM réelles — outillée
(True), migration (« aucun binaire emulator »), sans rien y démarrer.
Et tout ce qui n'était pas « 2 » démarrait l'émulateur : un « n » de travers
suffisait. Au passage, « Choice » n'était traduit dans aucun des trois menus
qui l'affichent.
--- EN ---
Three defects found while driving the menu against real VMs. setsid detaches,
so its exit code is 0 even when nothing launches: the menu said "Started" on a
VM with no SDK, whose log said "not found". Starting now waits to SEE the
process, and quotes the log when it never appears.
A prior probe reads binary and AVD in one go: a VM deployed without ticking the
tool is the normal case, not a failure. Verified on two real VMs — tooled
(True), migration ("no emulator binary") — without starting anything there.
And anything that was not "2" started the emulator; a stray "n" was enough.
Along the way, "Choice" was untranslated in all three menus showing it.
Assisted-by: Claude Opus 5
2026-08-19 03:46:12 -04:00
|
|
|
out, calls = self._play(["1"], probe="NO_SDK\n")
|
|
|
|
|
self.assertIn("SDK", out)
|
2026-08-24 04:17:56 -04:00
|
|
|
# Découpé sur la TRADUCTION : ce test échouait dès que l'interface
|
|
|
|
|
# passait en anglais, pour une raison qui n'a rien à voir avec le
|
|
|
|
|
# diagnostic qu'il vérifie.
|
|
|
|
|
self.assertNotIn("[1]", out.split(t("local VM"))[-1])
|
[FIX] script todo: ne plus annoncer un émulateur qui n'a pas démarré
Trois défauts vus en conduisant le menu sur de vraies VM. « setsid » détache,
donc son code de retour vaut 0 même quand rien ne se lance : le menu disait
« Démarré » sur une VM sans SDK, dont le journal disait « not found ». Le
démarrage attend maintenant de VOIR le processus, et à défaut cite le journal.
Une sonde préalable lit binaire et AVD d'un coup : une VM déployée sans cocher
l'outil est le cas normal, pas une panne. Vérifié sur deux VM réelles — outillée
(True), migration (« aucun binaire emulator »), sans rien y démarrer.
Et tout ce qui n'était pas « 2 » démarrait l'émulateur : un « n » de travers
suffisait. Au passage, « Choice » n'était traduit dans aucun des trois menus
qui l'affichent.
--- EN ---
Three defects found while driving the menu against real VMs. setsid detaches,
so its exit code is 0 even when nothing launches: the menu said "Started" on a
VM with no SDK, whose log said "not found". Starting now waits to SEE the
process, and quotes the log when it never appears.
A prior probe reads binary and AVD in one go: a VM deployed without ticking the
tool is the normal case, not a failure. Verified on two real VMs — tooled
(True), migration ("no emulator binary") — without starting anything there.
And anything that was not "2" started the emulator; a stray "n" was enough.
Along the way, "Choice" was untranslated in all three menus showing it.
Assisted-by: Claude Opus 5
2026-08-19 03:46:12 -04:00
|
|
|
self.assertEqual(self._started(calls), [])
|
|
|
|
|
|
|
|
|
|
def test_a_vm_without_the_avd_is_named_as_such(self):
|
|
|
|
|
out, calls = self._play(["1"], probe="NO_AVD\n")
|
|
|
|
|
self.assertIn("AVD", out)
|
|
|
|
|
self.assertEqual(self._started(calls), [])
|
|
|
|
|
|
|
|
|
|
def test_a_stray_answer_cancels_instead_of_starting(self):
|
|
|
|
|
"""« n » à une question à deux crans partait démarrer l'émulateur :
|
|
|
|
|
tout ce qui n'était pas « 2 » valait « sans fenêtre ». Observé."""
|
|
|
|
|
for stray in ("n", "3", "oui"):
|
|
|
|
|
self.calls = []
|
|
|
|
|
out, calls = self._play(["1", stray])
|
|
|
|
|
self.assertEqual(self._started(calls), [], stray)
|
|
|
|
|
|
|
|
|
|
def test_a_start_that_never_appears_reports_the_log_not_a_success(self):
|
|
|
|
|
"""Le code de retour d'un « setsid » détaché vaut 0 quoi qu'il arrive :
|
|
|
|
|
seule la présence du processus prouve le démarrage."""
|
|
|
|
|
out, calls = self._play(
|
|
|
|
|
["1", "1"], running_after=0, log="emulator: not found"
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("not found", out)
|
|
|
|
|
self.assertNotIn("scrcpy -s", out)
|
|
|
|
|
|
[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
def test_a_successful_start_chains_into_the_tunnel_help(self):
|
|
|
|
|
out, _ = self._play(["1", "1", "n"])
|
|
|
|
|
self.assertIn("scrcpy", out)
|
|
|
|
|
self.assertIn("adb connect localhost:5555", out)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestScrcpyTunnel(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.todo = TODO.__new__(TODO)
|
|
|
|
|
self.todo._qemu_resolve_ips = lambda names, labels=None: {
|
|
|
|
|
"vm-a": "192.168.123.81"
|
|
|
|
|
}
|
|
|
|
|
self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True))
|
|
|
|
|
self.calls = []
|
|
|
|
|
|
|
|
|
|
def _play(
|
|
|
|
|
self,
|
|
|
|
|
src="virsh",
|
|
|
|
|
answers=("n",),
|
|
|
|
|
rc=0,
|
|
|
|
|
port_taken=False,
|
|
|
|
|
started=False,
|
|
|
|
|
name="vm-a",
|
|
|
|
|
):
|
|
|
|
|
def fake_run(cmd, *a, **k):
|
|
|
|
|
self.calls.append(cmd)
|
|
|
|
|
return _run_ok(returncode=rc, stderr="refus")
|
|
|
|
|
|
|
|
|
|
it = iter(answers)
|
[REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.
--- EN ---
This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.
Assisted-by: Claude Opus 5
2026-09-24 13:30:31 -04:00
|
|
|
with (
|
|
|
|
|
mock.patch("subprocess.run", side_effect=fake_run),
|
|
|
|
|
mock.patch("builtins.input", lambda *a: next(it)),
|
|
|
|
|
mock.patch.object(
|
|
|
|
|
TODO, "_port_in_use", staticmethod(lambda p: port_taken)
|
|
|
|
|
),
|
|
|
|
|
mock.patch.dict("os.environ", {"USER": "poste"}),
|
|
|
|
|
mock.patch(
|
|
|
|
|
"sys.stdout", new_callable=__import__("io").StringIO
|
|
|
|
|
) as out,
|
|
|
|
|
):
|
[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
self.todo._qemu_scrcpy_tunnel(name, src, started=started)
|
|
|
|
|
return out.getvalue(), self.calls
|
|
|
|
|
|
|
|
|
|
def test_a_local_vm_needs_the_vm_as_the_LAST_hop(self):
|
|
|
|
|
"""L'émulateur n'écoute que sur le 127.0.0.1 de la VM. Une redirection
|
|
|
|
|
vers IP_VM:5555 depuis l'hyperviseur est refusée (mesuré) : « localhost »
|
|
|
|
|
ne vaut que sur le dernier saut, d'où -J."""
|
|
|
|
|
out, _ = self._play(src="virsh")
|
|
|
|
|
self.assertIn("-L 5555:localhost:5555", out)
|
|
|
|
|
self.assertIn("-J poste@10.0.0.2", out)
|
|
|
|
|
self.assertIn("erplibre@192.168.123.81", out)
|
|
|
|
|
self.assertNotIn("-L 5555:192.168.123.81:5555", out)
|
|
|
|
|
|
|
|
|
|
def test_a_configured_host_rides_its_own_proxyjump(self):
|
|
|
|
|
out, _ = self._play(src="ssh_config", name="test-vm_02+proof")
|
|
|
|
|
self.assertIn("-L 5555:localhost:5555 test-vm_02+proof", out)
|
|
|
|
|
self.assertNotIn("-J", out)
|
|
|
|
|
self.assertIn("ProxyJump", out)
|
|
|
|
|
|
|
|
|
|
def test_it_gives_the_three_workstation_commands(self):
|
|
|
|
|
"""Le tunnel ne sert à rien seul : la connexion adb et scrcpy le
|
|
|
|
|
suivent, et scrcpy n'est pas installé par défaut."""
|
|
|
|
|
out, _ = self._play()
|
|
|
|
|
self.assertIn("adb connect localhost:5555", out)
|
|
|
|
|
self.assertIn("scrcpy -s localhost:5555", out)
|
|
|
|
|
self.assertIn("apt install scrcpy", out)
|
|
|
|
|
|
|
|
|
|
def test_this_path_never_falls_back_to_x11(self):
|
|
|
|
|
"""Tout l'intérêt : plus de X11 nulle part, le flux est du H.264."""
|
|
|
|
|
out, _ = self._play()
|
|
|
|
|
self.assertNotIn("ssh -X", out)
|
|
|
|
|
|
|
|
|
|
def test_the_tunneled_port_is_the_device_not_the_adb_server(self):
|
|
|
|
|
"""5037 est le serveur adb du poste : le tunneler obligerait à tuer
|
|
|
|
|
celui de l'utilisateur, qui occupe le même port. 5555 est l'appareil.
|
|
|
|
|
"""
|
|
|
|
|
out, _ = self._play()
|
|
|
|
|
self.assertNotIn("5037", out)
|
|
|
|
|
self.assertIn("5555", out)
|
|
|
|
|
|
|
|
|
|
def test_it_does_not_repeat_the_start_command_after_starting(self):
|
|
|
|
|
out, _ = self._play(started=True)
|
|
|
|
|
self.assertNotIn("-no-window", out)
|
|
|
|
|
out, _ = self._play(started=False)
|
|
|
|
|
self.assertIn("-no-window", out)
|
|
|
|
|
self.assertIn("$HOME/android/emulator/emulator", out)
|
|
|
|
|
|
|
|
|
|
def test_declining_opens_nothing(self):
|
|
|
|
|
_, calls = self._play(answers=("n",))
|
|
|
|
|
self.assertEqual(calls, [])
|
|
|
|
|
|
|
|
|
|
def test_accepting_opens_a_detached_tunnel_that_fails_loudly(self):
|
|
|
|
|
"""« -f » sans « ExitOnForwardFailure » rend 0 alors que la redirection
|
|
|
|
|
a échoué : un succès annoncé pour un tunnel absent."""
|
|
|
|
|
out, calls = self._play(answers=("o",))
|
|
|
|
|
self.assertEqual(len(calls), 1)
|
|
|
|
|
cmd = calls[0]
|
|
|
|
|
self.assertIn("-f", cmd)
|
|
|
|
|
self.assertIn("-N", cmd)
|
|
|
|
|
self.assertIn("ExitOnForwardFailure=yes", cmd)
|
|
|
|
|
self.assertIn("5555:localhost:5555", cmd)
|
|
|
|
|
self.assertIn("erplibre@192.168.123.81", cmd)
|
|
|
|
|
self.assertIn("✅", out)
|
|
|
|
|
|
|
|
|
|
def test_an_occupied_port_is_named_instead_of_a_silent_bind_error(self):
|
|
|
|
|
"""Le « bind: Address already in use » d'ssh se perd en mode détaché."""
|
|
|
|
|
out, calls = self._play(answers=("o",), port_taken=True)
|
|
|
|
|
self.assertEqual(calls, [])
|
|
|
|
|
self.assertIn("5555", out)
|
|
|
|
|
self.assertIn("pkill", out)
|
|
|
|
|
|
|
|
|
|
def test_a_refused_tunnel_is_reported(self):
|
|
|
|
|
out, _ = self._play(answers=("o",), rc=255)
|
|
|
|
|
self.assertIn("refus", out)
|
|
|
|
|
self.assertNotIn("✅", out)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPortInUse(unittest.TestCase):
|
|
|
|
|
def test_a_listening_socket_is_seen(self):
|
|
|
|
|
with socket.socket() as srv:
|
|
|
|
|
srv.bind(("127.0.0.1", 0))
|
|
|
|
|
srv.listen(1)
|
|
|
|
|
self.assertTrue(TODO._port_in_use(srv.getsockname()[1]))
|
|
|
|
|
|
|
|
|
|
def test_a_closed_port_is_free(self):
|
|
|
|
|
with socket.socket() as probe:
|
|
|
|
|
probe.bind(("127.0.0.1", 0))
|
|
|
|
|
port = probe.getsockname()[1]
|
|
|
|
|
self.assertFalse(TODO._port_in_use(port))
|
|
|
|
|
|
|
|
|
|
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
class TestEmulatorGpuOverride(unittest.TestCase):
|
|
|
|
|
"""« -gpu host » doit être essayable sans toucher au code.
|
|
|
|
|
|
|
|
|
|
Les drapeaux sont figés à l'import de todo.py : la variable doit donc être
|
|
|
|
|
lue AVANT, ce qu'un sous-processus est seul à démontrer. Le défaut reste
|
|
|
|
|
swangle — mesuré — car un contexte GL qui échoue laisse l'émulateur pendu
|
|
|
|
|
au lieu de retomber.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _flags(self, env=None):
|
|
|
|
|
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
environ = dict(os.environ, PYTHONPATH=root)
|
|
|
|
|
environ.update(env or {})
|
|
|
|
|
res = subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
sys.executable,
|
|
|
|
|
"-c",
|
|
|
|
|
"import sys; sys.argv=['todo.py']\n"
|
|
|
|
|
"from script.todo.todo import TODO\n"
|
|
|
|
|
"print(TODO._QEMU_EMULATOR_FLAGS)",
|
|
|
|
|
],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
cwd=root,
|
|
|
|
|
env=environ,
|
|
|
|
|
timeout=120,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(0, res.returncode, res.stderr[-400:])
|
|
|
|
|
return res.stdout
|
|
|
|
|
|
|
|
|
|
def test_the_default_is_the_measured_one(self):
|
|
|
|
|
self.assertIn("-gpu swangle", self._flags())
|
|
|
|
|
|
|
|
|
|
def test_the_variable_replaces_it(self):
|
|
|
|
|
out = self._flags({"EL_EMULATOR_GPU": "host"})
|
|
|
|
|
self.assertIn("-gpu host", out)
|
|
|
|
|
self.assertNotIn("swangle", out)
|
|
|
|
|
|
|
|
|
|
|
[ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.
La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.
Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.
--- EN ---
The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.
The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.
Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.
Assisted-by: Claude Opus 5
2026-08-19 03:35:31 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|