HearthNet-Nemotron / tests /test_e2e_playwright.py
GitHub Actions
Quality improvements: Unicode chars, Token class, imports, type hints, formatting
3f78ea8
Raw
History Blame
9.18 kB
"""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()