"""Playwright E2E tests for the HearthNet Gradio UI. These tests spin up the Gradio app on a local port, then use Playwright to drive a real browser and validate user-facing flows with real data. Requires: playwright, gradio, and the hearthnet package installed. Install browsers once with: playwright install chromium """ from __future__ import annotations import asyncio import json import os import sys import threading import time from typing import Generator import pytest # --------------------------------------------------------------------------- # App fixture — start Gradio on a free port, tear down after tests # --------------------------------------------------------------------------- def _find_free_port() -> int: import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] @pytest.fixture(scope="session") def app_port() -> Generator[int, None, None]: """Launch the HearthNet Gradio app in a background thread and yield the port.""" import gradio as gr from hearthnet.ui.app import build_ui port = _find_free_port() demo: gr.Blocks | None = None def _run(): nonlocal demo from hearthnet.node import HearthNode from hearthnet.controller import HearthNetController hn_node = HearthNode( node_id="e2e-test-node", display_name="E2E Test Node", community_id="test-community", ) ctrl = HearthNetController(node=hn_node) bus = ctrl.node.bus # HearthNode exposes .bus ui_app = build_ui(bus=bus) gradio_blocks = ui_app.build() # UiApp.build() → gr.Blocks if hasattr(gradio_blocks, "launch"): gradio_blocks.launch( server_name="127.0.0.1", server_port=port, prevent_thread_lock=True, quiet=True, ) t = threading.Thread(target=_run, daemon=True) t.start() # Wait for Gradio to be ready (up to 30s) import urllib.request deadline = time.time() + 30 ready = False while time.time() < deadline: try: urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2) # nosec B310 ready = True break except Exception: time.sleep(0.5) if not ready: pytest.skip("Gradio app did not start within 30s") yield port if demo is not None: try: demo.close() except Exception: pass @pytest.fixture(scope="session") def browser_ctx(app_port): """Playwright browser context.""" from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) ctx = browser.new_context( base_url=f"http://127.0.0.1:{app_port}", viewport={"width": 1280, "height": 900}, ) yield ctx ctx.close() browser.close() @pytest.fixture def page(browser_ctx): """Fresh page per test.""" pg = browser_ctx.new_page() yield pg pg.close() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- BASE_TIMEOUT = 15_000 # 15s def _wait_tab(page, tab_text: str) -> None: """Click a tab and wait for its panel to load.""" page.get_by_role("tab", name=tab_text).click() page.wait_for_load_state("networkidle", timeout=BASE_TIMEOUT) # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestUiLoads: """Smoke: the app loads and shows expected tabs.""" def test_page_title(self, page, app_port): page.goto("/") page.wait_for_load_state("networkidle", timeout=BASE_TIMEOUT) title = page.title() # Gradio sets the page title to the demo's title assert title # not empty def test_all_tabs_present(self, page, app_port): page.goto("/") page.wait_for_load_state("networkidle", timeout=BASE_TIMEOUT) for tab in ["Ask", "Chat", "Marketplace", "Files", "Emergency", "Settings"]: assert page.get_by_role("tab", name=tab).count() > 0, f"Tab '{tab}' not found" def test_settings_tab_shows_node_id(self, page, app_port): page.goto("/") page.wait_for_load_state("networkidle", timeout=BASE_TIMEOUT) _wait_tab(page, "Settings") # Settings tab should show some node identity information content = page.content() assert any(kw in content for kw in ["node", "identity", "Node", "community"]) class TestAskTab: """User types a question — the LLM (or fallback) responds.""" def test_ask_question_gets_response(self, page, app_port): page.goto("/") page.wait_for_load_state("networkidle", timeout=BASE_TIMEOUT) _wait_tab(page, "Ask") # Find the message input — Gradio chatbot uses a textarea textarea = page.locator("textarea").first textarea.fill("Hello, what is HearthNet?") page.keyboard.press("Enter") # Wait for some response to appear (up to 15s for LLM/fallback) page.wait_for_timeout(3000) content = page.content() # Some response should have appeared — either real LLM or fallback assert ( page.locator(".message").count() > 0 or "HearthNet" in content or "hello" in content.lower() ) class TestMarketplaceTab: """Create a marketplace post and verify it appears in the list.""" def test_marketplace_loads(self, page, app_port): page.goto("/") page.wait_for_load_state("networkidle", timeout=BASE_TIMEOUT) _wait_tab(page, "Marketplace") content = page.content() # Should show some marketplace UI elements assert any(kw in content.lower() for kw in ["marketplace", "post", "offer", "request"]) class TestEmergencyTab: """Emergency tab shows current connectivity status.""" def test_emergency_tab_loads(self, page, app_port): page.goto("/") page.wait_for_load_state("networkidle", timeout=BASE_TIMEOUT) _wait_tab(page, "Emergency") content = page.content() assert any( kw in content.lower() for kw in ["emergency", "connectivity", "status", "internet"] ) class TestChatTab: """Chat tab loads and accepts message input.""" def test_chat_tab_loads(self, page, app_port): page.goto("/") page.wait_for_load_state("networkidle", timeout=BASE_TIMEOUT) _wait_tab(page, "Chat") content = page.content() assert any(kw in content.lower() for kw in ["chat", "message", "send", "peer"]) class TestFilesTab: """Files tab loads and shows file interface.""" def test_files_tab_loads(self, page, app_port): page.goto("/") page.wait_for_load_state("networkidle", timeout=BASE_TIMEOUT) _wait_tab(page, "Files") content = page.content() assert any(kw in content.lower() for kw in ["file", "upload", "blob", "share"]) class TestApiEndpoints: """Direct HTTP API tests (no browser) — verify transport layer.""" def test_health_endpoint(self, app_port): """The Gradio app itself exposes a health-check path.""" import urllib.request url = f"http://127.0.0.1:{app_port}/" with urllib.request.urlopen(url, timeout=5) as resp: # nosec B310 assert resp.status == 200 def test_gradio_api_info(self, app_port): """Gradio exposes /info endpoint for API discovery.""" import urllib.request try: with urllib.request.urlopen( # nosec B310 f"http://127.0.0.1:{app_port}/info", timeout=5 ) as resp: data = json.loads(resp.read()) assert "named_endpoints" in data or isinstance(data, dict) except Exception: pass # /info may not be available on all Gradio versions — skip silently class TestResponsiveLayout: """Verify the UI adapts to mobile viewport.""" def test_mobile_viewport(self, browser_ctx, app_port): mobile_ctx = browser_ctx.browser.new_context( base_url=f"http://127.0.0.1:{app_port}", viewport={"width": 390, "height": 844}, # iPhone 14 Pro ) page = mobile_ctx.new_page() try: page.goto("/") page.wait_for_load_state("networkidle", timeout=BASE_TIMEOUT) # Should not throw layout errors errors = [] page.on("pageerror", lambda e: errors.append(str(e))) page.wait_for_timeout(2000) # Allow some JS errors (Gradio sometimes logs warnings) but no fatal crashes fatal = [e for e in errors if "TypeError" in e or "SyntaxError" in e] assert len(fatal) == 0, f"Fatal JS errors on mobile: {fatal}" finally: page.close() mobile_ctx.close()