"""Bug rapporté le 2026-09-06 (côté client) : stopWatch() ne signalait jamais explicitement au serveur qu'un direct MSE devait s'arrêter - seul l'AbortController local (MSE_ABORT) était coupé, la connexion HTTP restant ouverte côté serveur jusqu'à sa prochaine écriture (voir le pendant serveur, test_serve_live_mse_arret_explicite.py).""" from __future__ import annotations import json import shutil import subprocess import unittest from pathlib import Path class TestsArretExpliciteDirectMseCoteClient(unittest.TestCase): @classmethod def setUpClass(cls): cls.node = shutil.which("node") if cls.node is None: raise unittest.SkipTest("node introuvable") source = (Path(__file__).parent / "serve_app.js").read_text(encoding="utf-8") debut = source.index("function arreterSessionDirect") fin = source.index("// --- MSE/fMP4", debut) cls.bloc = source[debut:fin] if "arreterSessionDirect(mseSessionId, beacon)" not in cls.bloc: raise AssertionError("stopWatch() n'appelle plus arreterSessionDirect pour MSE") def _executer(self, scenario: str) -> dict: script = f""" {self.bloc} const fetchAppels = []; global.fetch = (url, options) => {{ fetchAppels.push({{ url, options }}); return Promise.resolve({{ ok: true }}); }}; global.navigator = {{ sendBeacon: () => false }}; const BLINK_TOKEN = "jeton-test"; const boxes = {{}}; function $(id) {{ return boxes[id] || null; }} function cssId(name) {{ return name.replace(/[^\\w-]/g, "_"); }} function repos(name, libelle) {{ return `repos:${{name}}:${{libelle}}`; }} function t(cle) {{ return cle; }} const LIVE_PENDING = {{}}; const LIVE_AUTO_STOP_TIMER = {{}}; const MSE_ABORT = {{}}; const MSE_SESSION = {{}}; const WEBRTC_ABORT = {{}}; const WEBRTC_SESSION = {{}}; const WEBRTC_PC = {{}}; {scenario} process.stdout.write(JSON.stringify({{ fetchAppels: fetchAppels.map((a) => ({{ url: a.url, method: a.options && a.options.method, body: a.options && a.options.body ? JSON.parse(a.options.body) : null, }})), mseSessionRestante: MSE_SESSION["Jardin"] || null, webrtcSessionRestante: WEBRTC_SESSION["Jardin"] || null, }})); """ resultat = subprocess.run( [self.node, "-e", script], capture_output=True, text=True, timeout=60, ) if resultat.returncode != 0: self.fail(f"Node a échoué : {resultat.stderr}") return json.loads(resultat.stdout) def test_stop_watch_signale_l_arret_explicite_d_une_session_mse(self): resultat = self._executer(""" MSE_SESSION["Jardin"] = "session-mse-abc"; MSE_ABORT["Jardin"] = { abort() {} }; stopWatch("Jardin"); """) self.assertEqual(len(resultat["fetchAppels"]), 1) appel = resultat["fetchAppels"][0] self.assertEqual(appel["url"], "/api/arreter-direct") self.assertEqual(appel["method"], "POST") self.assertEqual(appel["body"], {"session_id": "session-mse-abc"}) self.assertIsNone(resultat["mseSessionRestante"]) def test_stop_watch_sans_session_mse_ne_fait_pas_d_appel_inutile(self): resultat = self._executer(""" stopWatch("Jardin"); """) self.assertEqual(resultat["fetchAppels"], []) def test_stop_watch_signale_mse_et_webrtc_independamment(self): resultat = self._executer(""" MSE_SESSION["Jardin"] = "session-mse"; WEBRTC_SESSION["Jardin"] = "session-webrtc"; stopWatch("Jardin"); """) session_ids = {a["body"]["session_id"] for a in resultat["fetchAppels"]} self.assertEqual(session_ids, {"session-mse", "session-webrtc"}) self.assertIsNone(resultat["mseSessionRestante"]) self.assertIsNone(resultat["webrtcSessionRestante"]) class TestsGenerationSessionIdWatchMse(unittest.TestCase): """watchMse() doit poser MSE_SESSION[name] AVANT son premier appel à connecterMse() (sinon un clic sur Arrêter juste après le lancement du direct ne trouverait rien à annuler), et le transmettre tel quel.""" @classmethod def setUpClass(cls): cls.node = shutil.which("node") if cls.node is None: raise unittest.SkipTest("node introuvable") source = (Path(__file__).parent / "serve_app.js").read_text(encoding="utf-8") debut_id = source.index("function nouvelIdentifiantDirect") fin_id = source.index("\n}\n", debut_id) + len("\n}\n") debut_watch = source.index("async function watchMse") fin_watch = source.index("// L'état de repos", debut_watch) # watchMse() arme son budget par armerBudget(), qui consulte la case # « Direct continu » : le bloc qui les définit l'accompagne. debut_continu = source.index("const CLE_DIRECT_CONTINU") fin_continu = source.index("// Un seul identifiant par watchMse()", debut_continu) cls.bloc = (source[debut_continu:fin_continu] + "\n" + source[debut_id:fin_id] + "\n" + source[debut_watch:fin_watch]) if "connecterMse(" not in cls.bloc or "sessionId" not in cls.bloc: raise AssertionError("watchMse() ne transmet plus de sessionId à connecterMse()") def test_sessionid_pose_avant_le_premier_appel_et_transmis_tel_quel(self): script = f""" global.window = {{}}; {self.bloc} const boxes = {{ "live-Jardin": {{ innerHTML: "", querySelector: () => ({{}}) }}, }}; function $(id) {{ return boxes[id] || null; }} function h(s) {{ return s; }} function cssId(name) {{ return name.replace(/[^\\w-]/g, "_"); }} function t(cle) {{ return cle; }} function recordBtn() {{ return ""; }} function audioBtn() {{ return ""; }} function preparerAudioVideo() {{}} function expandBtn() {{ return ""; }} function failWatch() {{}} function stopWatch() {{}} function attendreOuAbandon() {{ return Promise.resolve(); }} const MSE_ABORT = {{}}; const MSE_SESSION = {{}}; const MSE_MAX_ECHECS_A_VIDE = 1; const MSE_BUDGET_TOTAL_MS = 10000; const MSE_DELAI_MODULE_OCCUPE_MS = 1; const MSE_DELAI_RECONNEXION_MS = 1; let sessionIdVuParConnecterMse = null; let sessionIdPoseAvantAppel = null; async function connecterMse(name, video, signal, texte, t0, reveilInitial, sessionId) {{ sessionIdVuParConnecterMse = sessionId; sessionIdPoseAvantAppel = MSE_SESSION[name]; const error = new Error("Aborted"); error.name = "AbortError"; throw error; }} watchMse("Jardin").then(() => {{ process.stdout.write(JSON.stringify({{ sessionIdVuParConnecterMse, sessionIdPoseAvantAppel, sessionIdApresRetour: MSE_SESSION["Jardin"] || null, coherent: sessionIdVuParConnecterMse === sessionIdPoseAvantAppel && typeof sessionIdVuParConnecterMse === "string" && sessionIdVuParConnecterMse.length > 0, }})); }}); """ resultat = subprocess.run( [self.node, "-e", script], capture_output=True, text=True, timeout=60, ) if resultat.returncode != 0: self.fail(f"Node a échoué : {resultat.stderr}") donnees = json.loads(resultat.stdout) self.assertTrue( donnees["coherent"], f"sessionId incohérent ou absent : {donnees}", ) # Le "finally" de watchMse() nettoie toujours MSE_SESSION à sa # sortie, quelle qu'en soit la raison (échec ici) - seul l'instant # T pendant l'appel à connecterMse() (vérifié ci-dessus par # "coherent") doit porter le sessionId. self.assertIsNone(donnees["sessionIdApresRetour"]) if __name__ == "__main__": unittest.main(verbosity=2)