Spaces:
Running on Zero
Running on Zero
GitHub Actions
Quality improvements: Unicode chars, Token class, imports, type hints, formatting
3f78ea8 | """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] | |
| 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 | |
| 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() | |
| 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() | |