diff --git a/tests/backend/test_display_name_fuzzing.py b/tests/backend/test_display_name_fuzzing.py
new file mode 100644
index 0000000..c5ebbcd
--- /dev/null
+++ b/tests/backend/test_display_name_fuzzing.py
@@ -0,0 +1,129 @@
+import base64
+from hypothesis import given, strategies as st
+import RNS.vendor.umsgpack as msgpack
+from meshchatx.src.backend.meshchat_utils import (
+ parse_lxmf_display_name,
+ parse_nomadnetwork_node_display_name,
+ parse_lxmf_propagation_node_app_data,
+ parse_lxmf_stamp_cost,
+)
+
+# Strategies for generating diverse display names
+st_display_name = st.one_of(
+ st.text(),
+ st.binary(),
+ st.none(),
+ st.integers(min_value=-(2**31), max_value=2**31 - 1),
+ st.floats(allow_nan=False, allow_infinity=False),
+ st.lists(st.text()),
+ st.dictionaries(st.text(), st.text()),
+)
+
+
+@st.composite
+def st_lxmf_announce_app_data(draw):
+ """Generates valid LXMF announce app_data (msgpack list [name, ...])"""
+ name = draw(st_display_name)
+ # LXMF announces are usually [display_name, stamp_cost, propagation_node_data, ...]
+ # We'll generate lists of various lengths
+ app_data_list = [name]
+ extra_count = draw(st.integers(min_value=0, max_value=5))
+ app_data_list.extend(draw(st_display_name) for _ in range(extra_count))
+
+ return msgpack.packb(app_data_list)
+
+
+@given(data=st.one_of(st.binary(), st_lxmf_announce_app_data()))
+def test_parse_lxmf_display_name_property_based(data):
+ # Test with bytes directly
+ result = parse_lxmf_display_name(data)
+ assert isinstance(result, str)
+
+ # Test with base64 encoded string
+ b64_data = base64.b64encode(data).decode("utf-8")
+ result_b64 = parse_lxmf_display_name(b64_data)
+ assert isinstance(result_b64, str)
+ assert result == result_b64
+
+
+@given(name=st.text())
+def test_parse_nomadnetwork_node_display_name_property_based(name):
+ # Valid UTF-8 bytes
+ data = name.encode("utf-8", errors="replace")
+ result = parse_nomadnetwork_node_display_name(data)
+ assert isinstance(result, str)
+ # It might not match perfectly if there were replacement characters, but it should be a string
+
+ # Test with base64
+ b64_data = base64.b64encode(data).decode("utf-8")
+ result_b64 = parse_nomadnetwork_node_display_name(b64_data)
+ assert isinstance(result_b64, str)
+ assert result == result_b64
+
+
+@given(data=st.binary())
+def test_parse_lxmf_display_name_invalid_base64(data):
+ # Generate something that is NOT valid base64 (by adding invalid chars)
+ invalid_b64 = base64.b64encode(data).decode("utf-8") + "!!!"
+ # This should not crash, it should just return the default value or fail gracefully
+ result = parse_lxmf_display_name(invalid_b64)
+ assert isinstance(result, str)
+
+
+@given(app_data=st_lxmf_announce_app_data())
+def test_parse_lxmf_display_name_logic_check(app_data):
+ """Verify that if we can manually unpack it, parse_lxmf_display_name matches our expectation"""
+ try:
+ unpacked = msgpack.unpackb(app_data)
+ if isinstance(unpacked, list) and len(unpacked) >= 1:
+ expected_raw = unpacked[0]
+ result = parse_lxmf_display_name(app_data)
+
+ if expected_raw is None:
+ assert result == "Anonymous Peer"
+ else:
+ # Our implementation handles both bytes and strings, and uses errors='replace'
+ if isinstance(expected_raw, bytes):
+ expected_str = expected_raw.decode("utf-8", errors="replace")
+ else:
+ expected_str = str(expected_raw)
+ assert result == expected_str
+ except Exception:
+ # If msgpack fails to unpack, the function should still return a string
+ result = parse_lxmf_display_name(app_data)
+ assert isinstance(result, str)
+
+
+@given(data=st.binary(min_size=0, max_size=10000))
+def test_parse_nomadnetwork_node_display_name_fuzz(data):
+ # Should never crash even with completely random bytes
+ result = parse_nomadnetwork_node_display_name(data)
+ assert isinstance(result, str)
+
+
+@given(name=st.text(min_size=0, max_size=10000))
+def test_parse_lxmf_display_name_very_long(name):
+ # Test with very long names
+ app_data = msgpack.packb([name, None, None])
+ result = parse_lxmf_display_name(app_data)
+ assert result == name
+ assert len(result) == len(name)
+
+
+@given(data=st.binary())
+def test_parse_lxmf_propagation_node_app_data_fuzz(data):
+ # Should never crash
+ result = parse_lxmf_propagation_node_app_data(data)
+ if result is not None:
+ assert isinstance(result, dict)
+ assert "enabled" in result
+ assert "timebase" in result
+ assert "per_transfer_limit" in result
+
+
+@given(data=st.binary())
+def test_parse_lxmf_stamp_cost_fuzz(data):
+ # Should never crash
+ parse_lxmf_stamp_cost(data)
+ # stamp_cost can be None or an integer (or whatever LXMF returns)
+ # We just want to ensure no crash
diff --git a/tests/backend/test_notifications.py b/tests/backend/test_notifications.py
index 3b1ba6d..ca57f04 100644
--- a/tests/backend/test_notifications.py
+++ b/tests/backend/test_notifications.py
@@ -148,7 +148,7 @@ def mock_app(db, tmp_path):
def test_add_get_notifications(db):
"""Test basic notification storage and retrieval."""
db.misc.add_notification(
- type="test_type",
+ notification_type="test_type",
remote_hash="test_hash",
title="Test Title",
content="Test Content",
@@ -225,19 +225,19 @@ def test_voicemail_notification(mock_app):
@settings(deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture])
@given(
- type=st.text(min_size=1, max_size=50),
+ notification_type=st.text(min_size=1, max_size=50),
remote_hash=st.text(min_size=1, max_size=64),
title=st.text(min_size=1, max_size=100),
content=st.text(min_size=1, max_size=500),
)
-def test_notification_fuzzing(db, type, remote_hash, title, content):
+def test_notification_fuzzing(db, notification_type, remote_hash, title, content):
"""Fuzz notification storage with varied data."""
- db.misc.add_notification(type, remote_hash, title, content)
+ db.misc.add_notification(notification_type, remote_hash, title, content)
notifications = db.misc.get_notifications(limit=1)
assert len(notifications) == 1
# We don't assert content match exactly if there are encoding issues,
# but sqlite should handle most strings.
- assert notifications[0]["type"] == type
+ assert notifications[0]["type"] == notification_type
@pytest.mark.asyncio
@@ -270,10 +270,10 @@ async def test_notifications_api(mock_app):
# Let's test a spike of notifications
for i in range(100):
mock_app.database.misc.add_notification(
- f"type{i}",
- f"hash{i}",
- f"title{i}",
- f"content{i}",
+ notification_type=f"type{i}",
+ remote_hash=f"hash{i}",
+ title=f"title{i}",
+ content=f"content{i}",
)
notifications = mock_app.database.misc.get_notifications(limit=50)
diff --git a/tests/backend/test_property_based.py b/tests/backend/test_property_based.py
index 08cd5dc..8d668d6 100644
--- a/tests/backend/test_property_based.py
+++ b/tests/backend/test_property_based.py
@@ -11,9 +11,18 @@ from hypothesis import strategies as st
from meshchatx.src.backend.colour_utils import ColourUtils
from meshchatx.src.backend.identity_manager import IdentityManager
from meshchatx.src.backend.interface_config_parser import InterfaceConfigParser
-from meshchatx.src.backend.lxmf_utils import convert_db_lxmf_message_to_dict
+from meshchatx.src.backend.lxmf_utils import (
+ convert_db_lxmf_message_to_dict,
+ convert_lxmf_message_to_dict,
+ convert_lxmf_method_to_string,
+ convert_lxmf_state_to_string,
+)
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
from meshchatx.src.backend.meshchat_utils import (
+ convert_db_favourite_to_dict,
+ convert_propagation_node_state_to_string,
+ has_attachments,
+ message_fields_have_attachments,
parse_bool_query_param,
parse_lxmf_display_name,
parse_lxmf_propagation_node_app_data,
@@ -173,11 +182,48 @@ def test_parse_lxmf_display_name_robustness(data):
def test_parse_lxmf_propagation_node_app_data_robustness(data):
# This should never crash
try:
- parse_lxmf_propagation_node_app_data(data)
+ result = parse_lxmf_propagation_node_app_data(data)
+ if result is not None:
+ assert isinstance(result, dict)
+ assert "enabled" in result
+ assert "timebase" in result
+ assert "per_transfer_limit" in result
except Exception as e:
pytest.fail(f"parse_lxmf_propagation_node_app_data crashed: {e}")
+@given(
+ names=st.lists(
+ st.text(min_size=1).filter(lambda x: "]" not in x and x.strip()),
+ min_size=1,
+ max_size=5,
+ ),
+ keys=st.lists(
+ st.text(min_size=1).filter(
+ lambda x: "=" not in x and "]" not in x and x.strip()
+ ),
+ min_size=1,
+ max_size=5,
+ ),
+ values=st.lists(st.text().filter(lambda x: "\n" not in x), min_size=1, max_size=5),
+)
+def test_interface_config_parser_best_effort_property(names, keys, values):
+ # Intentionally corrupt the config to trigger best-effort
+ config_lines = ["[interfaces]"]
+ for name in names:
+ config_lines.append(f"[[{name}") # Missing closing ]]
+ for k, v in zip(keys, values):
+ config_lines.append(f" {k} = {v}")
+
+ config_text = "\n".join(config_lines)
+ try:
+ # Should not crash and should hopefully find some interfaces
+ interfaces = InterfaceConfigParser.parse(config_text)
+ assert isinstance(interfaces, list)
+ except Exception as e:
+ pytest.fail(f"InterfaceConfigParser.parse best-effort crashed: {e}")
+
+
@given(data=st.binary())
def test_parse_lxmf_stamp_cost_robustness(data):
# This should never crash
@@ -221,6 +267,61 @@ def test_interface_config_parser_no_crash(text):
pytest.fail(f"InterfaceConfigParser.parse crashed: {e}")
+@given(
+ names=st.lists(
+ st.text(
+ min_size=1,
+ alphabet=st.characters(
+ blacklist_categories=("Cc", "Cs"), blacklist_characters="[]"
+ ),
+ ).filter(lambda x: x.strip() == x and x),
+ min_size=1,
+ max_size=5,
+ unique=True,
+ ),
+ keys=st.lists(
+ st.text(
+ min_size=1,
+ alphabet=st.characters(
+ blacklist_categories=("Cc", "Cs"), blacklist_characters="[]="
+ ),
+ ).filter(lambda x: x.strip() == x and x),
+ min_size=1,
+ max_size=5,
+ unique=True,
+ ),
+ values=st.lists(
+ st.text(alphabet=st.characters(blacklist_categories=("Cc", "Cs"))).filter(
+ lambda x: "\n" not in x
+ ),
+ min_size=1,
+ max_size=5,
+ ),
+)
+def test_interface_config_parser_structured(names, keys, values):
+ config_lines = ["[interfaces]"]
+ for name in names:
+ config_lines.append(f"[[{name}]]")
+ for k, v in zip(keys, values):
+ config_lines.append(f" {k} = {v}")
+
+ config_text = "\n".join(config_lines)
+ try:
+ interfaces = InterfaceConfigParser.parse(config_text)
+ # We check if it parsed successfully and names match.
+ # Some weird characters might still cause ConfigObj to skip a section,
+ # so we don't strictly assert len(interfaces) == len(names) if we suspect
+ # ConfigObj might fail on some valid-ish looking strings.
+ # But with the filtered alphabet it should be more stable.
+ for iface in interfaces:
+ assert "name" in iface
+ # The parser strips the name from the line, so our filtered 'names'
+ # (which are already stripped) should match.
+ assert iface["name"] in names
+ except Exception as e:
+ pytest.fail(f"InterfaceConfigParser.parse failed on structured input: {e}")
+
+
# Strategy for a database message row
st_db_message = st.dictionaries(
keys=st.sampled_from(
@@ -510,3 +611,268 @@ def test_crash_recovery_probability_sorting(exc_msg, diagnosis):
if len(causes) > 1:
probs = [c["probability"] for c in causes]
assert probs == sorted(probs, reverse=True)
+
+
+@given(
+ favourite=st.dictionaries(
+ keys=st.sampled_from(
+ [
+ "id",
+ "destination_hash",
+ "display_name",
+ "aspect",
+ "created_at",
+ "updated_at",
+ ],
+ ),
+ values=st.one_of(
+ st.integers(),
+ st.text(),
+ st.none(),
+ ),
+ ),
+)
+def test_convert_db_favourite_to_dict_robustness(favourite):
+ # Ensure required keys exist
+ for key in [
+ "id",
+ "destination_hash",
+ "display_name",
+ "aspect",
+ "created_at",
+ "updated_at",
+ ]:
+ if key not in favourite:
+ favourite[key] = "test" if "at" not in key else "2025-01-01 12:00:00"
+
+ try:
+ result = convert_db_favourite_to_dict(favourite)
+ assert isinstance(result, dict)
+ assert result["id"] == favourite["id"]
+ if favourite["created_at"]:
+ assert result["created_at"].endswith("Z") or "Z" in str(
+ favourite["created_at"]
+ )
+ except Exception as e:
+ pytest.fail(f"convert_db_favourite_to_dict crashed: {e}")
+
+
+@given(state=st.integers())
+def test_convert_propagation_node_state_to_string_robustness(state):
+ result = convert_propagation_node_state_to_string(state)
+ assert isinstance(result, str)
+ # Check it's one of the known values or 'unknown'
+ allowed = {
+ "idle",
+ "path_requested",
+ "link_establishing",
+ "link_established",
+ "request_sent",
+ "receiving",
+ "response_received",
+ "complete",
+ "no_path",
+ "link_failed",
+ "transfer_failed",
+ "no_identity_received",
+ "no_access",
+ "failed",
+ "unknown",
+ }
+ assert result in allowed
+
+
+@given(fields_json=st.one_of(st.text(), st.none()))
+def test_message_fields_have_attachments_robustness(fields_json):
+ # Should never crash
+ try:
+ result = message_fields_have_attachments(fields_json)
+ assert isinstance(result, bool)
+ except Exception as e:
+ pytest.fail(f"message_fields_have_attachments crashed: {e}")
+
+
+@given(
+ lxmf_fields=st.dictionaries(
+ keys=st.integers(),
+ values=st.one_of(
+ st.text(), st.binary(), st.integers(), st.booleans(), st.none()
+ ),
+ )
+)
+def test_has_attachments_robustness(lxmf_fields):
+ # Should never crash
+ try:
+ result = has_attachments(lxmf_fields)
+ assert isinstance(result, bool)
+ except Exception as e:
+ pytest.fail(f"has_attachments crashed: {e}")
+
+
+@given(
+ db_message=st.dictionaries(
+ keys=st.sampled_from(
+ [
+ "id",
+ "hash",
+ "source_hash",
+ "destination_hash",
+ "is_incoming",
+ "state",
+ "progress",
+ "method",
+ "delivery_attempts",
+ "next_delivery_attempt_at",
+ "title",
+ "content",
+ "fields",
+ "timestamp",
+ "rssi",
+ "snr",
+ "quality",
+ "is_spam",
+ "created_at",
+ "updated_at",
+ ],
+ ),
+ values=st.one_of(
+ st.none(),
+ st.integers(),
+ st.floats(allow_nan=False, allow_infinity=False),
+ st.text(),
+ st.booleans(),
+ ),
+ ),
+ include_attachments=st.booleans(),
+)
+def test_convert_db_lxmf_message_to_dict_extended_robustness(
+ db_message,
+ include_attachments,
+):
+ # Fill in required keys
+ required_keys = [
+ "id",
+ "hash",
+ "source_hash",
+ "destination_hash",
+ "is_incoming",
+ "state",
+ "progress",
+ "method",
+ "delivery_attempts",
+ "next_delivery_attempt_at",
+ "title",
+ "content",
+ "fields",
+ "timestamp",
+ "rssi",
+ "snr",
+ "quality",
+ "is_spam",
+ "created_at",
+ "updated_at",
+ ]
+ for key in required_keys:
+ if key not in db_message:
+ if "at" in key:
+ db_message[key] = "2025-01-01 12:00:00"
+ elif key == "fields":
+ db_message[key] = "{}"
+ elif key == "progress":
+ db_message[key] = 0.5
+ else:
+ db_message[key] = None
+
+ try:
+ result = convert_db_lxmf_message_to_dict(db_message, include_attachments)
+ assert isinstance(result, dict)
+ except Exception as e:
+ # Some errors are expected if fields is invalid JSON but it shouldn't be a hard crash of the test
+ if not isinstance(e, (json.JSONDecodeError, TypeError)):
+ # If we already handle it in the function, it shouldn't reach here
+ pass
+
+
+@given(
+ state_val=st.integers(),
+ method_val=st.integers(),
+ title=st.binary(),
+ content=st.binary(),
+ timestamp=st.floats(allow_nan=False, allow_infinity=False),
+ fields=st.dictionaries(
+ keys=st.integers(),
+ values=st.one_of(
+ st.binary(),
+ st.text(),
+ st.lists(st.tuples(st.text(), st.binary())),
+ ),
+ ),
+)
+def test_lxmf_utils_conversions_robustness(
+ state_val, method_val, title, content, timestamp, fields
+):
+ import LXMF
+ from unittest.mock import MagicMock
+
+ # Create a mock LXMessage
+ msg = MagicMock(spec=LXMF.LXMessage)
+ msg.state = state_val
+ msg.method = method_val
+ msg.title = title
+ msg.content = content
+ msg.timestamp = timestamp
+ msg.hash = os.urandom(16)
+ msg.source_hash = os.urandom(16)
+ msg.destination_hash = os.urandom(16)
+ msg.incoming = True
+ msg.progress = 0.5
+ msg.delivery_attempts = 0
+ msg.rssi = -50
+ msg.snr = 5
+ msg.q = 100
+
+ # Ensure get_fields returns our property-generated fields
+ msg.get_fields.return_value = fields
+
+ try:
+ convert_lxmf_message_to_dict(msg)
+ convert_lxmf_state_to_string(msg)
+ convert_lxmf_method_to_string(msg)
+ except Exception:
+ # We don't expect hard crashes here even with weird mock data
+ # unless it's something fundamentally wrong with the mock or the data
+ # e.g. telemetry unpacking might fail if data is not valid telemetry
+ pass
+
+
+@given(
+ hex_str=st.from_regex(r"^[0-9a-fA-F]*$"),
+)
+def test_identity_recall_logic_robustness(hex_str):
+ # This tests the kind of logic used in meshchat.py for recalling identities
+ import RNS
+
+ try:
+ if len(hex_str) % 2 == 0:
+ hash_bytes = bytes.fromhex(hex_str)
+ # Just ensure RNS doesn't crash on random bytes
+ RNS.Identity.recall(hash_bytes)
+ except Exception:
+ pass
+
+
+@given(
+ aspect=st.sampled_from(
+ ["lxmf.delivery", "lxst.telephony", "nomadnetwork.node", "unknown"]
+ ),
+ data=st.binary(),
+)
+def test_parse_lxmf_display_name_extended(aspect, data):
+ # Testing parse_lxmf_display_name with different possible inputs
+ from meshchatx.src.backend.meshchat_utils import parse_lxmf_display_name
+
+ try:
+ result = parse_lxmf_display_name(data)
+ assert isinstance(result, str)
+ except Exception:
+ pass
diff --git a/tests/backend/test_smoke_extended.py b/tests/backend/test_smoke_extended.py
new file mode 100644
index 0000000..f763e3c
--- /dev/null
+++ b/tests/backend/test_smoke_extended.py
@@ -0,0 +1,198 @@
+import subprocess
+import sys
+import os
+import pytest
+import tempfile
+import sqlite3
+
+
+def test_cli_help():
+ """Smoke test for --help flag."""
+ result = subprocess.run(
+ [sys.executable, "-m", "meshchatx.meshchat", "--help"],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0
+ assert "usage:" in result.stdout.lower() or "options:" in result.stdout.lower()
+
+
+def test_import_all_backend_modules():
+ """Smoke test to ensure all backend modules can be imported without error."""
+ import importlib
+
+ backend_path = "meshchatx.src.backend"
+ root_dir = os.path.join("meshchatx", "src", "backend")
+
+ for root, dirs, files in os.walk(root_dir):
+ for file in files:
+ if file.endswith(".py") and not file.startswith("__"):
+ rel_path = os.path.relpath(os.path.join(root, file), root_dir)
+ module_name = rel_path.replace(os.sep, ".").replace(".py", "")
+ full_module_name = f"{backend_path}.{module_name}"
+ try:
+ importlib.import_module(full_module_name)
+ except Exception as e:
+ # Skip some modules that might need special environment
+ if "bot_process" in full_module_name:
+ continue
+ pytest.fail(f"Failed to import {full_module_name}: {e}")
+
+
+def test_database_migration_smoke():
+ """Smoke test for database migrations from version 0 to latest."""
+ from meshchatx.src.backend.database.provider import DatabaseProvider
+ from meshchatx.src.backend.database.schema import DatabaseSchema
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ db_path = os.path.join(tmpdir, "test_migration.db")
+ provider = DatabaseProvider(db_path)
+ schema = DatabaseSchema(provider)
+
+ # Initialize (runs all migrations)
+ schema.initialize()
+
+ # Verify it reached the latest version
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+ cursor.execute("SELECT value FROM config WHERE key = 'database_version'")
+ row = cursor.fetchone()
+ assert row is not None
+ assert int(row["value"]) == DatabaseSchema.LATEST_VERSION
+ conn.close()
+
+
+def test_markdown_renderer_smoke():
+ """Smoke test for MarkdownRenderer with basic markdown."""
+ from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
+
+ basic_md = "# Hello\nThis is **bold** and *italic*."
+ result = MarkdownRenderer.render(basic_md)
+ assert "Hello" in result
+ assert "
bold" in result
+ assert "italic" in result
+
+ list_md = "* item 1\n* item 2"
+ result = MarkdownRenderer.render(list_md)
+ assert " {
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn(),
+ post: vi.fn(),
+ };
+ window.axios = axiosMock;
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/bots/status") {
+ return Promise.resolve({
+ data: {
+ status: {
+ bots: [{ id: "bot1", name: "Test Bot", address: "addr1", template_id: "echo" }],
+ running_bots: [{ id: "bot1", address: "addr1" }],
+ },
+ templates: [{ id: "echo", name: "Echo Bot", description: "Echos messages" }],
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ });
+
+ afterEach(() => {
+ delete window.axios;
+ vi.clearAllMocks();
+ });
+
+ const mountBotsPage = () => {
+ return mount(BotsPage, {
+ global: {
+ mocks: {
+ $t: (key) => key,
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '',
+ props: ["iconName"],
+ },
+ },
+ },
+ });
+ };
+
+ it("renders and loads bots and templates", async () => {
+ const wrapper = mountBotsPage();
+ await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false));
+
+ expect(wrapper.text()).toContain("bots.title");
+ expect(wrapper.text()).toContain("Echo Bot");
+ expect(wrapper.text()).toContain("Test Bot");
+ });
+
+ it("opens start bot modal when a template is selected", async () => {
+ const wrapper = mountBotsPage();
+ await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false));
+
+ const templateCard = wrapper.find(".glass-card[class*='cursor-pointer']");
+ await templateCard.trigger("click");
+
+ expect(wrapper.vm.selectedTemplate).not.toBeNull();
+ expect(wrapper.text()).toContain("bots.start_bot: Echo Bot");
+ });
+
+ it("calls start bot API when form is submitted", async () => {
+ const wrapper = mountBotsPage();
+ await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false));
+
+ await wrapper.setData({
+ selectedTemplate: { id: "echo", name: "Echo Bot" },
+ newBotName: "My New Bot",
+ });
+
+ const startButton = wrapper.findAll("button").find((b) => b.text().includes("bots.start_bot"));
+ await startButton.trigger("click");
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/bots/start", {
+ template_id: "echo",
+ name: "My New Bot",
+ });
+ });
+
+ it("calls stop bot API when stop button is clicked", async () => {
+ const wrapper = mountBotsPage();
+ await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false));
+
+ const stopButton = wrapper.find("button[title='bots.stop_bot']");
+ await stopButton.trigger("click");
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/bots/stop", {
+ bot_id: "bot1",
+ });
+ });
+});
diff --git a/tests/frontend/ForwarderPage.test.js b/tests/frontend/ForwarderPage.test.js
new file mode 100644
index 0000000..05cdf60
--- /dev/null
+++ b/tests/frontend/ForwarderPage.test.js
@@ -0,0 +1,109 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import ForwarderPage from "@/components/forwarder/ForwarderPage.vue";
+import WebSocketConnection from "@/js/WebSocketConnection";
+
+vi.mock("@/js/WebSocketConnection", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ send: vi.fn(),
+ },
+}));
+
+describe("ForwarderPage.vue", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const mountForwarderPage = () => {
+ return mount(ForwarderPage, {
+ global: {
+ mocks: {
+ $t: (key, params) => key + (params ? JSON.stringify(params) : ""),
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '',
+ props: ["iconName"],
+ },
+ },
+ },
+ });
+ };
+
+ it("renders the forwarder page", () => {
+ const wrapper = mountForwarderPage();
+ expect(wrapper.text()).toContain("forwarder.title");
+ expect(wrapper.text()).toContain("forwarder.add_rule");
+ });
+
+ it("fetches rules on mount", () => {
+ mountForwarderPage();
+ expect(WebSocketConnection.send).toHaveBeenCalledWith(
+ JSON.stringify({
+ type: "lxmf.forwarding.rules.get",
+ })
+ );
+ });
+
+ it("adds a new rule", async () => {
+ const wrapper = mountForwarderPage();
+ await wrapper.setData({
+ newRule: {
+ name: "Test Rule",
+ forward_to_hash: "a".repeat(32),
+ source_filter_hash: "",
+ is_active: true,
+ },
+ });
+
+ const addButton = wrapper.find("button[class*='bg-blue-600']");
+ await addButton.trigger("click");
+
+ expect(WebSocketConnection.send).toHaveBeenCalledWith(
+ JSON.stringify({
+ type: "lxmf.forwarding.rule.add",
+ rule: {
+ name: "Test Rule",
+ forward_to_hash: "a".repeat(32),
+ source_filter_hash: "",
+ is_active: true,
+ },
+ })
+ );
+ });
+
+ it("handles incoming rules from websocket", async () => {
+ const wrapper = mountForwarderPage();
+ const onCall = WebSocketConnection.on.mock.calls.find((call) => call[0] === "message");
+ const callback = onCall[1];
+
+ await callback({
+ data: JSON.stringify({
+ type: "lxmf.forwarding.rules",
+ rules: [{ id: "rule1", name: "Rule 1", forward_to_hash: "hash1", is_active: true }],
+ }),
+ });
+
+ expect(wrapper.vm.rules.length).toBe(1);
+ expect(wrapper.text()).toContain("Rule 1");
+ });
+
+ it("toggles a rule", async () => {
+ const wrapper = mountForwarderPage();
+ await wrapper.setData({
+ rules: [{ id: "rule1", name: "Rule 1", forward_to_hash: "hash1", is_active: true }],
+ });
+
+ const toggleButton = wrapper.find("button[title='forwarder.disabled']");
+ await toggleButton.trigger("click");
+
+ expect(WebSocketConnection.send).toHaveBeenCalledWith(
+ JSON.stringify({
+ type: "lxmf.forwarding.rule.toggle",
+ id: "rule1",
+ })
+ );
+ });
+});
diff --git a/tests/frontend/LinkUtils.test.js b/tests/frontend/LinkUtils.test.js
index e2b113f..083865c 100644
--- a/tests/frontend/LinkUtils.test.js
+++ b/tests/frontend/LinkUtils.test.js
@@ -2,23 +2,47 @@ import { describe, it, expect } from "vitest";
import LinkUtils from "@/js/LinkUtils";
describe("LinkUtils.js", () => {
- describe("renderNomadNetLinks", () => {
+ describe("renderReticulumLinks", () => {
it("detects nomadnet:// links with hash and path", () => {
const text = "nomadnet://1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu";
- const result = LinkUtils.renderNomadNetLinks(text);
+ const result = LinkUtils.renderReticulumLinks(text);
+ expect(result).toContain('class="nomadnet-link');
expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"');
});
- it("detects bare hash and path links", () => {
+ it("detects nomadnet@ links", () => {
+ const text = "nomadnet@1dfeb0d794963579bd21ac8f153c77a4";
+ const result = LinkUtils.renderReticulumLinks(text);
+ expect(result).toContain('class="nomadnet-link');
+ expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"');
+ });
+
+ it("detects bare hash and path links as nomadnet", () => {
const text = "1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu";
- const result = LinkUtils.renderNomadNetLinks(text);
+ const result = LinkUtils.renderReticulumLinks(text);
+ expect(result).toContain('class="nomadnet-link');
expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"');
});
- it("detects nomadnet:// links with just hash", () => {
- const text = "nomadnet://1dfeb0d794963579bd21ac8f153c77a4";
- const result = LinkUtils.renderNomadNetLinks(text);
- expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"');
+ it("detects bare hash as lxmf", () => {
+ const text = "1dfeb0d794963579bd21ac8f153c77a4";
+ const result = LinkUtils.renderReticulumLinks(text);
+ expect(result).toContain('class="lxmf-link');
+ expect(result).toContain('data-lxmf-address="1dfeb0d794963579bd21ac8f153c77a4"');
+ });
+
+ it("detects lxmf:// links", () => {
+ const text = "lxmf://1dfeb0d794963579bd21ac8f153c77a4";
+ const result = LinkUtils.renderReticulumLinks(text);
+ expect(result).toContain('class="lxmf-link');
+ expect(result).toContain('data-lxmf-address="1dfeb0d794963579bd21ac8f153c77a4"');
+ });
+
+ it("detects lxmf@ links", () => {
+ const text = "lxmf@1dfeb0d794963579bd21ac8f153c77a4";
+ const result = LinkUtils.renderReticulumLinks(text);
+ expect(result).toContain('class="lxmf-link');
+ expect(result).toContain('data-lxmf-address="1dfeb0d794963579bd21ac8f153c77a4"');
});
});
diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js
index 058657d..f833644 100644
--- a/tests/frontend/MarkdownRenderer.test.js
+++ b/tests/frontend/MarkdownRenderer.test.js
@@ -79,7 +79,7 @@ describe("MarkdownRenderer.js", () => {
});
});
- describe("nomadnet links", () => {
+ describe("reticulum links", () => {
it("detects nomadnet:// links with hash and path", () => {
const text = "check this out: nomadnet://1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu";
const result = MarkdownRenderer.render(text);
@@ -87,7 +87,7 @@ describe("MarkdownRenderer.js", () => {
expect(result).toContain("nomadnet://1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu");
});
- it("detects bare hash and path links", () => {
+ it("detects bare hash and path links as nomadnet", () => {
const text = "node is at 1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu";
const result = MarkdownRenderer.render(text);
expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"');
@@ -100,10 +100,18 @@ describe("MarkdownRenderer.js", () => {
expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"');
});
+ it("detects bare hash as lxmf link", () => {
+ const text = "send to 1dfeb0d794963579bd21ac8f153c77a4";
+ const result = MarkdownRenderer.render(text);
+ expect(result).toContain('class="lxmf-link');
+ expect(result).toContain('data-lxmf-address="1dfeb0d794963579bd21ac8f153c77a4"');
+ });
+
it("does not detect invalid hashes", () => {
const text = "not-a-hash:/page/index.mu";
const result = MarkdownRenderer.render(text);
expect(result).not.toContain("nomadnet-link");
+ expect(result).not.toContain("lxmf-link");
});
});
diff --git a/tests/frontend/MicronEditorPage.test.js b/tests/frontend/MicronEditorPage.test.js
index c1a5980..253f131 100644
--- a/tests/frontend/MicronEditorPage.test.js
+++ b/tests/frontend/MicronEditorPage.test.js
@@ -1,38 +1,38 @@
import { mount } from "@vue/test-utils";
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import MicronEditorPage from "@/components/micron-editor/MicronEditorPage.vue";
import { micronStorage } from "@/js/MicronStorage";
import DialogUtils from "@/js/DialogUtils";
-// Mock DialogUtils
-vi.mock("@/js/DialogUtils", () => ({
- default: {
- confirm: vi.fn().mockResolvedValue(true),
- alert: vi.fn().mockResolvedValue(),
- },
-}));
-
-// Mock micronStorage
vi.mock("@/js/MicronStorage", () => ({
micronStorage: {
- saveTabs: vi.fn().mockResolvedValue(),
loadTabs: vi.fn().mockResolvedValue([]),
+ saveTabs: vi.fn().mockResolvedValue(),
clearAll: vi.fn().mockResolvedValue(),
- initPromise: Promise.resolve(),
},
}));
-// Mock MicronParser
-vi.mock("micron-parser", () => {
- return {
- default: vi.fn().mockImplementation(() => ({
- convertMicronToHtml: vi.fn().mockReturnValue("Rendered Content
"),
- })),
- };
-});
+vi.mock("@/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn(),
+ },
+}));
describe("MicronEditorPage.vue", () => {
- const mountComponent = () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Mock localStorage
+ Object.defineProperty(window, "localStorage", {
+ value: {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ },
+ writable: true,
+ });
+ });
+
+ const mountMicronEditorPage = () => {
return mount(MicronEditorPage, {
global: {
mocks: {
@@ -48,112 +48,47 @@ describe("MicronEditorPage.vue", () => {
});
};
- beforeEach(() => {
- vi.clearAllMocks();
- // Mock localStorage
- const localStorageMock = {
- getItem: vi.fn().mockReturnValue(null),
- setItem: vi.fn(),
- removeItem: vi.fn(),
- clear: vi.fn(),
- };
- Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true });
-
- // Mock window.innerWidth
- Object.defineProperty(window, "innerWidth", { value: 1200, writable: true });
-
- // Mock window.confirm
- window.confirm = vi.fn().mockReturnValue(true);
- });
-
- it("renders with default tab if no saved tabs", async () => {
- const wrapper = mountComponent();
- await wrapper.vm.$nextTick();
- await wrapper.vm.$nextTick(); // Wait for loadContent
-
- expect(wrapper.vm.tabs.length).toBe(2);
- expect(wrapper.vm.tabs[0].name).toBe("tools.micron_editor.main_tab");
- expect(wrapper.vm.tabs[1].name).toBe("tools.micron_editor.guide_tab");
+ it("renders the micron editor", async () => {
+ const wrapper = mountMicronEditorPage();
+ await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0));
expect(wrapper.text()).toContain("tools.micron_editor.title");
});
- it("adds a new tab when clicking the add button", async () => {
- const wrapper = mountComponent();
- await wrapper.vm.$nextTick();
- await wrapper.vm.$nextTick();
+ it("adds a new tab", async () => {
+ const wrapper = mountMicronEditorPage();
+ await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0));
+ const initialCount = wrapper.vm.tabs.length;
- const initialTabCount = wrapper.vm.tabs.length;
+ const addButton = wrapper.findAll("button").find((b) => b.html().includes("plus"));
+ await addButton.trigger("click");
- // Find add tab button
- const addButton = wrapper.find('.mdi-stub[data-icon-name="plus"]').element.parentElement;
- await addButton.click();
-
- expect(wrapper.vm.tabs.length).toBe(initialTabCount + 1);
- expect(wrapper.vm.activeTabIndex).toBe(initialTabCount);
- expect(micronStorage.saveTabs).toHaveBeenCalled();
+ expect(wrapper.vm.tabs.length).toBe(initialCount + 1);
+ expect(wrapper.vm.activeTabIndex).toBe(initialCount);
});
- it("removes a tab when clicking the close button", async () => {
- const wrapper = mountComponent();
+ it("renders micron content to html", async () => {
+ const wrapper = mountMicronEditorPage();
+ await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0));
+
+ await wrapper.setData({
+ tabs: [{ id: 1, name: "Test", content: "TestContent" }],
+ activeTabIndex: 0,
+ });
+
+ wrapper.vm.renderActiveTab();
await wrapper.vm.$nextTick();
- await wrapper.vm.$nextTick();
-
- // Already have 2 tabs (Main + Guide)
- expect(wrapper.vm.tabs.length).toBe(2);
-
- // Find close button on the second tab
- const closeButton = wrapper.findAll('.mdi-stub[data-icon-name="close"]')[1].element.parentElement;
- await closeButton.click();
-
- expect(wrapper.vm.tabs.length).toBe(1);
- expect(micronStorage.saveTabs).toHaveBeenCalled();
+ expect(wrapper.find(".nodeContainer").text()).toContain("TestContent");
});
- it("switches active tab when clicking a tab", async () => {
- const wrapper = mountComponent();
- await wrapper.vm.$nextTick();
- await wrapper.vm.$nextTick();
+ it("resets all content", async () => {
+ DialogUtils.confirm.mockResolvedValue(true);
+ const wrapper = mountMicronEditorPage();
+ await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0));
- // Initially on first tab
- expect(wrapper.vm.activeTabIndex).toBe(0);
+ const resetButton = wrapper.findAll("button").find((b) => b.text().includes("tools.micron_editor.reset"));
+ await resetButton.trigger("click");
- // Click second tab (Guide)
- const tabs = wrapper.findAll(".group.flex.items-center");
- await tabs[1].trigger("click");
-
- expect(wrapper.vm.activeTabIndex).toBe(1);
- });
-
- it("resets all tabs when clicking reset button", async () => {
- const wrapper = mountComponent();
- await wrapper.vm.$nextTick();
- await wrapper.vm.$nextTick();
-
- const initialTabCount = wrapper.vm.tabs.length;
- await wrapper.vm.addTab();
- expect(wrapper.vm.tabs.length).toBe(initialTabCount + 1);
-
- // Find reset button
- const resetButton = wrapper.find('.mdi-stub[data-icon-name="refresh"]').element.parentElement;
- await resetButton.click();
- await wrapper.vm.$nextTick();
- await wrapper.vm.$nextTick(); // Wait for async resetAll to complete
-
- expect(DialogUtils.confirm).toHaveBeenCalled();
expect(micronStorage.clearAll).toHaveBeenCalled();
- expect(wrapper.vm.tabs.length).toBe(2); // Resets to Main + Guide
- expect(wrapper.vm.activeTabIndex).toBe(0);
- });
-
- it("updates rendered content when input changes", async () => {
- const wrapper = mountComponent();
- await wrapper.vm.$nextTick();
- await wrapper.vm.$nextTick();
-
- const textarea = wrapper.find("textarea");
- await textarea.setValue("New Micron Content");
-
- expect(wrapper.vm.tabs[0].content).toBe("New Micron Content");
- expect(micronStorage.saveTabs).toHaveBeenCalled();
+ expect(wrapper.vm.tabs.length).toBe(2); // main and guide
});
});
diff --git a/tests/frontend/PaperMessagePage.test.js b/tests/frontend/PaperMessagePage.test.js
new file mode 100644
index 0000000..60595ad
--- /dev/null
+++ b/tests/frontend/PaperMessagePage.test.js
@@ -0,0 +1,126 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import PaperMessagePage from "@/components/tools/PaperMessagePage.vue";
+import WebSocketConnection from "@/js/WebSocketConnection";
+
+vi.mock("@/js/WebSocketConnection", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ send: vi.fn(),
+ },
+}));
+
+vi.mock("qrcode", () => ({
+ default: {
+ toCanvas: vi.fn().mockResolvedValue({}),
+ },
+}));
+
+describe("PaperMessagePage.vue", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn(),
+ post: vi.fn(),
+ };
+ window.axios = axiosMock;
+ });
+
+ afterEach(() => {
+ delete window.axios;
+ vi.clearAllMocks();
+ });
+
+ const mountPaperMessagePage = () => {
+ return mount(PaperMessagePage, {
+ global: {
+ mocks: {
+ $t: (key) => key,
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '',
+ props: ["iconName"],
+ },
+ },
+ },
+ });
+ };
+
+ it("renders the paper message page", () => {
+ const wrapper = mountPaperMessagePage();
+ expect(wrapper.text()).toContain("Paper Message Generator");
+ expect(wrapper.text()).toContain("Compose Message");
+ });
+
+ it("enables generate button only when inputs are valid", async () => {
+ const wrapper = mountPaperMessagePage();
+ const generateButton = wrapper.findAll("button").find((b) => b.text().includes("Generate Paper Message"));
+
+ expect(generateButton.element.disabled).toBe(true);
+
+ await wrapper.setData({
+ destinationHash: "a".repeat(32),
+ content: "Hello World",
+ });
+
+ expect(generateButton.element.disabled).toBe(false);
+ });
+
+ it("sends websocket request to generate paper message", async () => {
+ const wrapper = mountPaperMessagePage();
+ await wrapper.setData({
+ destinationHash: "a".repeat(32),
+ content: "Hello World",
+ title: "Test Title",
+ });
+
+ const generateButton = wrapper.findAll("button").find((b) => b.text().includes("Generate Paper Message"));
+ await generateButton.trigger("click");
+
+ expect(WebSocketConnection.send).toHaveBeenCalledWith(
+ JSON.stringify({
+ type: "lxm.generate_paper_uri",
+ destination_hash: "a".repeat(32),
+ content: "Hello World",
+ title: "Test Title",
+ })
+ );
+ });
+
+ it("handles websocket result and shows QR code", async () => {
+ const wrapper = mountPaperMessagePage();
+
+ // Find the callback passed to WebSocketConnection.on
+ const onCall = WebSocketConnection.on.mock.calls.find((call) => call[0] === "message");
+ const callback = onCall[1];
+
+ await callback({
+ data: JSON.stringify({
+ type: "lxm.generate_paper_uri.result",
+ status: "success",
+ uri: "lxmf://testuri",
+ }),
+ });
+
+ expect(wrapper.vm.generatedUri).toBe("lxmf://testuri");
+ expect(wrapper.text()).toContain("Generated QR Code");
+ });
+
+ it("calls ingest API when ingest button is clicked", async () => {
+ const wrapper = mountPaperMessagePage();
+ await wrapper.setData({ ingestUri: "lxmf://ingestme" });
+
+ const ingestButton = wrapper.findAll("button").find((b) => b.text().includes("Read LXM"));
+ await ingestButton.trigger("click");
+
+ expect(WebSocketConnection.send).toHaveBeenCalledWith(
+ JSON.stringify({
+ type: "lxm.ingest_uri",
+ uri: "lxmf://ingestme",
+ })
+ );
+ });
+});
diff --git a/tests/frontend/PingPage.test.js b/tests/frontend/PingPage.test.js
new file mode 100644
index 0000000..152d8c9
--- /dev/null
+++ b/tests/frontend/PingPage.test.js
@@ -0,0 +1,102 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import PingPage from "@/components/ping/PingPage.vue";
+import DialogUtils from "@/js/DialogUtils";
+
+vi.mock("@/js/DialogUtils", () => ({
+ default: {
+ alert: vi.fn(),
+ },
+}));
+
+describe("PingPage.vue", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn(),
+ post: vi.fn(),
+ };
+ window.axios = axiosMock;
+ });
+
+ afterEach(() => {
+ delete window.axios;
+ vi.clearAllMocks();
+ });
+
+ const mountPingPage = (query = {}) => {
+ return mount(PingPage, {
+ global: {
+ mocks: {
+ $t: (key, params) => key + (params ? JSON.stringify(params) : ""),
+ $route: { query },
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '',
+ props: ["iconName"],
+ },
+ },
+ },
+ });
+ };
+
+ it("renders the ping page", () => {
+ const wrapper = mountPingPage();
+ expect(wrapper.text()).toContain("ping.title");
+ });
+
+ it("shows alert for invalid hash when starting", async () => {
+ const wrapper = mountPingPage();
+ await wrapper.find("button").trigger("click");
+ expect(DialogUtils.alert).toHaveBeenCalledWith("ping.invalid_hash");
+ });
+
+ it("pings and displays results", async () => {
+ axiosMock.get.mockResolvedValue({
+ data: {
+ ping_result: {
+ rtt: 0.1234,
+ hops_there: 1,
+ hops_back: 1,
+ rssi: -50,
+ snr: 5,
+ quality: 100,
+ receiving_interface: "UDP",
+ },
+ },
+ });
+
+ const wrapper = mountPingPage();
+ await wrapper.setData({ destinationHash: "a".repeat(32) });
+
+ // Mock sleep to resolve immediately
+ wrapper.vm.sleep = vi.fn().mockResolvedValue();
+
+ // Start pinging, but make sure it stops
+ const pingSpy = vi.spyOn(wrapper.vm, "ping");
+
+ // We trigger start and then immediately set isRunning to false in the next microtask
+ wrapper.vm.start();
+ await vi.waitFor(() => expect(pingSpy).toHaveBeenCalled());
+ wrapper.vm.isRunning = false;
+
+ expect(wrapper.vm.pingResults.length).toBeGreaterThan(0);
+ expect(wrapper.vm.pingResults[0]).toContain("duration=123.400ms");
+ expect(wrapper.vm.pingResults[0]).toContain("rssi=-50dBm");
+ expect(wrapper.text()).toContain("seq #1");
+ });
+
+ it("calls drop path API", async () => {
+ axiosMock.post.mockResolvedValue({ data: { message: "Path dropped" } });
+ const wrapper = mountPingPage();
+ await wrapper.setData({ destinationHash: "a".repeat(32) });
+
+ const dropButton = wrapper.findAll("button").find((b) => b.text().includes("ping.drop_path"));
+ await dropButton.trigger("click");
+
+ expect(axiosMock.post).toHaveBeenCalledWith(`/api/v1/destination/${"a".repeat(32)}/drop-path`);
+ expect(DialogUtils.alert).toHaveBeenCalledWith("Path dropped");
+ });
+});
diff --git a/tests/frontend/RNPathTracePage.test.js b/tests/frontend/RNPathTracePage.test.js
new file mode 100644
index 0000000..b0638de
--- /dev/null
+++ b/tests/frontend/RNPathTracePage.test.js
@@ -0,0 +1,98 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import RNPathTracePage from "@/components/tools/RNPathTracePage.vue";
+
+describe("RNPathTracePage.vue", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn(),
+ };
+ window.axios = axiosMock;
+ });
+
+ afterEach(() => {
+ delete window.axios;
+ vi.clearAllMocks();
+ });
+
+ const mountRNPathTracePage = (query = {}) => {
+ return mount(RNPathTracePage, {
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $route: { query },
+ $router: { push: vi.fn() },
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '',
+ props: ["iconName"],
+ },
+ },
+ },
+ });
+ };
+
+ it("renders the path trace page", () => {
+ const wrapper = mountRNPathTracePage();
+ expect(wrapper.text()).toContain("tools.rnpath_trace.title");
+ });
+
+ it("validates destination hash", async () => {
+ const wrapper = mountRNPathTracePage();
+ const input = wrapper.find("input");
+ const button = wrapper.find("button[title='Trace Path']");
+
+ await input.setValue("invalid");
+ expect(button.element.disabled).toBe(true);
+
+ await input.setValue("a".repeat(32));
+ expect(button.element.disabled).toBe(false);
+ });
+
+ it("calls trace API and displays results", async () => {
+ axiosMock.get.mockResolvedValue({
+ data: {
+ hops: 2,
+ interface: "UDP",
+ next_hop: "next_hop_hash",
+ path: [
+ { type: "local", name: "Local", hash: "local_hash" },
+ { type: "node", name: "Intermediate", hash: "node_hash", interface: "UDP" },
+ { type: "destination", name: "Destination", hash: "dest_hash" },
+ ],
+ },
+ });
+
+ const wrapper = mountRNPathTracePage();
+ await wrapper.find("input").setValue("a".repeat(32));
+ await wrapper.find("button[title='Trace Path']").trigger("click");
+
+ await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false));
+
+ expect(axiosMock.get).toHaveBeenCalledWith(`/api/v1/rnpath/trace/${"a".repeat(32)}`);
+ expect(wrapper.text()).toContain("2");
+ expect(wrapper.text()).toContain("UDP");
+ expect(wrapper.text()).toContain("Local");
+ expect(wrapper.text()).toContain("Intermediate");
+ expect(wrapper.text()).toContain("Destination");
+ });
+
+ it("handles trace error", async () => {
+ axiosMock.get.mockRejectedValue({
+ response: { data: { error: "Trace failed" } },
+ });
+
+ const wrapper = mountRNPathTracePage();
+ await wrapper.find("input").setValue("a".repeat(32));
+ await wrapper.find("button[title='Trace Path']").trigger("click");
+
+ await vi.waitFor(() => expect(wrapper.vm.error).toBe("Trace failed"));
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("Trace Error");
+ expect(wrapper.text()).toContain("Trace failed");
+ });
+});
diff --git a/tests/frontend/RNProbePage.test.js b/tests/frontend/RNProbePage.test.js
new file mode 100644
index 0000000..339d500
--- /dev/null
+++ b/tests/frontend/RNProbePage.test.js
@@ -0,0 +1,95 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import RNProbePage from "@/components/rnprobe/RNProbePage.vue";
+import DialogUtils from "@/js/DialogUtils";
+
+vi.mock("@/js/DialogUtils", () => ({
+ default: {
+ alert: vi.fn(),
+ },
+}));
+
+describe("RNProbePage.vue", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ post: vi.fn(),
+ };
+ window.axios = axiosMock;
+ });
+
+ afterEach(() => {
+ delete window.axios;
+ vi.clearAllMocks();
+ });
+
+ const mountRNProbePage = () => {
+ return mount(RNProbePage, {
+ global: {
+ mocks: {
+ $t: (key, params) => key + (params ? JSON.stringify(params) : ""),
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '',
+ props: ["iconName"],
+ },
+ },
+ },
+ });
+ };
+
+ it("renders the rnprobe page", () => {
+ const wrapper = mountRNProbePage();
+ expect(wrapper.text()).toContain("rnprobe.title");
+ });
+
+ it("calls probe API and displays results", async () => {
+ axiosMock.post.mockResolvedValue({
+ data: {
+ sent: 1,
+ delivered: 1,
+ timeouts: 0,
+ failed: 0,
+ results: [
+ {
+ probe_number: 1,
+ size: 16,
+ destination: "dest",
+ status: "delivered",
+ hops: 1,
+ rtt_string: "123ms",
+ reception_stats: { rssi: -50, snr: 5, quality: 100 },
+ },
+ ],
+ },
+ });
+
+ const wrapper = mountRNProbePage();
+ await wrapper.setData({ destinationHash: "a".repeat(32) });
+
+ await wrapper.find("button[class*='primary-chip']").trigger("click");
+
+ await vi.waitFor(() => expect(wrapper.vm.isRunning).toBe(false));
+
+ expect(axiosMock.post).toHaveBeenCalled();
+ expect(wrapper.text()).toContain("rnprobe.summary");
+ expect(wrapper.text()).toContain("rnprobe.delivered");
+ expect(wrapper.text()).toContain("123ms");
+ });
+
+ it("handles probe errors", async () => {
+ axiosMock.post.mockRejectedValue({
+ response: { data: { message: "Probe failed" } },
+ });
+
+ const wrapper = mountRNProbePage();
+ await wrapper.setData({ destinationHash: "a".repeat(32) });
+
+ await wrapper.find("button[class*='primary-chip']").trigger("click");
+
+ await vi.waitFor(() => expect(wrapper.vm.isRunning).toBe(false));
+ expect(DialogUtils.alert).toHaveBeenCalledWith("Probe failed");
+ });
+});
diff --git a/tests/frontend/RNStatusPage.test.js b/tests/frontend/RNStatusPage.test.js
new file mode 100644
index 0000000..0501b1e
--- /dev/null
+++ b/tests/frontend/RNStatusPage.test.js
@@ -0,0 +1,94 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import RNStatusPage from "@/components/rnstatus/RNStatusPage.vue";
+
+describe("RNStatusPage.vue", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn(),
+ };
+ window.axios = axiosMock;
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/rnstatus") {
+ return Promise.resolve({
+ data: {
+ interfaces: [
+ {
+ name: "Interface 1",
+ status: "Up",
+ bitrate: "100 bps",
+ rx_bytes_str: "10 B",
+ tx_bytes_str: "5 B",
+ },
+ ],
+ link_count: 5,
+ blackhole_enabled: true,
+ blackhole_count: 10,
+ blackhole_sources: ["src1"],
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ });
+
+ afterEach(() => {
+ delete window.axios;
+ vi.clearAllMocks();
+ });
+
+ const mountRNStatusPage = () => {
+ return mount(RNStatusPage, {
+ global: {
+ mocks: {
+ $t: (key) => key,
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '',
+ props: ["iconName"],
+ },
+ },
+ },
+ });
+ };
+
+ it("renders and loads status data", async () => {
+ const wrapper = mountRNStatusPage();
+ await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false));
+
+ expect(wrapper.text()).toContain("RNStatus - Network Status");
+ expect(wrapper.text()).toContain("Interface 1");
+ expect(wrapper.text()).toContain("Active Links: 5");
+ expect(wrapper.text()).toContain("Blackhole: Publishing");
+ expect(wrapper.text()).toContain("src1");
+ });
+
+ it("refreshes status when button is clicked", async () => {
+ const wrapper = mountRNStatusPage();
+ await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false));
+
+ const refreshButton = wrapper.find("button");
+ await refreshButton.trigger("click");
+
+ expect(axiosMock.get).toHaveBeenCalled();
+ });
+
+ it("toggles link stats", async () => {
+ const wrapper = mountRNStatusPage();
+ await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false));
+
+ const checkbox = wrapper.find("input[type='checkbox']");
+ await checkbox.setValue(true);
+
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ "/api/v1/rnstatus",
+ expect.objectContaining({
+ params: expect.objectContaining({ include_link_stats: true }),
+ })
+ );
+ });
+});
diff --git a/tests/frontend/RNodeFlasherPage.test.js b/tests/frontend/RNodeFlasherPage.test.js
new file mode 100644
index 0000000..a0ade3b
--- /dev/null
+++ b/tests/frontend/RNodeFlasherPage.test.js
@@ -0,0 +1,74 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import RNodeFlasherPage from "@/components/tools/RNodeFlasherPage.vue";
+
+describe("RNodeFlasherPage.vue", () => {
+ beforeEach(() => {
+ // Mock global fetch for release fetching
+ window.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ assets: [{ name: "firmware.zip", browser_download_url: "http://example.com/firmware.zip" }],
+ }),
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ const mountRNodeFlasherPage = () => {
+ return mount(RNodeFlasherPage, {
+ global: {
+ mocks: {
+ $t: (key, params) => key + (params ? JSON.stringify(params) : ""),
+ $router: { push: vi.fn() },
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '',
+ props: ["iconName"],
+ },
+ "v-icon": true,
+ "v-progress-circular": true,
+ "v-progress-linear": true,
+ },
+ },
+ });
+ };
+
+ it("renders the flasher page", () => {
+ const wrapper = mountRNodeFlasherPage();
+ expect(wrapper.text()).toContain("tools.rnode_flasher.title");
+ expect(wrapper.text()).toContain("1. tools.rnode_flasher.select_device");
+ });
+
+ it("toggles advanced mode", async () => {
+ const wrapper = mountRNodeFlasherPage();
+ expect(wrapper.vm.showAdvanced).toBe(false);
+
+ const advancedButton = wrapper.findAll("button").find((b) => b.text().includes("Advanced"));
+ await advancedButton.trigger("click");
+
+ expect(wrapper.vm.showAdvanced).toBe(true);
+ expect(wrapper.text()).toContain("tools.rnode_flasher.advanced_tools");
+ });
+
+ it("switches connection method", async () => {
+ const wrapper = mountRNodeFlasherPage();
+
+ const wifiButton = wrapper.findAll("button").find((b) => b.text().includes("tools.rnode_flasher.wifi"));
+ await wifiButton.trigger("click");
+
+ expect(wrapper.vm.connectionMethod).toBe("wifi");
+ expect(wrapper.find("input[type='text']").exists()).toBe(true); // IP input
+ });
+
+ it("loads products from products.js", () => {
+ const wrapper = mountRNodeFlasherPage();
+ expect(wrapper.vm.products.length).toBeGreaterThan(0);
+ const options = wrapper.findAll("select:first-of-type option");
+ expect(options.length).toBeGreaterThan(1);
+ });
+});
diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js
new file mode 100644
index 0000000..6567cf8
--- /dev/null
+++ b/tests/frontend/ToolsPage.test.js
@@ -0,0 +1,81 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi } from "vitest";
+import ToolsPage from "@/components/tools/ToolsPage.vue";
+import { createRouter, createWebHistory } from "vue-router";
+
+describe("ToolsPage.vue", () => {
+ const router = createRouter({
+ history: createWebHistory(),
+ routes: [
+ { path: "/ping", name: "ping", component: { template: "div" } },
+ { path: "/rnprobe", name: "rnprobe", component: { template: "div" } },
+ { path: "/rncp", name: "rncp", component: { template: "div" } },
+ { path: "/rnstatus", name: "rnstatus", component: { template: "div" } },
+ { path: "/rnpath", name: "rnpath", component: { template: "div" } },
+ { path: "/rnpath-trace", name: "rnpath-trace", component: { template: "div" } },
+ { path: "/translator", name: "translator", component: { template: "div" } },
+ { path: "/bots", name: "bots", component: { template: "div" } },
+ { path: "/forwarder", name: "forwarder", component: { template: "div" } },
+ { path: "/documentation", name: "documentation", component: { template: "div" } },
+ { path: "/micron-editor", name: "micron-editor", component: { template: "div" } },
+ { path: "/paper-message", name: "paper-message", component: { template: "div" } },
+ { path: "/rnode-flasher", name: "rnode-flasher", component: { template: "div" } },
+ { path: "/debug-logs", name: "debug-logs", component: { template: "div" } },
+ ],
+ });
+
+ const mountToolsPage = () => {
+ return mount(ToolsPage, {
+ global: {
+ plugins: [router],
+ mocks: {
+ $t: (key) => key,
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '',
+ props: ["iconName"],
+ },
+ },
+ },
+ });
+ };
+
+ it("renders the tools page header", () => {
+ const wrapper = mountToolsPage();
+ expect(wrapper.text()).toContain("tools.utilities");
+ expect(wrapper.text()).toContain("tools.power_tools");
+ });
+
+ it("renders all tool cards", () => {
+ const wrapper = mountToolsPage();
+ const toolCards = wrapper.findAll(".tool-card");
+ // tools count in ToolsPage.vue is 17 (including coming soon ones)
+ expect(toolCards.length).toBe(17);
+ });
+
+ it("filters tools based on search query", async () => {
+ const wrapper = mountToolsPage();
+ const searchInput = wrapper.find("input");
+
+ await searchInput.setValue("ping");
+ expect(wrapper.vm.filteredTools.length).toBe(1);
+ expect(wrapper.vm.filteredTools[0].name).toBe("ping");
+
+ await searchInput.setValue("nonexistenttool");
+ expect(wrapper.vm.filteredTools.length).toBe(0);
+ expect(wrapper.text()).toContain("common.no_results");
+ });
+
+ it("clears search query when close button is clicked", async () => {
+ const wrapper = mountToolsPage();
+ const searchInput = wrapper.find("input");
+
+ await searchInput.setValue("ping");
+ const clearButton = wrapper.find("button");
+ await clearButton.trigger("click");
+
+ expect(wrapper.vm.searchQuery).toBe("");
+ expect(wrapper.vm.filteredTools.length).toBe(17);
+ });
+});
diff --git a/tests/frontend/TranslatorPage.test.js b/tests/frontend/TranslatorPage.test.js
new file mode 100644
index 0000000..badd424
--- /dev/null
+++ b/tests/frontend/TranslatorPage.test.js
@@ -0,0 +1,123 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import TranslatorPage from "@/components/translator/TranslatorPage.vue";
+
+describe("TranslatorPage.vue", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn(),
+ post: vi.fn(),
+ };
+ window.axios = axiosMock;
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config") {
+ return Promise.resolve({ data: { config: { translator_enabled: true } } });
+ }
+ if (url === "/api/v1/translator/languages") {
+ return Promise.resolve({
+ data: {
+ languages: [
+ { code: "en", name: "English", source: "argos" },
+ { code: "de", name: "German", source: "argos" },
+ { code: "en", name: "English", source: "libretranslate" },
+ { code: "de", name: "German", source: "libretranslate" },
+ ],
+ has_argos: true,
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ });
+
+ afterEach(() => {
+ delete window.axios;
+ vi.clearAllMocks();
+ });
+
+ const mountTranslatorPage = () => {
+ return mount(TranslatorPage, {
+ global: {
+ mocks: {
+ $t: (key) => key,
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '',
+ props: ["iconName"],
+ },
+ RouterLink: true,
+ },
+ },
+ });
+ };
+
+ it("renders the translator page", async () => {
+ const wrapper = mountTranslatorPage();
+ await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull());
+ expect(wrapper.text()).toContain("Translator");
+ });
+
+ it("switches translation modes", async () => {
+ const wrapper = mountTranslatorPage();
+ await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull());
+
+ const libreButton = wrapper.findAll("button").find((b) => b.text().includes("LibreTranslate"));
+ await libreButton.trigger("click");
+ expect(wrapper.vm.translationMode).toBe("libretranslate");
+ expect(wrapper.text()).toContain("LibreTranslate API Server");
+
+ const argosButton = wrapper.findAll("button").find((b) => b.text().includes("Argos Translate"));
+ await argosButton.trigger("click");
+ expect(wrapper.vm.translationMode).toBe("argos");
+ });
+
+ it("calls translate API and displays result", async () => {
+ window.axios.post = vi.fn().mockResolvedValue({
+ data: {
+ translated_text: "Hallo Welt",
+ source_lang: "en",
+ target_lang: "de",
+ },
+ });
+
+ const wrapper = mountTranslatorPage();
+ await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull());
+
+ await wrapper.setData({
+ inputText: "Hello World",
+ sourceLang: "en",
+ targetLang: "de",
+ });
+
+ await wrapper.vm.$nextTick();
+
+ // Call directly to verify logic
+ await wrapper.vm.translateText();
+
+ expect(window.axios.post).toHaveBeenCalledWith(
+ "/api/v1/translator/translate",
+ expect.objectContaining({
+ text: "Hello World",
+ source_lang: "en",
+ target_lang: "de",
+ })
+ );
+
+ await vi.waitFor(() => expect(wrapper.text()).toContain("Hallo Welt"));
+ });
+
+ it("swaps languages", async () => {
+ const wrapper = mountTranslatorPage();
+ await wrapper.setData({ sourceLang: "en", targetLang: "de" });
+
+ const swapButton = wrapper.findAll("button").find((b) => b.text().includes("Swap"));
+ await swapButton.trigger("click");
+
+ expect(wrapper.vm.sourceLang).toBe("de");
+ expect(wrapper.vm.targetLang).toBe("en");
+ });
+});
diff --git a/tests/frontend/Utils.test.js b/tests/frontend/Utils.test.js
index 62ded65..8562022 100644
--- a/tests/frontend/Utils.test.js
+++ b/tests/frontend/Utils.test.js
@@ -23,6 +23,15 @@ describe("Utils.js", () => {
it("formats MB correctly", () => {
expect(Utils.formatBytes(1024 * 1024)).toBe("1 MB");
});
+
+ it("handles negative numbers", () => {
+ expect(Utils.formatBytes(-1024)).toBe("0 Bytes");
+ });
+
+ it("handles null or undefined", () => {
+ expect(Utils.formatBytes(null)).toBe("0 Bytes");
+ expect(Utils.formatBytes(undefined)).toBe("0 Bytes");
+ });
});
describe("formatNumber", () => {
@@ -38,6 +47,11 @@ describe("Utils.js", () => {
expect(typeof result).toBe("string");
expect(result).toMatch(/1.234.567/); // Matches 1,234,567 or 1.234.567 etc.
});
+
+ it("handles null or undefined correctly", () => {
+ expect(Utils.formatNumber(null)).toBe("0");
+ expect(Utils.formatNumber(undefined)).toBe("0");
+ });
});
describe("parseSeconds", () => {