"""Tests navigateur WebRTC hors ligne sur les fonctions JavaScript livrées.""" import json import shutil import subprocess import unittest from pathlib import Path from test_serve_app_js_audio import audio_source class TestsNavigateurWebRTC(unittest.TestCase): @classmethod def setUpClass(cls): cls.node = shutil.which("node") if not cls.node: raise unittest.SkipTest("Node indisponible") source = Path(__file__).with_name("serve_app.js").read_text(encoding="utf-8") cls.source = ( source[source.index('window.addEventListener("pagehide"'):source.index("// Toute valeur")] + "let rondeAReprendre = false;\n" + source[source.index("async function loadSystem("):source.index("// Un calcul lancé")] + source[source.index("function comparerCamerasDirect("):source.index("function majCaseMasquees(")] + source[source.index("function nomsDirectsActifs()"):source.index("function cameraCard(")] + source[source.index("function arreterSessionDirect("):source.index("// --- MSE/fMP4")] + source[source.index("const MSE_ABORT = {};"):source.index("async function watchMse(")] + source[source.index("const CLE_VUE = "):source.index('// Seule cette ligne de texte')] ) for avant, apres in { "const WEBRTC_DELAI_DEMARRAGE_MS = 130 * 1000;": "const WEBRTC_DELAI_DEMARRAGE_MS = 150;", "const WEBRTC_DELAI_PREMIERE_IMAGE_MS = 30 * 1000;": "const WEBRTC_DELAI_PREMIERE_IMAGE_MS = 30;", "const WEBRTC_DUREE_APRES_LECTURE_MS = 450 * 1000;": "const WEBRTC_DUREE_APRES_LECTURE_MS = 100;", "const WEBRTC_BUDGET_TOTAL_MS = 10 * 60 * 1000;": "const WEBRTC_BUDGET_TOTAL_MS = 400;", "const WEBRTC_DELAI_MODULE_OCCUPE_MS = 10000;": "const WEBRTC_DELAI_MODULE_OCCUPE_MS = 3;", "const WEBRTC_DELAI_RECONNEXION_MS = 3000;": "const WEBRTC_DELAI_RECONNEXION_MS = 3;", }.items(): if avant not in cls.source: raise AssertionError(avant) cls.source = cls.source.replace(avant, apres) cls.source = audio_source(source) + "\n" + cls.source def scenario(self, scenario): script = r''' const scenario = process.argv[2]; const assert = require('node:assert/strict'); const crypto = require('node:crypto').webcrypto; const pauses = ms => new Promise(resolve => setTimeout(resolve, ms)); const window = {events: {}, addEventListener(name, fn) { this.events[name] = fn; }}; const navigator = {sendBeacon(_url, body) { beacons.push(body); return true; }}; const BLINK_TOKEN = 'test-token'; const beacons = [], stops = [], offers = [], peers = [], pending = [], failures = []; const t = key => key; const tf = key => key; const h = String; const cssId = String; const cameraSurBatterie = () => false; const recordBtn = () => ''; const expandBtn = () => ''; const signalerDirect = () => {}; const failWatch = (name, message) => failures.push({name, message}); const repos = () => 'repos'; const lireJSON = r => r.json(); const document = {querySelectorAll: () => []}; let system = {webrtc: true, systems: []}; class Video extends EventTarget { constructor() { super(); this.srcObject = null; this.readyState = 0; this.videoWidth = 0; } play() { return Promise.resolve(); } } const video = new Video(); const box = {innerHTML: 'original', isConnected: true, querySelector: () => video, insertAdjacentHTML() {}}; const list = {innerHTML: 'original'}; const view = {value: 'live'}; let loads = 0; const load = () => loads++; const render = () => {}; const hint = {isConnected: true, textContent: '', remove() { this.isConnected = false; }}; const $ = id => id === 'view' ? view : id === 'list' ? list : id === 'count' ? {} : id.startsWith('hint-') ? hint : box; function fetch(url, options = {}) { if (url === '/api/system') return Promise.resolve({json: async () => ({webrtc:true, systems:[]})}); if (url === '/api/arreter-direct') { stops.push(JSON.parse(options.body).session_id); return Promise.resolve({ok: true}); } if (url === '/api/attente-module') { if (scenario === 'race' || scenario === 'pending_stop') { return new Promise(resolve => pending.push(resolve)); } return Promise.resolve({json: async () => ({libre: true})}); } const offer = JSON.parse(options.body); offers.push(offer); if (scenario === 'fetch_timeout' || scenario === 'abort_offer') return new Promise(() => {}); if (scenario === 'retry_then_ended' && offers.length === 1) { return Promise.resolve({ok: false, status: 503, json: async () => ({error: 'first attempt failed'})}); } if (scenario === 'busy' || scenario === 'busy_retry') { return Promise.resolve({ok: false, status: 409, json: async () => ({error: 'busy'})}); } return Promise.resolve({ok: true, json: async () => ({ sdp: 'answer', type: 'answer', session_id: offer.session_id, })}); } class RTCPeerConnection extends EventTarget { constructor() { super(); this.connectionState = 'new'; this.iceConnectionState = 'new'; this.closed = 0; peers.push(this); } addTransceiver() {} createDataChannel(label) { this.canal = new EventTarget(); this.canal.label = label; return this.canal; } async createOffer() { return {sdp: scenario === 'no_h264' ? 'v=0\r\na=rtpmap:120 VP8/90000\r\n' : 'v=0\r\na=rtpmap:102 H264/90000\r\n', type: 'offer'}; } async setLocalDescription(offer) { this.localDescription = offer; } async setRemoteDescription() { this.connectionState = 'connected'; const track = new EventTarget(); this.ontrack({track, streams: [{}]}); if (scenario === 'decoded' || scenario === 'ended' || scenario === 'retry_then_ended' || scenario === 'continuous' || scenario === 'server_closed') { video.readyState = 2; video.videoWidth = 1920; video.dispatchEvent(new Event('loadeddata')); // server_closed : ni piste terminée ni connexion fermée, seulement // le canal « fin » que ferme le serveur. if (scenario === 'server_closed') setTimeout(() => this.canal.dispatchEvent(new Event('close')), 15); else if (scenario !== 'decoded') setTimeout(() => track.dispatchEvent(new Event('ended')), 15); } if (scenario === 'ice_failed') { this.connectionState = 'failed'; this.dispatchEvent(new Event('connectionstatechange')); } } close() { this.closed++; this.connectionState = 'closed'; } } ''' + self.source + r''' (async () => { const result = {}; if (scenario === 'render') { WEBRTC_PC.Cam = {}; renderLive(); assert.equal(list.innerHTML, 'original'); delete WEBRTC_PC.Cam; LIVE_PENDING.Cam = new AbortController(); renderLive(); assert.equal(list.innerHTML, 'original'); } else if (scenario === 'system_refresh') { WEBRTC_PC.Cam = {}; await loadSystem(true); assert.equal(list.innerHTML, 'original'); } else if (scenario === 'leave_view') { WEBRTC_SESSION.Cam = 'owned-session'; WEBRTC_PC.Cam = new RTCPeerConnection(); view.value = 'clips'; view.onchange(); assert.equal(loads, 1); assert.deepEqual(stops, ['owned-session']); assert.equal(Object.keys(WEBRTC_PC).length, 0); } else if (scenario === 'pagehide') { window.events.pagehide(); assert.equal(beacons.length, 0); WEBRTC_SESSION.Cam = 'owned-session'; WEBRTC_PC.Cam = new RTCPeerConnection(); window.events.pagehide(); assert.equal(beacons.length, 1); assert.equal(JSON.parse(await beacons[0].text()).session_id, 'owned-session'); assert.equal(Object.keys(WEBRTC_PC).length, 0); } else if (scenario === 'race' || scenario === 'pending_stop') { const starts = []; watchWebRTC = async name => { starts.push(name); }; const a = watchLive('A'); await pauses(0); assert.ok(LIVE_PENDING.A); assert.ok(box.innerHTML.includes('stop-live')); if (scenario === 'pending_stop') { stopWatch('A'); await a; assert.deepEqual(starts, []); } else { const b = watchLive('B'); await pauses(0); pending[1]({json: async () => ({libre: true})}); await b; pending[0]({json: async () => ({libre: true})}); await a; assert.deepEqual(starts, ['B']); } assert.equal(Object.keys(LIVE_PENDING).length, 0); } else if (scenario === 'retry_then_ended') { await watchWebRTC('Cam'); assert.equal(offers.length, 2); assert.equal(Object.keys(WEBRTC_ABORT).length, 0); assert.equal(failures.length, 0); assert.equal(box.innerHTML, 'repos'); } else if (scenario === 'continuous') { // Chaque session se ferme d'elle-même, comme Blink au bout de six // minutes : case cochée, une autre s'ouvre, même au-delà du budget // (400 ms ici) ; décochée, la boucle s'arrête à la fin de la session. choisirDirectContinu('Cam', true); const debut = performance.now(); const run = watchWebRTC('Cam'); while (performance.now() - debut < WEBRTC_BUDGET_TOTAL_MS + 200) await pauses(10); const avant = offers.length; choisirDirectContinu('Cam', false); await run; assert.ok(avant >= 3, `sessions ouvertes : ${avant}`); assert.ok(offers.length <= avant + 1); assert.equal(failures.length, 0); assert.equal(box.innerHTML, 'repos'); assert.equal(Object.keys(WEBRTC_ABORT).length, 0); result.sessions = offers.length; } else if (scenario === 'no_h264') { await watchWebRTC('Cam'); assert.equal(offers.length, 0); assert.equal(peers.length, 1); assert.equal(peers[0].closed, 1); assert.equal(failures.length, 1); assert.equal(failures[0].message, 'watch.noh264'); assert.equal(Object.keys(WEBRTC_ABORT).length, 0); assert.equal(Object.keys(WEBRTC_PC).length, 0); } else if (scenario === 'busy_retry') { await watchWebRTC('Cam'); assert.ok(offers.length > WEBRTC_MAX_ECHECS); assert.equal(Object.keys(WEBRTC_ABORT).length, 0); assert.equal(failures.length, 1); } else { const controller = new AbortController(); let displayed = 0; const started = performance.now(); const promise = tenterWebRTC('Cam', video, controller.signal, 1, () => displayed++); if (scenario === 'abort_offer') setTimeout(() => controller.abort(), 10); let error; try { result.value = await promise; } catch (e) { error = e; } result.elapsed = performance.now() - started; if (scenario === 'decoded' || scenario === 'ended' || scenario === 'server_closed') { assert.equal(displayed, 1); assert.equal(result.value, true); if (scenario === 'server_closed') { assert.equal(peers[0].canal.label, 'fin'); // Sous les 100 ms de WEBRTC_DUREE_APRES_LECTURE_MS du banc : c'est bien // le canal qui a terminé la session, pas la minuterie de la page. assert.ok(result.elapsed < 80, `fin vue après ${result.elapsed} ms`); } assert.ok(result.elapsed >= (scenario === 'decoded' ? 80 : 10)); } else { assert.ok(error); assert.equal(displayed, 0); if (scenario === 'busy') assert.equal(error.status, 409); if (scenario === 'abort_offer') assert.equal(error.name, 'AbortError'); if (scenario === 'no_image') assert.ok(result.elapsed >= 20); if (scenario === 'fetch_timeout') assert.ok(result.elapsed >= 100); } assert.equal(Object.keys(WEBRTC_PC).length, 0); assert.equal(Object.keys(WEBRTC_SESSION).length, 0); assert.equal(peers[0].closed, 1); assert.equal(stops.length, 1); assert.equal(stops[0], offers[0].session_id); } process.stdout.write(JSON.stringify({ok:true, ...result})); })().catch(error => { console.error(error.stack); process.exitCode = 1; }); ''' result = subprocess.run([self.node, "-", scenario], input=script, capture_output=True, text=True, encoding="utf-8", timeout=60) self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue(json.loads(result.stdout)["ok"]) def test_fin_de_session_serveur_vue_tout_de_suite_par_le_canal(self): self.scenario("server_closed") def test_direct_continu_enchaine_les_sessions_au_dela_du_budget(self): self.scenario("continuous") def test_grille_preserve_video_et_demande_en_attente(self): self.scenario("render") def test_pagehide_ne_coupe_que_les_sessions_de_son_onglet(self): self.scenario("pagehide") def test_actualisation_etat_preserve_video_active(self): self.scenario("system_refresh") def test_quitter_direct_ferme_sa_session_avant_rendu_clips(self): self.scenario("leave_view") def test_dernier_clic_gagne_meme_si_reponses_inversees(self): self.scenario("race") def test_arret_possible_pendant_attente_module(self): self.scenario("pending_stop") def test_sdp_sans_image_expire_et_ferme_session(self): self.scenario("no_image") def test_fetch_bloque_est_interrompu_par_budget(self): self.scenario("fetch_timeout") def test_annulation_offre_en_cours_envoie_arret_cible(self): self.scenario("abort_offer") def test_echec_ice_initial_ne_compte_pas_comme_lecture(self): self.scenario("ice_failed") def test_lecture_confirmee_puis_nettoyage_a_la_fin(self): self.scenario("decoded") def test_fin_piste_ferme_session_sans_attendre_plafond(self): self.scenario("ended") def test_absence_h264_ne_reveille_pas_la_camera_et_ne_reessaie_pas(self): self.scenario("no_h264") def test_statut_busy_est_conserve(self): self.scenario("busy") def test_busy_n_epuise_pas_les_essais_camera(self): self.scenario("busy_retry") def test_reprise_reussie_efface_erreur_a_la_fin_normale(self): self.scenario("retry_then_ended") if __name__ == "__main__": unittest.main()