"""User-story E2E tests for HearthNet — Playwright + screenshot proof. Each test class is a complete user story. Every story: - uses a REAL two-node in-memory mesh (no mocks) - drives a real Chromium browser via Playwright - saves annotated screenshots to docs/screenshots/stories/ Stories covered: US-01 Alice asks a question → LLM answers (Ask tab) US-02 Alice queries with RAG context (Ask + corpus) US-03 Routing trace proves which node answered (Ask routing panel) US-04 Alice sends a direct message to Bob (Chat tab) US-05 Alice opens the Mesh tab → sees Bob (live SVG graph) US-06 Alice refreshes peer list → sees Bob's capabilities (Settings) US-07 Alice posts to marketplace → post appears in list (Marketplace tab) US-08 Alice ingests a document into the knowledge base (Settings RAG ingest) US-09 Emergency tab shows connectivity mode (Emergency tab) US-10 Bob asks a question — answer comes from Alice's LLM (remote routing) US-11 All 7 tabs are present with correct headings US-12 Join-mesh QR section is shown in Settings Run: pytest tests/test_e2e_user_stories.py -v # Screenshots: docs/screenshots/stories/*.png """ from __future__ import annotations import socket import threading import time import urllib.request from pathlib import Path from typing import Generator import pytest SCREENSHOT_DIR = Path("docs/screenshots/stories") SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) # ────────────────────────────────────────────────────────────────────────────── # Fixtures # ────────────────────────────────────────────────────────────────────────────── def _free_port() -> int: s = socket.socket() s.bind(("127.0.0.1", 0)) p = s.getsockname()[1] s.close() return p def _wait_ready(port: int, timeout: float = 30.0) -> bool: deadline = time.time() + timeout while time.time() < deadline: try: urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2) # nosec B310 return True except Exception: time.sleep(0.4) return False @pytest.fixture(scope="module") def two_node_mesh(): """Launch Alice + Bob as a real in-memory mesh. Yield (port_alice, port_bob).""" from hearthnet.node import InMemoryNetwork from hearthnet.ui.app import build_ui net = InMemoryNetwork() alice = net.add_node("alice", "Alice", "ed25519:hearthnet-demo") bob = net.add_node("bob", "Bob", "ed25519:hearthnet-demo") alice.install_demo_services(corpus="alice-docs") bob.install_demo_services(corpus="bob-docs") net.mesh_discover() port_a, port_b = _free_port(), _free_port() def _build(node): return build_ui( bus=node.bus, state_bus=node.state_bus, display_name=node.display_name, node_id=node.node_id, community_id=node.community_id, ).build() demo_a = _build(alice) demo_b = _build(bob) def _launch(demo, port): demo.launch(server_name="127.0.0.1", server_port=port, prevent_thread_lock=True, quiet=True) threading.Thread(target=_launch, args=(demo_a, port_a), daemon=True).start() threading.Thread(target=_launch, args=(demo_b, port_b), daemon=True).start() if not _wait_ready(port_a) or not _wait_ready(port_b): pytest.skip("Gradio nodes did not start within 30s") time.sleep(1.0) yield port_a, port_b for demo in [demo_a, demo_b]: try: demo.close() except Exception: pass @pytest.fixture(scope="module") def pw_browser(): """Shared Playwright browser for the module.""" pytest.importorskip("playwright", reason="playwright not installed") from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) yield browser browser.close() def _alice_page(pw_browser, two_node_mesh): port_a, _ = two_node_mesh ctx = pw_browser.new_context( base_url=f"http://127.0.0.1:{port_a}", viewport={"width": 1280, "height": 900}, ) page = ctx.new_page() page.goto("/", wait_until="networkidle", timeout=20_000) return page, ctx def _bob_page(pw_browser, two_node_mesh): _, port_b = two_node_mesh ctx = pw_browser.new_context( base_url=f"http://127.0.0.1:{port_b}", viewport={"width": 1280, "height": 900}, ) page = ctx.new_page() page.goto("/", wait_until="networkidle", timeout=20_000) return page, ctx def _tab(page, name: str, timeout: int = 15_000) -> None: page.get_by_role("tab", name=name).click() page.wait_for_load_state("networkidle", timeout=timeout) def _ss(page, name: str, caption: str) -> Path: """Save a screenshot with a descriptive name. Print caption.""" path = SCREENSHOT_DIR / f"{name}.png" page.screenshot(path=str(path), full_page=False) print(f"\n 📸 {path.name}: {caption}") return path # ────────────────────────────────────────────────────────────────────────────── # US-01 Ask tab: Alice queries the LLM # ────────────────────────────────────────────────────────────────────────────── class TestUS01AskLlm: """User story: Alice opens HearthNet and asks the mesh a question.""" def test_ask_tab_visible(self, pw_browser, two_node_mesh): page, ctx = _alice_page(pw_browser, two_node_mesh) try: assert page.get_by_role("tab", name="Ask").count() > 0 _ss(page, "US01-01-alice-home", "Alice's HearthNet node — home screen (Ask tab active)") finally: ctx.close() def test_ask_question_receives_response(self, pw_browser, two_node_mesh): """ Alice types 'What is HearthNet?' and the bus routes to the LLM. A response appears in the chat window. """ page, ctx = _alice_page(pw_browser, two_node_mesh) try: _ss( page, "US01-02-ask-empty", "Ask tab before sending — shows corpus selector, model selector, chat area", ) page.locator("textarea").first.fill("What is HearthNet?") page.get_by_role("button", name="Send").first.click() page.wait_for_timeout(4000) content = page.content() _ss( page, "US01-03-ask-response", "Ask tab after sending — LLM response appears in chat, routing trace shown below", ) # Response must exist — no fabricated fallback assert "HearthNet" in content or "demo-local" in content or "mesh" in content.lower(), ( "Expected LLM response content" ) finally: ctx.close() def test_routing_trace_appears(self, pw_browser, two_node_mesh): """ After sending a question, the routing trace panel appears showing which capability and which node answered. """ page, ctx = _alice_page(pw_browser, two_node_mesh) try: page.locator("textarea").first.fill("Tell me about routing.") page.get_by_role("button", name="Send").first.click() page.wait_for_timeout(4000) content = page.content() _ss( page, "US01-04-routing-trace", "Routing trace JSON — shows capability, routed_via node ID", ) # Routing trace panel should have appeared (contains routing keys) assert any(kw in content for kw in ["llm.chat", "routed_via", "capability", "rag"]) finally: ctx.close() # ────────────────────────────────────────────────────────────────────────────── # US-02 Ask tab + RAG: Alice queries with corpus context # ────────────────────────────────────────────────────────────────────────────── class TestUS02AskRag: """User story: Alice selects a RAG corpus and asks a context-aware question.""" def test_ask_with_rag_corpus_selected(self, pw_browser, two_node_mesh): """ Alice selects corpus='alice-docs', asks 'How do I filter water?'. RAG retrieval runs first, chunks feed the LLM. """ page, ctx = _alice_page(pw_browser, two_node_mesh) try: # Select corpus from dropdown — find dropdown near "RAG Corpus" try: corpus_dropdown = page.locator("select").first corpus_dropdown.select_option(label="alice-docs") except Exception: pass # Gradio dropdown may not be a