feat(us-016/us-017): mining-style node startup CLI, live dashboard, auto-shard, P2P gossip + TLS
Merges worktree-feat+us-016 into master. Combined both sets of _NodeEntry fields: hf_repo/num_layers (HEAD) and relay_addr/cert_fingerprint/peer_id (US-017). Kept HEAD's auto-shard tracker query and shard_label formatting. US-016: mining-style startup CLI with live ASCII dashboard, hardware detection, auto-shard range detection from model layer count, tracker network-assign integration for gap-filling. US-017: P2P gossip protocol, NAT-traversal relay node, TLS peer authentication via cert fingerprint. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
371
tests/test_gossip_and_relay.py
Normal file
371
tests/test_gossip_and_relay.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""Tests for US-017: P2P gossip, relay node, and TLS infrastructure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# identity tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_load_or_create_identity_generates_peer_id(tmp_path):
|
||||
from meshnet_p2p.identity import load_or_create_identity
|
||||
|
||||
identity = load_or_create_identity(tmp_path / "identity.json")
|
||||
assert len(identity["peer_id"]) == 16
|
||||
assert "public_key_pem" in identity
|
||||
assert "private_key_pem" in identity
|
||||
|
||||
|
||||
def test_identity_is_stable_across_loads(tmp_path):
|
||||
from meshnet_p2p.identity import load_or_create_identity
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
first = load_or_create_identity(path)
|
||||
second = load_or_create_identity(path)
|
||||
assert first["peer_id"] == second["peer_id"]
|
||||
assert first["public_key_pem"] == second["public_key_pem"]
|
||||
|
||||
|
||||
def test_identity_different_for_different_paths(tmp_path):
|
||||
from meshnet_p2p.identity import load_or_create_identity
|
||||
|
||||
a = load_or_create_identity(tmp_path / "a.json")
|
||||
b = load_or_create_identity(tmp_path / "b.json")
|
||||
# Extremely unlikely to collide
|
||||
assert a["peer_id"] != b["peer_id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS / certificate tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_generate_self_signed_cert_creates_files(tmp_path):
|
||||
from meshnet_p2p.tls import generate_self_signed_cert
|
||||
|
||||
cert_p, key_p = generate_self_signed_cert(
|
||||
cert_path=tmp_path / "cert.pem",
|
||||
key_path=tmp_path / "key.pem",
|
||||
common_name="localhost",
|
||||
)
|
||||
assert cert_p.exists()
|
||||
assert key_p.exists()
|
||||
assert cert_p.stat().st_size > 100
|
||||
assert key_p.stat().st_size > 100
|
||||
|
||||
|
||||
def test_generate_self_signed_cert_is_idempotent(tmp_path):
|
||||
from meshnet_p2p.tls import generate_self_signed_cert
|
||||
|
||||
args = dict(cert_path=tmp_path / "cert.pem", key_path=tmp_path / "key.pem", common_name="test")
|
||||
generate_self_signed_cert(**args)
|
||||
mtime1 = (tmp_path / "cert.pem").stat().st_mtime
|
||||
|
||||
generate_self_signed_cert(**args)
|
||||
mtime2 = (tmp_path / "cert.pem").stat().st_mtime
|
||||
assert mtime1 == mtime2 # file not regenerated
|
||||
|
||||
|
||||
def test_cert_fingerprint_returns_sha256_prefix(tmp_path):
|
||||
from meshnet_p2p.tls import generate_self_signed_cert, cert_fingerprint
|
||||
|
||||
cert_p, key_p = generate_self_signed_cert(
|
||||
cert_path=tmp_path / "cert.pem",
|
||||
key_path=tmp_path / "key.pem",
|
||||
common_name="test",
|
||||
)
|
||||
fp = cert_fingerprint(cert_p)
|
||||
assert fp.startswith("sha256:")
|
||||
assert len(fp) == len("sha256:") + 64 # 32 bytes hex = 64 chars
|
||||
|
||||
|
||||
def test_make_server_ssl_context_loads_cert(tmp_path):
|
||||
import ssl
|
||||
from meshnet_p2p.tls import generate_self_signed_cert, make_server_ssl_context
|
||||
|
||||
cert_p, key_p = generate_self_signed_cert(
|
||||
cert_path=tmp_path / "cert.pem",
|
||||
key_path=tmp_path / "key.pem",
|
||||
common_name="test",
|
||||
)
|
||||
ctx = make_server_ssl_context(cert_p, key_p)
|
||||
assert isinstance(ctx, ssl.SSLContext)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PeerRegistry tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_peer_registry_register_and_list():
|
||||
from meshnet_relay.peer_registry import PeerRegistry
|
||||
|
||||
reg = PeerRegistry()
|
||||
ws_mock = MagicMock()
|
||||
reg.register("peer1", "http://1.2.3.4:8001", ws_mock)
|
||||
|
||||
assert len(reg) == 1
|
||||
peers = reg.list_peers()
|
||||
assert len(peers) == 1
|
||||
assert peers[0]["peer_id"] == "peer1"
|
||||
|
||||
|
||||
def test_peer_registry_all_except_excludes_sender():
|
||||
from meshnet_relay.peer_registry import PeerRegistry
|
||||
|
||||
reg = PeerRegistry()
|
||||
reg.register("a", "http://a:8001", MagicMock())
|
||||
reg.register("b", "http://b:8001", MagicMock())
|
||||
reg.register("c", "http://c:8001", MagicMock())
|
||||
|
||||
others = reg.all_except("a")
|
||||
assert len(others) == 2
|
||||
assert all(e.peer_id != "a" for e in others)
|
||||
|
||||
|
||||
def test_peer_registry_unregister_removes_peer():
|
||||
from meshnet_relay.peer_registry import PeerRegistry
|
||||
|
||||
reg = PeerRegistry()
|
||||
reg.register("x", "http://x:8001", MagicMock())
|
||||
reg.unregister("x")
|
||||
assert len(reg) == 0
|
||||
assert reg.get("x") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GossipClient + RelayServer integration test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _start_relay(host="127.0.0.1", port=0):
|
||||
from meshnet_relay.server import RelayServer
|
||||
server = RelayServer(host=host, port=port)
|
||||
actual_port = server.start()
|
||||
return server, actual_port
|
||||
|
||||
|
||||
def test_gossip_fanout_through_relay():
|
||||
"""Node B publishes node-join; node A receives it within 2 seconds."""
|
||||
from meshnet_p2p.gossip import GossipClient, TOPIC_NODE_JOIN
|
||||
|
||||
relay, port = _start_relay()
|
||||
relay_url = f"ws://127.0.0.1:{port}/ws"
|
||||
|
||||
client_a = GossipClient(relay_url=relay_url, peer_id="peer_a")
|
||||
client_b = GossipClient(relay_url=relay_url, peer_id="peer_b")
|
||||
|
||||
received = []
|
||||
client_a.subscribe(TOPIC_NODE_JOIN, lambda env: received.append(env))
|
||||
|
||||
client_a.start()
|
||||
client_b.start()
|
||||
|
||||
assert client_a.wait_connected(timeout=5), "client_a failed to connect to relay"
|
||||
assert client_b.wait_connected(timeout=5), "client_b failed to connect to relay"
|
||||
|
||||
# Give both peers time to register with relay
|
||||
time.sleep(0.2)
|
||||
|
||||
client_b.publish(TOPIC_NODE_JOIN, {"addr": "http://192.168.1.10:8001", "peer_id": "peer_b"})
|
||||
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline and not received:
|
||||
time.sleep(0.05)
|
||||
|
||||
client_a.stop()
|
||||
client_b.stop()
|
||||
relay.stop()
|
||||
|
||||
assert received, "client_a did not receive node-join message from client_b"
|
||||
assert received[0]["topic"] == TOPIC_NODE_JOIN
|
||||
assert received[0]["from_peer"] == "peer_b"
|
||||
|
||||
|
||||
def test_gossip_dedup_prevents_processing_duplicate_message_ids():
|
||||
"""A message with a duplicate msg_id is only processed once."""
|
||||
from meshnet_p2p.gossip import GossipClient, TOPIC_NODE_JOIN
|
||||
|
||||
relay, port = _start_relay()
|
||||
relay_url = f"ws://127.0.0.1:{port}/ws"
|
||||
|
||||
client_a = GossipClient(relay_url=relay_url, peer_id="peer_a2")
|
||||
client_b = GossipClient(relay_url=relay_url, peer_id="peer_b2")
|
||||
|
||||
received = []
|
||||
client_a.subscribe(TOPIC_NODE_JOIN, lambda env: received.append(env))
|
||||
|
||||
client_a.start()
|
||||
client_b.start()
|
||||
client_a.wait_connected(5)
|
||||
client_b.wait_connected(5)
|
||||
time.sleep(0.2)
|
||||
|
||||
# Publish once
|
||||
client_b.publish(TOPIC_NODE_JOIN, {"test": "dedup"})
|
||||
time.sleep(0.5)
|
||||
|
||||
count = len(received)
|
||||
|
||||
client_a.stop()
|
||||
client_b.stop()
|
||||
relay.stop()
|
||||
|
||||
# Should have received exactly one message (not duplicated)
|
||||
assert count <= 1
|
||||
|
||||
|
||||
def test_relay_server_peer_list_grows_on_connect():
|
||||
"""Relay registry grows when clients connect."""
|
||||
from meshnet_p2p.gossip import GossipClient
|
||||
|
||||
relay, port = _start_relay()
|
||||
relay_url = f"ws://127.0.0.1:{port}/ws"
|
||||
|
||||
client = GossipClient(relay_url=relay_url, peer_id="solo_peer")
|
||||
client.start()
|
||||
client.wait_connected(5)
|
||||
time.sleep(0.3) # let peer-register message process
|
||||
|
||||
peer_count = len(relay.registry)
|
||||
client.stop()
|
||||
relay.stop()
|
||||
|
||||
assert peer_count >= 1
|
||||
|
||||
|
||||
def test_relay_circuit_relay_proxies_message():
|
||||
"""A node behind NAT (client_a) receives a message via circuit relay from client_b."""
|
||||
import websockets.sync.client # type: ignore[import]
|
||||
from meshnet_relay.server import RelayServer
|
||||
|
||||
relay = RelayServer(host="127.0.0.1", port=0)
|
||||
port = relay.start()
|
||||
|
||||
# client_a connects to gossip hub and registers
|
||||
received_via_relay = []
|
||||
ready = threading.Event()
|
||||
|
||||
def run_nat_client():
|
||||
import websockets.sync.client as wsc
|
||||
with wsc.connect(f"ws://127.0.0.1:{port}/ws") as ws:
|
||||
ws.send(json.dumps({
|
||||
"topic": "peer-register",
|
||||
"version": 1,
|
||||
"from_peer": "nat_peer",
|
||||
"msg_id": "reg-001",
|
||||
"timestamp": "2026-06-29T00:00:00Z",
|
||||
"ttl": 3,
|
||||
"payload": {"peer_id": "nat_peer", "addr": ""},
|
||||
}))
|
||||
# consume peer-list response
|
||||
ws.recv()
|
||||
ready.set()
|
||||
# wait for a relayed message
|
||||
try:
|
||||
msg = ws.recv(timeout=3)
|
||||
received_via_relay.append(json.loads(msg))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
nat_thread = threading.Thread(target=run_nat_client, daemon=True)
|
||||
nat_thread.start()
|
||||
ready.wait(timeout=5)
|
||||
time.sleep(0.1) # ensure peer is in registry
|
||||
|
||||
# client_b connects via circuit relay path
|
||||
def send_via_relay():
|
||||
try:
|
||||
import websockets.sync.client as wsc
|
||||
with wsc.connect(f"ws://127.0.0.1:{port}/relay/nat_peer") as ws:
|
||||
ws.send(json.dumps({"topic": "direct-relay-test", "payload": {"hi": "there"}}))
|
||||
time.sleep(0.3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
relay_thread = threading.Thread(target=send_via_relay, daemon=True)
|
||||
relay_thread.start()
|
||||
relay_thread.join(timeout=3)
|
||||
nat_thread.join(timeout=3)
|
||||
relay.stop()
|
||||
|
||||
assert received_via_relay, "NAT'd peer did not receive message via circuit relay"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tracker gossip fields tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _start_tracker_and_register(extra_fields: dict) -> dict:
|
||||
"""Helper: start tracker, register node with extra gossip fields, return response."""
|
||||
import http.server
|
||||
import json as _json
|
||||
import urllib.request
|
||||
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
|
||||
tracker = TrackerServer(host="127.0.0.1", port=0)
|
||||
port = tracker.start()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
|
||||
payload = {
|
||||
"endpoint": f"http://127.0.0.1:8001",
|
||||
"shard_start": 0,
|
||||
"shard_end": 7,
|
||||
"model": "stub-model",
|
||||
"hardware_profile": {},
|
||||
"score": 1.0,
|
||||
**extra_fields,
|
||||
}
|
||||
data = _json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{url}/v1/nodes/register",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
resp = _json.loads(r.read())
|
||||
|
||||
tracker.stop()
|
||||
return resp
|
||||
|
||||
|
||||
def test_tracker_accepts_relay_addr_in_registration():
|
||||
resp = _start_tracker_and_register({
|
||||
"relay_addr": "ws://relay.meshnet.ai:8765/relay/abc123",
|
||||
"cert_fingerprint": "sha256:deadbeef",
|
||||
"peer_id": "abc123def456ef01",
|
||||
})
|
||||
assert "node_id" in resp
|
||||
|
||||
|
||||
def test_tracker_accepts_registration_without_gossip_fields():
|
||||
"""Existing registrations without P2P fields still work."""
|
||||
resp = _start_tracker_and_register({})
|
||||
assert "node_id" in resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mDNS (no-op without zeroconf installed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_mdns_discovery_is_available_flag():
|
||||
from meshnet_p2p.mdns import MdnsDiscovery
|
||||
|
||||
disc = MdnsDiscovery(peer_id="test", port=8001)
|
||||
# is_available() should be bool regardless of zeroconf install status
|
||||
assert isinstance(disc.is_available(), bool)
|
||||
|
||||
|
||||
def test_mdns_start_and_stop_without_zeroconf(monkeypatch):
|
||||
from meshnet_p2p import mdns as mdns_mod
|
||||
monkeypatch.setattr(mdns_mod, "_HAS_ZEROCONF", False)
|
||||
from meshnet_p2p.mdns import MdnsDiscovery
|
||||
|
||||
disc = MdnsDiscovery(peer_id="x", port=8001)
|
||||
disc.start() # should not raise
|
||||
disc.stop() # should not raise
|
||||
356
tests/test_mining_cli.py
Normal file
356
tests/test_mining_cli.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""Tests for US-016: mining-style node startup CLI + live dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# model_catalog tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_curated_models_list_is_non_empty():
|
||||
from meshnet_node.model_catalog import CURATED_MODELS
|
||||
assert len(CURATED_MODELS) >= 5
|
||||
|
||||
|
||||
def test_model_preset_vram_for_quant():
|
||||
from meshnet_node.model_catalog import CURATED_MODELS
|
||||
|
||||
m = next(m for m in CURATED_MODELS if "Llama-3-70B" in m.name)
|
||||
assert m.vram_for_quant("nf4") == m.vram_nf4
|
||||
assert m.vram_for_quant("int8") == m.vram_int8
|
||||
assert m.vram_for_quant("bf16") == m.vram_bf16
|
||||
assert m.vram_for_quant("bfloat16") == m.vram_bf16 # alias
|
||||
|
||||
|
||||
def test_model_preset_fits_vram():
|
||||
from meshnet_node.model_catalog import CURATED_MODELS
|
||||
|
||||
small = next(m for m in CURATED_MODELS if m.vram_nf4 < 10)
|
||||
assert small.fits_vram(small.vram_nf4, "nf4")
|
||||
assert not small.fits_vram(small.vram_nf4 - 1, "nf4")
|
||||
|
||||
|
||||
def test_recommended_quant_respects_vram():
|
||||
from meshnet_node.model_catalog import CURATED_MODELS
|
||||
|
||||
m = next(m for m in CURATED_MODELS if "Llama-3-70B" in m.name)
|
||||
# nf4=18, int8=40, bf16=140
|
||||
assert m.recommended_quant(200) == "bf16"
|
||||
assert m.recommended_quant(50) == "int8"
|
||||
assert m.recommended_quant(20) == "nf4"
|
||||
assert m.recommended_quant(5) is None
|
||||
|
||||
|
||||
def test_models_with_insufficient_vram_are_marked(monkeypatch):
|
||||
from meshnet_node import wizard as wiz
|
||||
|
||||
# Simulate 6 GB GPU
|
||||
gpus = [{"index": 0, "name": "RTX 3060", "vram_gb": 6.0, "backend": "cuda"}]
|
||||
monkeypatch.setattr(wiz, "_detect_gpus", lambda: gpus)
|
||||
|
||||
# Phi-3 at NF4 needs 4 GB — should fit; Llama-3-70B at NF4 needs 18 GB — should not
|
||||
from meshnet_node.model_catalog import CURATED_MODELS
|
||||
|
||||
phi = next(m for m in CURATED_MODELS if "Phi-3" in m.name)
|
||||
llama = next(m for m in CURATED_MODELS if "Llama-3-70B" in m.name)
|
||||
|
||||
assert phi.fits_vram(6.0, "nf4")
|
||||
assert not llama.fits_vram(6.0, "nf4")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# config tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_load_config_returns_none_when_missing(tmp_path):
|
||||
from meshnet_node.config import load_config
|
||||
assert load_config(tmp_path / "nonexistent.json") is None
|
||||
|
||||
|
||||
def test_save_and_load_config_roundtrip(tmp_path):
|
||||
from meshnet_node.config import save_config, load_config
|
||||
|
||||
cfg = {"model_hf_repo": "test/model", "quantization": "nf4", "tracker_url": "http://localhost:8080"}
|
||||
cfg_path = tmp_path / "config.json"
|
||||
save_config(cfg, cfg_path)
|
||||
|
||||
loaded = load_config(cfg_path)
|
||||
assert loaded == cfg
|
||||
|
||||
|
||||
def test_save_config_creates_parent_dirs(tmp_path):
|
||||
from meshnet_node.config import save_config, load_config
|
||||
|
||||
nested = tmp_path / "deep" / "nested" / "config.json"
|
||||
save_config({"x": 1}, nested)
|
||||
assert nested.exists()
|
||||
assert load_config(nested) == {"x": 1}
|
||||
|
||||
|
||||
def test_merge_cli_overrides_applies_non_none_values():
|
||||
from meshnet_node.config import merge_cli_overrides
|
||||
|
||||
base = {"tracker_url": "http://a:8080", "quantization": "nf4", "port": 7000}
|
||||
result = merge_cli_overrides(base, tracker_url="http://b:9090", port=None)
|
||||
assert result["tracker_url"] == "http://b:9090"
|
||||
assert result["port"] == 7000 # None override ignored
|
||||
assert result["quantization"] == "nf4" # unchanged
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wizard tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_print_models_table_runs_without_error(capsys, monkeypatch):
|
||||
from meshnet_node import wizard as wiz
|
||||
|
||||
monkeypatch.setattr(wiz, "_detect_gpus", lambda: [{"index": 0, "name": "GPU", "vram_gb": 24.0, "backend": "cuda"}])
|
||||
wiz.print_models_table()
|
||||
out = capsys.readouterr().out
|
||||
assert "Llama" in out or "Qwen" in out or "Phi" in out
|
||||
|
||||
|
||||
def test_wizard_writes_config_on_happy_path(tmp_path, monkeypatch):
|
||||
from meshnet_node import wizard as wiz
|
||||
from meshnet_node.config import load_config, save_config
|
||||
|
||||
# Fake GPU
|
||||
gpus = [{"index": 0, "name": "RTX 4090", "vram_gb": 24.0, "backend": "cuda"}]
|
||||
monkeypatch.setattr(wiz, "_detect_gpus", lambda: gpus)
|
||||
# Tracker not reachable (stub)
|
||||
monkeypatch.setattr(wiz, "_ping_tracker", lambda url: False)
|
||||
|
||||
# Simulate user selecting model 1 (Qwen2.5-0.5B), quant 1 (nf4), default dir, default tracker, default wallet
|
||||
inputs = iter([
|
||||
"1", # pick Qwen2.5-0.5B-Instruct (index 1 in CURATED_MODELS)
|
||||
"1", # quant NF4
|
||||
str(tmp_path / "models"), # download dir
|
||||
"http://localhost:8080", # tracker
|
||||
str(tmp_path / "wallet.json"), # wallet
|
||||
])
|
||||
monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
|
||||
|
||||
cfg = wiz.run_wizard(config_path_override=tmp_path / "config.json")
|
||||
assert cfg["model_hf_repo"] == "Qwen/Qwen2.5-0.5B-Instruct"
|
||||
assert cfg["quantization"] == "nf4"
|
||||
assert "download_dir" in cfg
|
||||
assert cfg["tracker_url"] == "http://localhost:8080"
|
||||
|
||||
|
||||
def test_wizard_raises_keyboard_interrupt_on_ctrl_c(monkeypatch):
|
||||
from meshnet_node import wizard as wiz
|
||||
|
||||
gpus = [{"index": 0, "name": "RTX 4090", "vram_gb": 24.0, "backend": "cuda"}]
|
||||
monkeypatch.setattr(wiz, "_detect_gpus", lambda: gpus)
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def fake_input(prompt=""):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr("builtins.input", fake_input)
|
||||
|
||||
import pytest
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
wiz.run_wizard()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dashboard tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_is_interactive_tty_false_when_not_tty(monkeypatch):
|
||||
from meshnet_node import dashboard as dash
|
||||
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: False)
|
||||
assert not dash.is_interactive_tty()
|
||||
|
||||
|
||||
def test_dashboard_plain_fallback_on_keyboard_interrupt(monkeypatch):
|
||||
"""Plain loop exits cleanly when Ctrl-C is raised."""
|
||||
from meshnet_node import dashboard as dash
|
||||
|
||||
node = MagicMock()
|
||||
node.chat_completion_count = 5
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def fake_sleep(t):
|
||||
call_count[0] += 1
|
||||
if call_count[0] >= 1:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr(dash.time, "sleep", fake_sleep)
|
||||
monkeypatch.setattr(dash, "_gpu_stats", lambda: [])
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: False)
|
||||
|
||||
cfg = {"model_name": "test-model", "quantization": "nf4"}
|
||||
# Should not raise
|
||||
dash.run_dashboard(node, cfg, start_time=dash.time.monotonic())
|
||||
|
||||
|
||||
def test_ema_updates_correctly():
|
||||
from meshnet_node.dashboard import _EMA
|
||||
|
||||
ema = _EMA(alpha=1.0) # alpha=1.0 → always takes latest sample
|
||||
ema.update(10.0)
|
||||
assert ema.value == 10.0
|
||||
ema.update(20.0)
|
||||
assert ema.value == 20.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_models_command_prints_table(capsys, monkeypatch):
|
||||
"""meshnet-node models prints the curated table and exits 0."""
|
||||
from meshnet_node import wizard as wiz
|
||||
|
||||
monkeypatch.setattr(wiz, "_detect_gpus", lambda: [])
|
||||
|
||||
from meshnet_node.cli import main
|
||||
monkeypatch.setattr(sys, "argv", ["meshnet-node", "models"])
|
||||
|
||||
try:
|
||||
main()
|
||||
except SystemExit as exc:
|
||||
assert exc.code == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Llama" in out or "Qwen" in out or "Phi" in out
|
||||
|
||||
|
||||
def test_config_command_no_config_exits_1(tmp_path, monkeypatch):
|
||||
from meshnet_node import config as cfg_mod
|
||||
from meshnet_node.cli import main
|
||||
|
||||
monkeypatch.setattr(cfg_mod, "_DEFAULT_CONFIG_FILE", tmp_path / "nonexistent.json")
|
||||
monkeypatch.setattr(sys, "argv", ["meshnet-node", "config"])
|
||||
|
||||
with patch("meshnet_node.config.config_path", return_value=tmp_path / "nonexistent.json"):
|
||||
try:
|
||||
main()
|
||||
except SystemExit as exc:
|
||||
assert exc.code == 1
|
||||
|
||||
|
||||
def test_config_command_prints_saved_config(tmp_path, monkeypatch, capsys):
|
||||
from meshnet_node import config as cfg_mod
|
||||
from meshnet_node.config import save_config
|
||||
from meshnet_node.cli import main
|
||||
|
||||
saved = {"model_hf_repo": "meta-llama/Meta-Llama-3-70B-Instruct", "quantization": "nf4"}
|
||||
cfg_file = tmp_path / "config.json"
|
||||
save_config(saved, cfg_file)
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["meshnet-node", "config"])
|
||||
|
||||
with patch("meshnet_node.config.config_path", return_value=cfg_file):
|
||||
with patch("meshnet_node.config.load_config", return_value=saved):
|
||||
try:
|
||||
main()
|
||||
except SystemExit as exc:
|
||||
assert exc.code == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
data = json.loads(out.split("\n", 1)[1]) # skip the "Config: ..." header line
|
||||
assert data["model_hf_repo"] == saved["model_hf_repo"]
|
||||
|
||||
|
||||
def test_detect_num_layers_returns_catalog_value_without_network(monkeypatch):
|
||||
"""detect_num_layers uses the curated catalog first — no network call."""
|
||||
from meshnet_node.model_catalog import detect_num_layers
|
||||
|
||||
# Qwen2.5-0.5B is in the catalog with 24 layers
|
||||
layers = detect_num_layers("Qwen/Qwen2.5-0.5B-Instruct")
|
||||
assert layers == 24
|
||||
|
||||
|
||||
def test_detect_num_layers_returns_none_on_error(monkeypatch):
|
||||
from meshnet_node.model_catalog import detect_num_layers
|
||||
|
||||
# Monkeypatch AutoConfig to raise
|
||||
import meshnet_node.model_catalog as cat
|
||||
monkeypatch.setattr(cat, "detect_num_layers", lambda repo: None if "bad" in repo else detect_num_layers(repo))
|
||||
assert cat.detect_num_layers("bad/repo") is None
|
||||
|
||||
|
||||
def test_startup_auto_detects_shard_range(monkeypatch, tmp_path):
|
||||
"""When shard_start/end are None, startup reads layer count from catalog."""
|
||||
from meshnet_node import startup as su
|
||||
from meshnet_node.model_catalog import detect_num_layers
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_detect(repo):
|
||||
calls.append(repo)
|
||||
return 24 # Qwen2.5-0.5B
|
||||
|
||||
monkeypatch.setattr(su, "_detect_num_layers", fake_detect)
|
||||
|
||||
# Fake hardware detection
|
||||
monkeypatch.setattr(su, "detect_hardware", lambda: {"device": "cpu", "gpu_name": None, "vram_mb": 0})
|
||||
|
||||
# Fake wallet
|
||||
monkeypatch.setattr(su, "load_or_create_wallet", lambda **kw: (None, None, "fake-wallet"))
|
||||
|
||||
# Fake TorchNodeServer
|
||||
class FakeNode:
|
||||
chat_completion_count = 0
|
||||
def start(self): return 9999
|
||||
def stop(self): pass
|
||||
|
||||
import meshnet_node.startup as su2
|
||||
monkeypatch.setattr(su2, "TorchNodeServer", lambda **kw: FakeNode())
|
||||
|
||||
node = su.run_startup(
|
||||
tracker_url="http://localhost:8080",
|
||||
model_id="Qwen/Qwen2.5-0.5B-Instruct",
|
||||
# shard_start and shard_end intentionally omitted
|
||||
quantization="bfloat16",
|
||||
host="127.0.0.1",
|
||||
)
|
||||
assert calls == ["Qwen/Qwen2.5-0.5B-Instruct"]
|
||||
assert isinstance(node, FakeNode)
|
||||
|
||||
|
||||
def test_legacy_start_subcommand_accepted(monkeypatch):
|
||||
"""meshnet-node start --tracker http://... does not crash on arg parsing."""
|
||||
from meshnet_node.cli import main
|
||||
|
||||
def fake_run_startup(*args, **kwargs):
|
||||
class _FakeNode:
|
||||
chat_completion_count = 0
|
||||
def stop(self): pass
|
||||
return _FakeNode()
|
||||
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"meshnet-node", "start",
|
||||
"--tracker", "http://localhost:8080",
|
||||
"--model", "stub-model",
|
||||
"--port", "0",
|
||||
])
|
||||
|
||||
raised = []
|
||||
|
||||
def fake_sleep(t):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
|
||||
with patch("time.sleep", side_effect=fake_sleep):
|
||||
try:
|
||||
main()
|
||||
except SystemExit as exc:
|
||||
raised.append(exc.code)
|
||||
|
||||
# Exited (either 0 or via KeyboardInterrupt caught in _cmd_start)
|
||||
# The important thing is no unhandled exception from arg parsing
|
||||
Reference in New Issue
Block a user