[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
This commit is contained in:
parent
3b2f7fa3d6
commit
8f5a439148
3 changed files with 164 additions and 3 deletions
|
|
@ -1677,6 +1677,34 @@ class TODO:
|
||||||
except (OSError, subprocess.SubprocessError, ValueError, IndexError):
|
except (OSError, subprocess.SubprocessError, ValueError, IndexError):
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
|
def _qemu_emulator_ready(self, target, src="virsh"):
|
||||||
|
"""La VM a-t-elle de quoi émuler ? Rend (prêt, raison).
|
||||||
|
|
||||||
|
Le binaire ET l'AVD, en une seule lecture : sans cette vérification le
|
||||||
|
démarrage détaché rendait 0 sur une VM sans SDK, et le menu annonçait
|
||||||
|
« Démarré » quand le journal disait « not found ». Une VM déployée sans
|
||||||
|
cocher l'outil Émulateur Android est le cas normal, pas une panne."""
|
||||||
|
probe = (
|
||||||
|
f"test -x {self._QEMU_EMULATOR_BIN} || echo NO_SDK; "
|
||||||
|
f"test -d $HOME/.android/avd/{self._QEMU_AVD_NAME}.avd"
|
||||||
|
" || echo NO_AVD"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["ssh"] + self._qemu_ssh_opts(src) + [target, probe],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=25,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return False, t("Cannot reach this VM.")
|
||||||
|
out = res.stdout or ""
|
||||||
|
if "NO_SDK" in out:
|
||||||
|
return False, t("No Android SDK in this VM: no emulator binary.")
|
||||||
|
if "NO_AVD" in out:
|
||||||
|
return False, t("No AVD named erplibre in this VM.")
|
||||||
|
return True, ""
|
||||||
|
|
||||||
def _qemu_emulator_menu(self):
|
def _qemu_emulator_menu(self):
|
||||||
"""Démarre l'émulateur Android d'une VM, et donne la suite qui va avec.
|
"""Démarre l'émulateur Android d'une VM, et donne la suite qui va avec.
|
||||||
|
|
||||||
|
|
@ -1722,10 +1750,22 @@ class TODO:
|
||||||
)
|
)
|
||||||
print(f" {t('Closed.')}")
|
print(f" {t('Closed.')}")
|
||||||
|
|
||||||
|
ready, why = self._qemu_emulator_ready(target, src)
|
||||||
|
if not ready:
|
||||||
|
print(f"\n ⚠ {why}")
|
||||||
|
print(f" {t('Tick the Android emulator tool when deploying.')}")
|
||||||
|
return
|
||||||
|
|
||||||
print(f"\n {t('Show a window?')}")
|
print(f"\n {t('Show a window?')}")
|
||||||
print(f" [1] {t('No window - stream with scrcpy (smoother)')} *")
|
print(f" [1] {t('No window - stream with scrcpy (smoother)')} *")
|
||||||
print(f" [2] {t('Window over ssh -X (raw pixels, slower)')}")
|
print(f" [2] {t('Window over ssh -X (raw pixels, slower)')}")
|
||||||
kind = input(f"{t('Choice')} [1]: ").strip() or "1"
|
kind = input(f"{t('Choice')} [1]: ").strip() or "1"
|
||||||
|
# Sans cette validation, TOUT ce qui n'est pas « 2 » démarrait
|
||||||
|
# l'émulateur : une frappe de travers (« n ») lançait le démarrage,
|
||||||
|
# observé. Un menu à deux crans n'a pas de troisième réponse.
|
||||||
|
if kind not in ("1", "2"):
|
||||||
|
print(t("Cancelled."))
|
||||||
|
return
|
||||||
emu = self._QEMU_EMULATOR_BIN
|
emu = self._QEMU_EMULATOR_BIN
|
||||||
avd = self._QEMU_AVD_NAME
|
avd = self._QEMU_AVD_NAME
|
||||||
|
|
||||||
|
|
@ -1760,6 +1800,27 @@ class TODO:
|
||||||
if res.returncode:
|
if res.returncode:
|
||||||
print(f" ⚠ {t('Could not start it:')} {res.stderr.strip()[:200]}")
|
print(f" ⚠ {t('Could not start it:')} {res.stderr.strip()[:200]}")
|
||||||
return
|
return
|
||||||
|
# « setsid » détache : le code de retour ne dit RIEN de l'émulateur.
|
||||||
|
# Le menu annonçait « Démarré » pendant que le journal de la VM disait
|
||||||
|
# « not found » — mesuré sur une VM sans SDK. On attend donc de voir le
|
||||||
|
# processus, et à défaut on rapporte le journal.
|
||||||
|
for _ in range(5):
|
||||||
|
if self._qemu_emulator_running(target, src) > 0:
|
||||||
|
break
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
print(f" ⚠ {t('It did not start; the VM log says:')}")
|
||||||
|
log = subprocess.run(
|
||||||
|
["ssh"]
|
||||||
|
+ self._qemu_ssh_opts(src)
|
||||||
|
+ [target, "tail -5 /tmp/erplibre-emulator.log 2>/dev/null"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=25,
|
||||||
|
)
|
||||||
|
for line in (log.stdout or "").strip().splitlines():
|
||||||
|
print(f" {line}")
|
||||||
|
return
|
||||||
print(
|
print(
|
||||||
f" {t('Started. Boot takes about a minute; log in the VM:')}"
|
f" {t('Started. Boot takes about a minute; log in the VM:')}"
|
||||||
" /tmp/erplibre-emulator.log"
|
" /tmp/erplibre-emulator.log"
|
||||||
|
|
|
||||||
|
|
@ -2252,6 +2252,27 @@ TRANSLATIONS = {
|
||||||
"fr": "(l'hyperviseur ne fait que relayer ; -J met la VM en dernier)",
|
"fr": "(l'hyperviseur ne fait que relayer ; -J met la VM en dernier)",
|
||||||
"en": "(the hypervisor only relays; -J puts the VM last)",
|
"en": "(the hypervisor only relays; -J puts the VM last)",
|
||||||
},
|
},
|
||||||
|
"Choice": {"fr": "Choix", "en": "Choice"},
|
||||||
|
"Tick the Android emulator tool when deploying.": {
|
||||||
|
"fr": "Cochez l'outil Émulateur Android au déploiement.",
|
||||||
|
"en": "Tick the Android emulator tool when deploying.",
|
||||||
|
},
|
||||||
|
"Cannot reach this VM.": {
|
||||||
|
"fr": "VM injoignable.",
|
||||||
|
"en": "Cannot reach this VM.",
|
||||||
|
},
|
||||||
|
"No Android SDK in this VM: no emulator binary.": {
|
||||||
|
"fr": "Pas de SDK Android dans cette VM : aucun binaire emulator.",
|
||||||
|
"en": "No Android SDK in this VM: no emulator binary.",
|
||||||
|
},
|
||||||
|
"No AVD named erplibre in this VM.": {
|
||||||
|
"fr": "Aucun AVD nommé erplibre dans cette VM.",
|
||||||
|
"en": "No AVD named erplibre in this VM.",
|
||||||
|
},
|
||||||
|
"It did not start; the VM log says:": {
|
||||||
|
"fr": "Il n'a pas démarré ; le journal de la VM dit :",
|
||||||
|
"en": "It did not start; the VM log says:",
|
||||||
|
},
|
||||||
"No ~/.ssh/config entry; see SSH configuration.": {
|
"No ~/.ssh/config entry; see SSH configuration.": {
|
||||||
"fr": "Aucune entrée ~/.ssh/config ; voir Configuration SSH.",
|
"fr": "Aucune entrée ~/.ssh/config ; voir Configuration SSH.",
|
||||||
"en": "No ~/.ssh/config entry; see SSH configuration.",
|
"en": "No ~/.ssh/config entry; see SSH configuration.",
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,39 @@ class TestEmulatorRunning(unittest.TestCase):
|
||||||
self.assertEqual(self.todo._qemu_emulator_running("h"), -1)
|
self.assertEqual(self.todo._qemu_emulator_running("h"), -1)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
class _MenuCase(unittest.TestCase):
|
class _MenuCase(unittest.TestCase):
|
||||||
"""Socle commun : une VM locale, des réponses scriptées, aucun vrai ssh."""
|
"""Socle commun : une VM locale, des réponses scriptées, aucun vrai ssh."""
|
||||||
|
|
||||||
|
|
@ -122,16 +155,29 @@ class _MenuCase(unittest.TestCase):
|
||||||
self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True))
|
self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True))
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|
||||||
def _play(self, answers, running=0, start_rc=0, port_taken=False):
|
def _play(self, answers, running=0, start_rc=0, port_taken=False,
|
||||||
"""Joue le menu avec des réponses données ; rend (sortie, commandes)."""
|
probe="", running_after=1, log="rien"):
|
||||||
|
"""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}
|
||||||
|
|
||||||
def fake_run(cmd, *a, **k):
|
def fake_run(cmd, *a, **k):
|
||||||
self.calls.append(cmd)
|
self.calls.append(cmd)
|
||||||
joined = " ".join(cmd)
|
joined = " ".join(cmd)
|
||||||
if "pgrep -c qemu-system" in joined:
|
if "pgrep -c qemu-system" in joined:
|
||||||
return _run_ok(f"{running}\n")
|
n = running_after if state["started"] else running
|
||||||
|
return _run_ok(f"{n}\n")
|
||||||
|
if "NO_SDK" in joined:
|
||||||
|
return _run_ok(probe)
|
||||||
if "setsid" in joined:
|
if "setsid" in joined:
|
||||||
|
state["started"] = True
|
||||||
return _run_ok(returncode=start_rc, stderr="boum")
|
return _run_ok(returncode=start_rc, stderr="boum")
|
||||||
|
if "tail -5" in joined:
|
||||||
|
return _run_ok(log)
|
||||||
return _run_ok()
|
return _run_ok()
|
||||||
|
|
||||||
it = iter(answers)
|
it = iter(answers)
|
||||||
|
|
@ -139,6 +185,8 @@ class _MenuCase(unittest.TestCase):
|
||||||
"builtins.input", lambda *a: next(it)
|
"builtins.input", lambda *a: next(it)
|
||||||
), mock.patch.object(
|
), mock.patch.object(
|
||||||
TODO, "_port_in_use", staticmethod(lambda p: port_taken)
|
TODO, "_port_in_use", staticmethod(lambda p: port_taken)
|
||||||
|
), mock.patch(
|
||||||
|
"script.todo.todo.time.sleep", lambda *a: None
|
||||||
), mock.patch(
|
), mock.patch(
|
||||||
"sys.stdout", new_callable=__import__("io").StringIO
|
"sys.stdout", new_callable=__import__("io").StringIO
|
||||||
) as out:
|
) as out:
|
||||||
|
|
@ -221,6 +269,37 @@ class TestEmulatorMenu(_MenuCase):
|
||||||
self.assertIn("boum", out)
|
self.assertIn("boum", out)
|
||||||
self.assertEqual(self._tunnels(calls), [])
|
self.assertEqual(self._tunnels(calls), [])
|
||||||
|
|
||||||
|
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,
|
||||||
|
où le démarrage détaché rendait 0 et le journal disait « not found »."""
|
||||||
|
out, calls = self._play(["1"], probe="NO_SDK\n")
|
||||||
|
self.assertIn("SDK", out)
|
||||||
|
self.assertNotIn("[1]", out.split("VM locale")[-1])
|
||||||
|
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)
|
||||||
|
|
||||||
def test_a_successful_start_chains_into_the_tunnel_help(self):
|
def test_a_successful_start_chains_into_the_tunnel_help(self):
|
||||||
out, _ = self._play(["1", "1", "n"])
|
out, _ = self._play(["1", "1", "n"])
|
||||||
self.assertIn("scrcpy", out)
|
self.assertIn("scrcpy", out)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue