"""La page de blink2video dans un vrai navigateur. Les tests test_serve_app_js_*.py exécutent des tranches de serve_app.js sous Node, avec un DOM et une horloge simulés ; ils ne voient ni la page entière, ni la mise en page, ni les vrais événements. Ici, le serveur de page (serve.py) est lancé depuis les sources, avec un BLINK_HOME jetable et sans compte Blink, et Playwright (bibliothèque de Microsoft qui pilote un vrai Chromium) charge la page : aucune erreur JavaScript ni réponse en échec, bascule FR / EN, dialogue Réglages et ses onglets, changement de vue. Rien n'y contacte Blink : le bouton Actualiser n'est jamais pressé. Jumeau de test_page_navigateur.py de gpxsolar et de lidar2map. Sans Playwright, le test est sauté ; la CI l'installe (job « navigateur »). python -m pip install playwright python -m playwright install chromium python test_page_navigateur.py """ import os import re import socket import subprocess import sys import tempfile import time import unittest import urllib.request from pathlib import Path try: from playwright.sync_api import Error as ErreurPlaywright, expect, sync_playwright except ImportError: sync_playwright = None RACINE = Path(__file__).resolve().parent def port_libre(): with socket.socket() as sonde: sonde.bind(("127.0.0.1", 0)) return sonde.getsockname()[1] def repond(adresse): ouvreur = urllib.request.build_opener(urllib.request.ProxyHandler({})) try: with ouvreur.open(adresse, timeout=1) as reponse: return b"blink2video" in reponse.read() except OSError: return False @unittest.skipIf(sync_playwright is None, "Playwright absent") class PageBlink2video(unittest.TestCase): @classmethod def setUpClass(cls): cls.dossier = tempfile.TemporaryDirectory() cls.journal = open(Path(cls.dossier.name) / "serveur.log", "wb") port = port_libre() environnement = dict(os.environ, BLINK_HOME=cls.dossier.name, BLINK_BOOTSTRAP="none") cls.processus = subprocess.Popen( [sys.executable, str(RACINE / "serve.py"), "--port", str(port)], cwd=cls.dossier.name, env=environnement, stdout=cls.journal, stderr=subprocess.STDOUT) cls.adresse = f"http://127.0.0.1:{port}/" limite = time.monotonic() + 120 while not repond(cls.adresse): if cls.processus.poll() is not None or time.monotonic() > limite: cls.arreter() raise AssertionError("blink2video n'a pas démarré : " + (Path(cls.dossier.name) / "serveur.log").read_text(errors="replace")[-2000:]) time.sleep(.2) cls.playwright = sync_playwright().start() try: cls.navigateur = cls.playwright.chromium.launch(headless=True) except ErreurPlaywright as erreur: cls.playwright.stop() cls.arreter() raise unittest.SkipTest(f"Chromium absent : {erreur}") @classmethod def arreter(cls): if cls.processus.poll() is None: cls.processus.terminate() try: cls.processus.wait(15) except subprocess.TimeoutExpired: cls.processus.kill() cls.processus.wait() cls.journal.close() cls.dossier.cleanup() @classmethod def tearDownClass(cls): cls.navigateur.close() cls.playwright.stop() cls.arreter() def setUp(self): self.contexte = self.navigateur.new_context(locale="fr-FR", viewport={"width": 1280, "height": 800}) self.page = self.contexte.new_page() self.erreurs = [] self.page.on("pageerror", lambda erreur: self.erreurs.append(str(erreur))) self.page.on("console", lambda message: self.erreurs.append(message.text) if message.type == "error" else None) self.page.on("response", lambda r: self.erreurs.append(f"HTTP {r.status} {r.url}") if r.status >= 400 else None) self.page.goto(self.adresse) expect(self.page.locator("#refresh")).to_be_visible() def tearDown(self): self.contexte.close() self.assertEqual(self.erreurs, []) def test_la_page_se_charge_et_change_de_langue(self): expect(self.page.locator("#refresh")).to_have_text("↻ Actualiser") self.page.locator('header [data-lang-btn="en"]').click() expect(self.page.locator("html")).to_have_attribute("lang", "en") expect(self.page.locator("#refresh")).to_have_text("↻ Refresh") expect(self.page.locator("#reglagesButton")).to_have_text("⚙ Settings…") self.page.locator('header [data-lang-btn="fr"]').click() expect(self.page.locator("#refresh")).to_have_text("↻ Actualiser") def test_le_dialogue_reglages_et_ses_onglets(self): self.page.locator("#reglagesButton").click() reglages = self.page.locator("#reglages") expect(reglages).to_be_visible() # La case « Démarrer automatiquement » : affichée, jamais cochée ici, # puisqu'elle écrit dans le système. expect(self.page.locator("#autostart")).to_be_visible() expect(self.page.locator("#panelGeneral")).to_be_visible() self.page.keyboard.press("Escape") expect(reglages).to_be_hidden() def test_le_changement_de_vue(self): self.page.locator("#view").select_option("pictures") expect(self.page.locator("#view")).to_have_value("pictures") self.page.locator("#view").select_option("clips") expect(self.page.locator("#view")).to_have_value("clips") def test_la_ronde_des_directs(self): # Issue #40. Toujours sans Blink : /api/system et les vignettes sont # simulés, et watchLive() montre un flux tiré d'un canvas, qui émet de # vrais « playing ». Ce que Node ne voit pas est ici réel : les cases # déplacées dans le DOM, les clics, la mise en page. Le plein écran # dépend du navigateur et n'est pas exigé. systeme = {"webrtc": True, "passages": {}, "systems": [ {"name": "Dehors", "key": "dehors", "armed": True, "cameras": [ {"name": "jardin", "key": "k-jardin", "armed": True}, {"name": "Terrasse", "key": "k-terrasse", "armed": False}, {"name": "Garage", "key": "k-garage", "armed": True, "offline": True}]}]} self.page.route("**/api/system", lambda route: route.fulfill(json=systeme)) self.page.route("**/camthumb/**", lambda route: route.fulfill( status=200, content_type="image/gif", body=GIF_1X1)) self.page.evaluate(DIRECT_SIMULE) self.page.locator("#view").select_option("live") expect(self.page.locator("#rondeButton")).to_be_visible() expect(self.page.locator("#list .live")).to_have_count(3) self.page.locator("#rondeButton").click() expect(self.page.locator("#rondeCameras label")).to_have_count(3) self.page.locator("#rondeSecondes").fill("10") self.page.locator("#rondeDemarrer").click() expect(self.page.locator("#ronde")).to_be_visible() # Garage, hors ligne, est sauté. Le titre dit l'attente du réveil, puis, # dès la première image, le décompte du temps d'antenne (PR #113). expect(self.page.locator("#rondeTitre")).to_have_text(re.compile(r"^jardin · 1/2 ")) expect(self.page.locator("#rondeScene #live-k-jardin video")).to_have_count(1) self.page.wait_for_function("() => ronde.image") expect(self.page.locator("#rondeTitre")).to_have_text(re.compile(r"^jardin · 1/2 \(\d+\)$")) self.page.locator("#rondeSuivante").click() expect(self.page.locator("#rondeTitre")).to_have_text(re.compile(r"^Terrasse · 2/2 ")) expect(self.page.locator("#list #live-k-jardin")).to_have_count(1) self.page.locator("#rondeArreter").click() expect(self.page.locator("#ronde")).to_be_hidden() expect(self.page.locator("#list .live")).to_have_count(3) expect(self.page.locator("#rondeScene > *")).to_have_count(0) # Échap arrête aussi, plein écran accordé ou non. self.page.locator("#rondeButton").click() self.page.locator("#rondeDemarrer").click() expect(self.page.locator("#rondeTitre")).to_have_text(re.compile(r"^jardin · 1/2 ")) self.page.keyboard.press("Escape") expect(self.page.locator("#ronde")).to_be_hidden() expect(self.page.locator("#list .live")).to_have_count(3) GIF_1X1 = (b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\x00" b"\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;") # Remplace le direct par un flux de canvas : aucune requête vers le serveur. DIRECT_SIMULE = """() => { watchLive = (cle) => { const toile = document.createElement("canvas"); toile.width = 320; toile.height = 180; const dessin = toile.getContext("2d"); setInterval(() => { dessin.fillStyle = "#" + (Date.now() % 4096).toString(16).padStart(3, "0"); dessin.fillRect(0, 0, 320, 180); }, 100); const video = document.createElement("video"); video.muted = true; video.autoplay = true; video.playsInline = true; video.srcObject = toile.captureStream(10); const boite = document.getElementById("live-" + cssId(cle)); boite.replaceChildren(video); return new Promise(() => {}); }; stopWatch = (cle) => { const boite = document.getElementById("live-" + cssId(cle)); if (boite) boite.innerHTML = repos(cle, t("watch.live")); }; }""" if __name__ == "__main__": unittest.main()