diff --git a/script/todo/todo.py b/script/todo/todo.py index 820c871..8d0adac 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1677,6 +1677,34 @@ class TODO: except (OSError, subprocess.SubprocessError, ValueError, IndexError): 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): """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.')}") + 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" [1] {t('No window - stream with scrcpy (smoother)')} *") print(f" [2] {t('Window over ssh -X (raw pixels, slower)')}") 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 avd = self._QEMU_AVD_NAME @@ -1760,6 +1800,27 @@ class TODO: if res.returncode: print(f" ⚠ {t('Could not start it:')} {res.stderr.strip()[:200]}") 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( f" {t('Started. Boot takes about a minute; log in the VM:')}" " /tmp/erplibre-emulator.log" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index dd91e31..c64a60c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2252,6 +2252,27 @@ TRANSLATIONS = { "fr": "(l'hyperviseur ne fait que relayer ; -J met la VM en dernier)", "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.": { "fr": "Aucune entrée ~/.ssh/config ; voir Configuration SSH.", "en": "No ~/.ssh/config entry; see SSH configuration.", diff --git a/test/test_qemu_emulator_menu.py b/test/test_qemu_emulator_menu.py index 4e871cd..7d8fc37 100644 --- a/test/test_qemu_emulator_menu.py +++ b/test/test_qemu_emulator_menu.py @@ -109,6 +109,39 @@ class TestEmulatorRunning(unittest.TestCase): 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): """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.calls = [] - def _play(self, answers, running=0, start_rc=0, port_taken=False): - """Joue le menu avec des réponses données ; rend (sortie, commandes).""" + def _play(self, answers, running=0, start_rc=0, port_taken=False, + 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): self.calls.append(cmd) joined = " ".join(cmd) 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: + state["started"] = True return _run_ok(returncode=start_rc, stderr="boum") + if "tail -5" in joined: + return _run_ok(log) return _run_ok() it = iter(answers) @@ -139,6 +185,8 @@ class _MenuCase(unittest.TestCase): "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: @@ -221,6 +269,37 @@ class TestEmulatorMenu(_MenuCase): self.assertIn("boum", out) 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): out, _ = self._play(["1", "1", "n"]) self.assertIn("scrcpy", out)