mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-05-25 00:55:40 +00:00
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:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user