Add property-based tests for display name parsing and fuzzing

- Introduced new test suite for `parse_lxmf_display_name`, `parse_nomadnetwork_node_display_name`, and related functions using Hypothesis for property-based testing.
- Added various strategies to generate diverse input data, including edge cases for invalid and long names.
- Implemented tests to ensure robustness against invalid base64 inputs and to verify expected behavior with valid and corrupted data.
- Created smoke tests for frontend components including BotsPage, ForwarderPage, and others to ensure proper rendering and functionality.
This commit is contained in:
Sudo-Ivan
2026-01-16 08:51:48 -06:00
parent 4106e28ff1
commit ee9ed05338
18 changed files with 1813 additions and 136 deletions
+129
View File
@@ -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
+9 -9
View File
@@ -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)
+368 -2
View File
@@ -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
+198
View File
@@ -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 "<h1" in result
assert "<strong>bold</strong>" in result
assert "<em>italic</em>" in result
list_md = "* item 1\n* item 2"
result = MarkdownRenderer.render(list_md)
assert "<ul" in result
assert "item 1" in result
def test_config_manager_smoke():
"""Smoke test for ConfigManager basic operations."""
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.config_manager import ConfigManager
with tempfile.TemporaryDirectory() as tmpdir:
db_path = os.path.join(tmpdir, "test_config.db")
# Use the high-level Database class which has the expected structure
db = Database(db_path)
db.initialize()
config = ConfigManager(db)
# Test default value
assert config.display_name.get() == "Anonymous Peer"
# Test set and get
config.display_name.set("New Name")
assert config.display_name.get() == "New Name"
# Test boolean
assert config.auto_announce_enabled.get() is False
config.auto_announce_enabled.set(True)
assert config.auto_announce_enabled.get() is True
def test_telephone_manager_smoke():
"""Smoke test for TelephoneManager initialization."""
import RNS
from meshchatx.src.backend.telephone_manager import TelephoneManager
# Mock identity
identity = RNS.Identity()
manager = TelephoneManager(identity)
assert manager.identity == identity
assert manager.telephone is None # Should be None until init_telephone
def test_voicemail_manager_smoke():
"""Smoke test for VoicemailManager initialization."""
from meshchatx.src.backend.voicemail_manager import VoicemailManager
from unittest.mock import MagicMock
mock_db = MagicMock()
mock_config = MagicMock()
mock_tm = MagicMock()
tmp_dir = tempfile.mkdtemp()
try:
manager = VoicemailManager(mock_db, mock_config, mock_tm, tmp_dir)
assert manager.db == mock_db
assert os.path.exists(os.path.join(tmp_dir, "voicemails", "recordings"))
finally:
import shutil
shutil.rmtree(tmp_dir)
def test_lxst_smoke():
"""Smoke test for LXST import and basic structure."""
import LXST
assert hasattr(LXST, "Telephone") or hasattr(LXST, "Pipeline")
def test_identity_context_smoke():
"""Smoke test for IdentityContext creation."""
import RNS
from meshchatx.src.backend.identity_context import IdentityContext
from unittest.mock import MagicMock
identity = RNS.Identity()
mock_app = MagicMock()
mock_app.storage_dir = tempfile.mkdtemp()
try:
ctx = IdentityContext(identity, mock_app)
assert ctx.identity == identity
assert ctx.identity_hash == identity.hash.hex()
finally:
import shutil
shutil.rmtree(mock_app.storage_dir)
def test_announce_manager_smoke():
"""Smoke test for AnnounceManager."""
from meshchatx.src.backend.announce_manager import AnnounceManager
from unittest.mock import MagicMock
mock_db = MagicMock()
manager = AnnounceManager(mock_db)
assert manager.db == mock_db
def test_rnstatus_handler_smoke():
"""Smoke test for RNStatusHandler."""
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
from unittest.mock import MagicMock
mock_rns = MagicMock()
handler = RNStatusHandler(mock_rns)
assert handler.reticulum == mock_rns
def test_lxmf_router_creation_smoke():
"""Smoke test for create_lxmf_router utility."""
import RNS
from meshchatx.src.backend.meshchat_utils import create_lxmf_router
from unittest.mock import patch
identity = RNS.Identity()
with tempfile.TemporaryDirectory() as tmpdir:
with patch("LXMF.LXMRouter") as mock_router:
create_lxmf_router(identity, tmpdir)
mock_router.assert_called()
+101
View File
@@ -0,0 +1,101 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import BotsPage from "@/components/tools/BotsPage.vue";
describe("BotsPage.vue", () => {
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: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
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",
});
});
});
+109
View File
@@ -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: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
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",
})
);
});
});
+32 -8
View File
@@ -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"');
});
});
+10 -2
View File
@@ -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");
});
});
+50 -115
View File
@@ -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("<div>Rendered Content</div>"),
})),
};
});
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
});
});
+126
View File
@@ -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: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
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",
})
);
});
});
+102
View File
@@ -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: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
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");
});
});
+98
View File
@@ -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: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
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");
});
});
+95
View File
@@ -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: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
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");
});
});
+94
View File
@@ -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: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
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 }),
})
);
});
});
+74
View File
@@ -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: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
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);
});
});
+81
View File
@@ -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: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
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);
});
});
+123
View File
@@ -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: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
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");
});
});
+14
View File
@@ -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", () => {