feat(electron, frontend, tests): update Linux app name handling, add UI transparency options, and improve message scrolling tests; refactor icon path retrieval and update license artifact generation logic

This commit is contained in:
Ivan
2026-04-16 00:52:09 -05:00
parent d444daa073
commit 2db12e0bde
12 changed files with 705 additions and 126 deletions
+12 -4
View File
@@ -68,6 +68,10 @@ if (process.argv.includes("--disable-gpu") || process.argv.includes("--disable-s
app.disableHardwareAcceleration();
}
if (process.platform === "linux") {
app.setName("reticulum-meshchatx");
}
// Protocol registration
if (process.defaultApp) {
if (process.argv.length >= 2) {
@@ -411,7 +415,7 @@ function formatRenderProcessGoneDetails(details) {
exitCode: details.exitCode,
},
null,
2,
2
);
}
@@ -441,12 +445,14 @@ function getDefaultReticulumConfigDir() {
return path.join(app.getPath("home"), ".reticulum");
}
function createTray() {
function getAppIconPath() {
const iconPath = path.join(__dirname, "build", "icon.png");
const fallbackIconPath = path.join(__dirname, "assets", "images", "logo.png");
const trayIcon = fs.existsSync(iconPath) ? iconPath : fallbackIconPath;
return fs.existsSync(iconPath) ? iconPath : fallbackIconPath;
}
tray = new Tray(trayIcon);
function createTray() {
tray = new Tray(getAppIconPath());
const contextMenu = Menu.buildFromTemplate([
{
label: "Show App",
@@ -523,10 +529,12 @@ app.whenReady().then(async () => {
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
if (!shouldLaunchHeadless) {
const appIconPath = getAppIconPath();
// create browser window
mainWindow = new BrowserWindow({
width: 1500,
height: 800,
icon: appIconPath,
webPreferences: {
// used to inject logging over ipc
preload: path.join(__dirname, "preload.js"),
+3
View File
@@ -24,6 +24,9 @@ const globalState = reactive({
nomad_render_html_enabled: true,
nomad_render_plaintext_enabled: true,
nomad_default_page_path: "/page/index.mu",
ui_transparency: 0,
ui_glass_enabled: true,
message_list_virtualization: true,
},
});
+11 -5
View File
@@ -21,6 +21,8 @@ export const MESHCHAT_THEME_VARIABLES_LIGHT = {
"--mc-focus-border": "#60a5fa",
"--mc-accent": "#2563eb",
"--mc-accent-hover": "#3b82f6",
"--mc-action-primary": "#2563eb",
"--mc-action-primary-hover": "#3b82f6",
"--mc-scrollbar-thumb": "#94a3b8",
"--mc-scrollbar-track": "#e2e8f0",
"--mc-scrollbar-thumb-border": "#e2e8f0",
@@ -72,10 +74,12 @@ export const MESHCHAT_THEME_VARIABLES_DARK = {
"--mc-text-secondary": "#ffffff",
"--mc-text-muted": "#9ca3af",
"--mc-text-label": "#e5e7eb",
"--mc-focus": "#60a5fa",
"--mc-focus-border": "#60a5fa",
"--mc-accent": "#2563eb",
"--mc-focus": "#3b82f6",
"--mc-focus-border": "#3b82f6",
"--mc-accent": "#60a5fa",
"--mc-accent-hover": "#3b82f6",
"--mc-action-primary": "#2563eb",
"--mc-action-primary-hover": "#3b82f6",
"--mc-scrollbar-thumb": "#52525b",
"--mc-scrollbar-track": "#18181b",
"--mc-scrollbar-thumb-border": "#18181b",
@@ -133,6 +137,8 @@ export function tailwindSemanticColorExtend() {
"fg-label": "var(--mc-text-label)",
accent: "var(--mc-accent)",
"accent-hover": "var(--mc-accent-hover)",
"action-primary": "var(--mc-action-primary)",
"action-primary-hover": "var(--mc-action-primary-hover)",
focus: "var(--mc-focus)",
glass: "var(--mc-glass-surface)",
"surface-muted": "var(--mc-surface-muted)",
@@ -190,7 +196,7 @@ export function vuetifyThemesFromTokens() {
colors: {
background: L["--mc-canvas"],
surface: L["--mc-surface"],
primary: L["--mc-accent"],
primary: L["--mc-action-primary"],
secondary: "#475569",
error: L["--mc-error"],
info: L["--mc-info"],
@@ -203,7 +209,7 @@ export function vuetifyThemesFromTokens() {
colors: {
background: D["--mc-canvas"],
surface: D["--mc-surface"],
primary: D["--mc-accent"],
primary: D["--mc-action-primary"],
secondary: "#94a3b8",
error: D["--mc-error"],
info: D["--mc-info"],
+78 -17
View File
@@ -59,6 +59,71 @@ function generateManifest(buildDir, manifestPath) {
console.log(`Manifest saved to ${manifestPath} (${Object.keys(manifest.files).length} files)`);
}
function failOnSpawnResult(stepName, result) {
if (result.error) {
throw result.error;
}
if (result.signal) {
console.error(`${stepName} was terminated by signal ${result.signal}.`);
if (result.signal === "SIGKILL") {
console.error(
"Build process was force-killed (often OOM killer). This is likely memory pressure, not a normal script error."
);
}
process.exit(1);
}
if (result.status !== 0) {
console.error(`${stepName} exited with status ${result.status}.`);
process.exit(result.status || 1);
}
}
function fileMtimeMs(filePath) {
if (!fs.existsSync(filePath)) {
return null;
}
return fs.statSync(filePath).mtimeMs;
}
function shouldRefreshLicenseArtifacts(repoRoot) {
const forceRefresh =
process.env.MESHCHATX_FORCE_LICENSE_ARTIFACTS === "1" ||
process.env.MESHCHATX_FORCE_LICENSE_ARTIFACTS === "true";
if (forceRefresh) {
return true;
}
const dataDir = path.join(repoRoot, "meshchatx", "src", "backend", "data");
const noticesPath = path.join(dataDir, "THIRD_PARTY_NOTICES.txt");
const frontendLicensesPath = path.join(dataDir, "licenses_frontend.json");
const outputTimes = [fileMtimeMs(noticesPath), fileMtimeMs(frontendLicensesPath)];
if (outputTimes.some((t) => t == null)) {
return true;
}
const oldestOutput = Math.min(...outputTimes);
const inputFiles = [
path.join(repoRoot, "pyproject.toml"),
path.join(repoRoot, "poetry.lock"),
path.join(repoRoot, "package.json"),
path.join(repoRoot, "pnpm-lock.yaml"),
path.join(repoRoot, "meshchatx", "src", "backend", "licenses_collector.py"),
];
let newestInput = 0;
for (const inputFile of inputFiles) {
const mtime = fileMtimeMs(inputFile);
if (mtime != null && mtime > newestInput) {
newestInput = mtime;
}
}
return newestInput >= oldestOutput;
}
try {
const platform = process.env.PLATFORM || process.platform;
const arch = process.env.ARCH || process.arch;
@@ -103,17 +168,17 @@ try {
spawnArgs = ["-x86_64", cmd, ...licensesArgs];
}
console.log("Generating embedded third-party license artifacts...");
const licensesResult = spawnSync(spawnCmd, spawnArgs, {
stdio: "inherit",
shell: false,
env: env,
});
if (licensesResult.error) {
throw licensesResult.error;
}
if (licensesResult.status !== 0) {
process.exit(licensesResult.status || 1);
const repoRoot = path.join(__dirname, "..");
if (shouldRefreshLicenseArtifacts(repoRoot)) {
console.log("Generating embedded third-party license artifacts...");
const licensesResult = spawnSync(spawnCmd, spawnArgs, {
stdio: "inherit",
shell: false,
env: env,
});
failOnSpawnResult("License artifact generation", licensesResult);
} else {
console.log("Skipping license artifact generation (artifacts are up to date).");
}
spawnCmd = cmd;
@@ -123,17 +188,13 @@ try {
spawnArgs = ["-x86_64", cmd, ...args];
}
console.log("Running cx_Freeze backend build...");
const result = spawnSync(spawnCmd, spawnArgs, {
stdio: "inherit",
shell: false,
env: env,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
process.exit(result.status || 1);
}
failOnSpawnResult("Backend build", result);
if (fs.existsSync(buildDir)) {
if (isDarwin) {
+5 -1
View File
@@ -133,7 +133,11 @@ def test_lxst_switch_profile_updates_codec_and_frame_time(monkeypatch):
telephone = LXSTTelephony.Telephone(identity)
telephone.call_status = LXSTTelephony.Signalling.STATUS_ESTABLISHED
telephone.active_call = SimpleNamespace(profile=LXSTTelephony.Profiles.QUALITY_MEDIUM, filters=[], packetizer=MagicMock())
telephone.active_call = SimpleNamespace(
profile=LXSTTelephony.Profiles.QUALITY_MEDIUM,
filters=[],
packetizer=MagicMock(),
)
telephone.transmit_mixer = _FakeMixer(target_frame_ms=60, gain=0.0)
telephone.audio_input = _FakeLineSource()
telephone.transmit_pipeline = _FakePipeline()
+377 -1
View File
@@ -1,4 +1,5 @@
import os
import json
import shutil
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
@@ -253,7 +254,7 @@ async def test_reload_reticulum_failure_recovery(mock_rns, temp_dir):
# or just mock a method inside the try block to raise.
with patch.object(
app,
"teardown_identity",
"_teardown_all_contexts_for_reload",
side_effect=Exception("Reload failed"),
):
result = await app.reload_reticulum()
@@ -312,3 +313,378 @@ async def test_hotswap_identity(mock_rns, temp_dir):
broadcast_call = app.websocket_broadcast.call_args[0][0]
assert "identity_switched" in broadcast_call
app.teardown_identity()
@pytest.mark.asyncio
async def test_reload_reticulum_restores_same_identity(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch("meshchatx.src.backend.identity_context.ConfigManager"),
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
patch("meshchatx.src.backend.identity_context.MapManager"),
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("LXMF.LXMRouter"),
patch("asyncio.sleep", return_value=None),
patch("socket.socket") as mock_socket,
):
mock_sock_inst = MagicMock()
mock_socket.return_value = mock_sock_inst
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
original_identity = app.identity
app.setup_identity = MagicMock()
app.cleanup_rns_state_for_identity = MagicMock()
result = await app.reload_reticulum()
assert result is True
app.cleanup_rns_state_for_identity.assert_called_with(original_identity.hash)
app.setup_identity.assert_called_with(original_identity)
app.teardown_identity()
@pytest.mark.asyncio
async def test_transport_enable_endpoint_reloads_rns(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch("meshchatx.src.backend.identity_context.ConfigManager"),
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
patch("meshchatx.src.backend.identity_context.MapManager"),
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("LXMF.LXMRouter"),
):
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app.reload_reticulum = AsyncMock(return_value=True)
handler = None
for route in app.get_routes():
if (
route.path == "/api/v1/reticulum/enable-transport"
and route.method == "POST"
):
handler = route.handler
break
assert handler is not None
response = await handler(MagicMock())
payload = json.loads(response.body)
assert response.status == 200
assert (
payload["message"]
== "Transport mode enabled and RNS restarted successfully."
)
app.reload_reticulum.assert_awaited_once()
app.teardown_identity()
@pytest.mark.asyncio
async def test_transport_disable_endpoint_reloads_rns(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch("meshchatx.src.backend.identity_context.ConfigManager"),
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
patch("meshchatx.src.backend.identity_context.MapManager"),
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("LXMF.LXMRouter"),
):
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app.reload_reticulum = AsyncMock(return_value=True)
handler = None
for route in app.get_routes():
if (
route.path == "/api/v1/reticulum/disable-transport"
and route.method == "POST"
):
handler = route.handler
break
assert handler is not None
response = await handler(MagicMock())
payload = json.loads(response.body)
assert response.status == 200
assert (
payload["message"]
== "Transport mode disabled and RNS restarted successfully."
)
app.reload_reticulum.assert_awaited_once()
app.teardown_identity()
@pytest.mark.asyncio
async def test_transport_enable_endpoint_reload_failure(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch("meshchatx.src.backend.identity_context.ConfigManager"),
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
patch("meshchatx.src.backend.identity_context.MapManager"),
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("LXMF.LXMRouter"),
):
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app.reload_reticulum = AsyncMock(return_value=False)
handler = None
for route in app.get_routes():
if (
route.path == "/api/v1/reticulum/enable-transport"
and route.method == "POST"
):
handler = route.handler
break
assert handler is not None
response = await handler(MagicMock())
payload = json.loads(response.body)
assert response.status == 500
assert (
payload["message"]
== "Transport mode was enabled in config, but RNS reload failed."
)
app.reload_reticulum.assert_awaited_once()
app.teardown_identity()
@pytest.mark.asyncio
async def test_transport_disable_endpoint_reload_failure(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch("meshchatx.src.backend.identity_context.ConfigManager"),
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
patch("meshchatx.src.backend.identity_context.MapManager"),
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("LXMF.LXMRouter"),
):
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app.reload_reticulum = AsyncMock(return_value=False)
handler = None
for route in app.get_routes():
if (
route.path == "/api/v1/reticulum/disable-transport"
and route.method == "POST"
):
handler = route.handler
break
assert handler is not None
response = await handler(MagicMock())
payload = json.loads(response.body)
assert response.status == 500
assert (
payload["message"]
== "Transport mode was disabled in config, but RNS reload failed."
)
app.reload_reticulum.assert_awaited_once()
app.teardown_identity()
@pytest.mark.asyncio
async def test_reticulum_reload_endpoint_success(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch("meshchatx.src.backend.identity_context.ConfigManager"),
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
patch("meshchatx.src.backend.identity_context.MapManager"),
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("LXMF.LXMRouter"),
):
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app.reload_reticulum = AsyncMock(return_value=True)
handler = None
for route in app.get_routes():
if route.path == "/api/v1/reticulum/reload" and route.method == "POST":
handler = route.handler
break
assert handler is not None
response = await handler(MagicMock())
payload = json.loads(response.body)
assert response.status == 200
assert payload["message"] == "Reticulum reloaded successfully"
app.reload_reticulum.assert_awaited_once()
app.teardown_identity()
@pytest.mark.asyncio
async def test_reticulum_reload_endpoint_failure(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch("meshchatx.src.backend.identity_context.ConfigManager"),
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
patch("meshchatx.src.backend.identity_context.MapManager"),
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("LXMF.LXMRouter"),
):
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app.reload_reticulum = AsyncMock(return_value=False)
handler = None
for route in app.get_routes():
if route.path == "/api/v1/reticulum/reload" and route.method == "POST":
handler = route.handler
break
assert handler is not None
response = await handler(MagicMock())
payload = json.loads(response.body)
assert response.status == 500
assert payload["error"] == "Failed to reload Reticulum"
app.reload_reticulum.assert_awaited_once()
app.teardown_identity()
@pytest.mark.asyncio
async def test_reload_teardown_stops_all_context_services(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch("meshchatx.src.backend.identity_context.ConfigManager"),
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
patch("meshchatx.src.backend.identity_context.MapManager"),
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("LXMF.LXMRouter"),
):
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
identity_a = MagicMock()
identity_a.hash = b"a" * 16
identity_b = MagicMock()
identity_b.hash = b"b" * 16
ctx_a = MagicMock()
ctx_a.identity = identity_a
ctx_a.bot_handler = MagicMock()
ctx_a.identity_hash = identity_a.hash.hex()
ctx_b = MagicMock()
ctx_b.identity = identity_b
ctx_b.bot_handler = MagicMock()
ctx_b.identity_hash = identity_b.hash.hex()
app.contexts = {
ctx_a.identity_hash: ctx_a,
ctx_b.identity_hash: ctx_b,
}
app.current_context = ctx_a
app.stop_local_propagation_node = MagicMock()
app.page_node_manager.teardown = MagicMock()
app._teardown_all_contexts_for_reload()
ctx_a.bot_handler.stop_all.assert_called_once()
ctx_b.bot_handler.stop_all.assert_called_once()
assert app.stop_local_propagation_node.call_count == 2
app.page_node_manager.teardown.assert_called_once()
ctx_a.teardown.assert_called_once()
ctx_b.teardown.assert_called_once()
assert app.contexts == {}
assert app.current_context is None
+54 -16
View File
@@ -40,9 +40,17 @@ async def test_initiate_retries_path_requests_during_lookup(telephone_manager):
)
with (
patch("meshchatx.src.backend.telephone_manager.RNS.Identity.recall", return_value=MagicMock()),
patch("meshchatx.src.backend.telephone_manager.RNS.Transport.has_path", side_effect=has_path),
patch("meshchatx.src.backend.telephone_manager.RNS.Transport.request_path") as request_path,
patch(
"meshchatx.src.backend.telephone_manager.RNS.Identity.recall",
return_value=MagicMock(),
),
patch(
"meshchatx.src.backend.telephone_manager.RNS.Transport.has_path",
side_effect=has_path,
),
patch(
"meshchatx.src.backend.telephone_manager.RNS.Transport.request_path"
) as request_path,
):
await telephone_manager.initiate(destination_hash, timeout_seconds=1)
@@ -60,14 +68,22 @@ async def test_initiate_cancels_quickly_while_finding_path_identity(telephone_ma
telephone_manager._update_initiation_status(None, None)
with (
patch("meshchatx.src.backend.telephone_manager.RNS.Identity.recall", return_value=None),
patch("meshchatx.src.backend.telephone_manager.RNS.Transport.has_path", return_value=False),
patch(
"meshchatx.src.backend.telephone_manager.RNS.Identity.recall",
return_value=None,
),
patch(
"meshchatx.src.backend.telephone_manager.RNS.Transport.has_path",
return_value=False,
),
patch(
"meshchatx.src.backend.telephone_manager.RNS.Transport.request_path",
side_effect=request_path_and_cancel,
),
):
task = asyncio.create_task(telephone_manager.initiate(destination_hash, timeout_seconds=5))
task = asyncio.create_task(
telephone_manager.initiate(destination_hash, timeout_seconds=5)
)
result = await asyncio.wait_for(task, timeout=0.3)
assert result is None
@@ -84,12 +100,23 @@ async def test_initiate_cancels_quickly_while_dialling(telephone_manager):
telephone_manager.telephone.call.side_effect = blocking_call
with (
patch("meshchatx.src.backend.telephone_manager.RNS.Identity.recall", return_value=MagicMock()),
patch("meshchatx.src.backend.telephone_manager.RNS.Transport.has_path", return_value=True),
patch(
"meshchatx.src.backend.telephone_manager.RNS.Identity.recall",
return_value=MagicMock(),
),
patch(
"meshchatx.src.backend.telephone_manager.RNS.Transport.has_path",
return_value=True,
),
):
task = asyncio.create_task(telephone_manager.initiate(destination_hash, timeout_seconds=5))
task = asyncio.create_task(
telephone_manager.initiate(destination_hash, timeout_seconds=5)
)
for _ in range(200):
if telephone_manager.initiation_status in ("Establishing link...", "Calling..."):
if telephone_manager.initiation_status in (
"Establishing link...",
"Calling...",
):
break
await asyncio.sleep(0)
@@ -149,7 +176,9 @@ async def test_cancel_after_path_found_before_dialling_stabilizes(telephone_mana
return_value=True,
),
):
task = asyncio.create_task(telephone_manager.initiate(destination_hash, timeout_seconds=2))
task = asyncio.create_task(
telephone_manager.initiate(destination_hash, timeout_seconds=2)
)
for _ in range(200):
if telephone_manager.initiation_status == "Establishing link...":
break
@@ -254,7 +283,9 @@ async def test_call_thread_exception_surfaces_without_hanging(telephone_manager)
"meshchatx.src.backend.telephone_manager.RNS.Transport.has_path",
return_value=True,
),
patch("meshchatx.src.backend.telephone_manager.asyncio.sleep", side_effect=no_wait),
patch(
"meshchatx.src.backend.telephone_manager.asyncio.sleep", side_effect=no_wait
),
):
result = await asyncio.wait_for(
telephone_manager.initiate(destination_hash, timeout_seconds=1),
@@ -274,6 +305,7 @@ async def test_inconsistent_call_status_finishes_within_timeout(telephone_manage
return None
telephone_manager.telephone.call.side_effect = inconsistent_call
async def no_wait(_seconds):
return None
@@ -286,7 +318,9 @@ async def test_inconsistent_call_status_finishes_within_timeout(telephone_manage
"meshchatx.src.backend.telephone_manager.RNS.Transport.has_path",
return_value=True,
),
patch("meshchatx.src.backend.telephone_manager.asyncio.sleep", side_effect=no_wait),
patch(
"meshchatx.src.backend.telephone_manager.asyncio.sleep", side_effect=no_wait
),
):
result = await asyncio.wait_for(
telephone_manager.initiate(destination_hash, timeout_seconds=0.2),
@@ -339,8 +373,10 @@ async def test_lxst_busy_and_rejected_end_without_stuck_status(telephone_manager
for terminal_state in (0, 1):
telephone_manager._status_events.clear()
telephone_manager.telephone.call_status = 3
telephone_manager.telephone.call.side_effect = lambda _identity, state=terminal_state: setattr(
telephone_manager.telephone, "call_status", state
telephone_manager.telephone.call.side_effect = (
lambda _identity, state=terminal_state: setattr(
telephone_manager.telephone, "call_status", state
)
)
with (
@@ -386,7 +422,9 @@ async def test_rapid_dial_cancel_soak_has_bounded_memory(telephone_manager):
tracemalloc.start()
for _ in range(loops):
telephone_manager.telephone.call_status = 3
task = asyncio.create_task(telephone_manager.initiate(destination_hash, timeout_seconds=1))
task = asyncio.create_task(
telephone_manager.initiate(destination_hash, timeout_seconds=1)
)
await asyncio.sleep(0.005)
telephone_manager._update_initiation_status(None, None)
await asyncio.wait_for(task, timeout=0.5)
+76
View File
@@ -1,8 +1,81 @@
const crypto = require("crypto");
const { expect } = require("@playwright/test");
const E2E_BACKEND_PORT = process.env.E2E_BACKEND_PORT || "18079";
const E2E_BACKEND_ORIGIN = `http://127.0.0.1:${E2E_BACKEND_PORT}`;
const E2E_SCROLL_PEER_HASH = `e2e0${"0".repeat(28)}`;
function buildE2eLxmfRow({ peerHash, localHash, index, total, inbound }) {
const hash = crypto.randomBytes(16).toString("hex");
const baseTs = Math.floor(Date.now() / 1000) - total;
return {
hash,
source_hash: inbound ? peerHash : localHash,
destination_hash: inbound ? localHash : peerHash,
peer_hash: peerHash,
state: "delivered",
progress: 1.0,
is_incoming: inbound ? 1 : 0,
method: "direct",
delivery_attempts: 1,
next_delivery_attempt_at: null,
title: "",
content: `E2E scroll seed ${String(index).padStart(3, "0")} ${"x".repeat(64)}`,
fields: "{}",
timestamp: baseTs + index,
rssi: null,
snr: null,
quality: null,
is_spam: 0,
reply_to_hash: null,
attachments_stripped: 0,
};
}
/**
* @param {import('@playwright/test').APIRequestContext} request
* @returns {Promise<string>}
*/
async function getE2eLocalLxmfHash(request) {
const cfgRes = await request.get(`${E2E_BACKEND_ORIGIN}/api/v1/config`);
expect(cfgRes.ok()).toBeTruthy();
const cfgBody = await cfgRes.json();
const localHash = cfgBody.config?.lxmf_address_hash;
expect(localHash && String(localHash).length === 32).toBeTruthy();
return localHash;
}
/**
* Inserts LXMF rows via maintenance import so the messages UI has a long thread for scroll tests.
* @param {import('@playwright/test').APIRequestContext} request
* @param {{ messageCount?: number }} [opts]
* @returns {Promise<{ peerHash: string, localHash: string }>}
*/
async function seedE2eLongConversationThread(request, opts = {}) {
const messageCount = opts.messageCount ?? 45;
const localHash = await getE2eLocalLxmfHash(request);
const peerHash = E2E_SCROLL_PEER_HASH;
const messages = [];
for (let i = 0; i < messageCount; i++) {
messages.push(
buildE2eLxmfRow({
peerHash,
localHash,
index: i,
total: messageCount,
inbound: i % 2 === 0,
})
);
}
const imp = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, {
data: { messages },
});
expect(imp.ok()).toBeTruthy();
return { peerHash, localHash };
}
const PALETTE_PLACEHOLDER = /Search commands,\s*(routes|navigate),\s*or peers\.{0,3}/i;
/**
@@ -51,8 +124,11 @@ async function dismissMapOnboardingTooltip(page) {
module.exports = {
E2E_BACKEND_ORIGIN,
E2E_SCROLL_PEER_HASH,
PALETTE_PLACEHOLDER,
dismissMapOnboardingTooltip,
openCommandPalette,
prepareE2eSession,
getE2eLocalLxmfHash,
seedE2eLongConversationThread,
};
+26 -5
View File
@@ -82,9 +82,18 @@ test.describe("Messages conversation scroll", () => {
test("starts near bottom with a long thread", async ({ page }) => {
await page.goto("/#/messages");
await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 });
await page.locator(".conversation-item").filter({ hasText: /E2E scroll seed/ }).first().click();
await page
.locator(".conversation-item")
.filter({ hasText: /E2E scroll seed/ })
.first()
.click();
await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
await expect(page.locator("#messages").getByText(/E2E scroll seed 119/).first()).toBeVisible({
await expect(
page
.locator("#messages")
.getByText(/E2E scroll seed 119/)
.first()
).toBeVisible({
timeout: 25000,
});
@@ -98,7 +107,11 @@ test.describe("Messages conversation scroll", () => {
const localHash = await getE2eLocalLxmfHash(request);
await page.goto("/#/messages");
await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 });
await page.locator(".conversation-item").filter({ hasText: /E2E scroll seed/ }).first().click();
await page
.locator(".conversation-item")
.filter({ hasText: /E2E scroll seed/ })
.first()
.click();
await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
await waitForMessagesOverflow(page);
@@ -145,7 +158,11 @@ test.describe("Messages conversation scroll", () => {
const localHash = await getE2eLocalLxmfHash(request);
await page.goto("/#/messages");
await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 });
await page.locator(".conversation-item").filter({ hasText: /E2E scroll seed/ }).first().click();
await page
.locator(".conversation-item")
.filter({ hasText: /E2E scroll seed/ })
.first()
.click();
await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
await waitForMessagesOverflow(page);
@@ -183,7 +200,11 @@ test.describe("Messages conversation scroll", () => {
test("preserves scroll anchor when loading older messages from the top", async ({ page }) => {
await page.goto("/#/messages");
await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 });
await page.locator(".conversation-item").filter({ hasText: /E2E scroll seed/ }).first().click();
await page
.locator(".conversation-item")
.filter({ hasText: /E2E scroll seed/ })
.first()
.click();
await expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
await waitForMessagesOverflow(page);
@@ -104,77 +104,65 @@ describe("ConversationViewer performance baselines", () => {
expect(sig[0]).toMatch(/^single:msg_/);
});
it(
"bulk chatItems update: baseline ceiling (detect regressions)",
async () => {
const wrapper = mountViewer();
const n = 800;
const items = makeChatItems(n, myLxmfAddressHash, peerHash);
it("bulk chatItems update: baseline ceiling (detect regressions)", async () => {
const wrapper = mountViewer();
const n = 800;
const items = makeChatItems(n, myLxmfAddressHash, peerHash);
const t0 = performance.now();
await wrapper.setData({ chatItems: items });
await wrapper.vm.$nextTick();
const ms = performance.now() - t0;
const t0 = performance.now();
await wrapper.setData({ chatItems: items });
await wrapper.vm.$nextTick();
const ms = performance.now() - t0;
expect(wrapper.vm.selectedPeerChatDisplayGroups.length).toBe(n);
expect(ms).toBeLessThan(15000);
},
60_000
);
expect(wrapper.vm.selectedPeerChatDisplayGroups.length).toBe(n);
expect(ms).toBeLessThan(15000);
}, 60_000);
it(
"incremental append: baseline ceiling when thread already large",
async () => {
const wrapper = mountViewer();
const n = 600;
await wrapper.setData({ chatItems: makeChatItems(n, myLxmfAddressHash, peerHash) });
await wrapper.vm.$nextTick();
it("incremental append: baseline ceiling when thread already large", async () => {
const wrapper = mountViewer();
const n = 600;
await wrapper.setData({ chatItems: makeChatItems(n, myLxmfAddressHash, peerHash) });
await wrapper.vm.$nextTick();
const newMsg = {
type: "lxmf_message",
is_outbound: true,
lxmf_message: {
hash: "newmsg".padEnd(32, "0"),
source_hash: myLxmfAddressHash,
destination_hash: peerHash,
content: "New",
created_at: new Date().toISOString(),
state: "delivered",
method: "direct",
progress: 1.0,
delivery_attempts: 1,
id: n,
},
};
const newMsg = {
type: "lxmf_message",
is_outbound: true,
lxmf_message: {
hash: "newmsg".padEnd(32, "0"),
source_hash: myLxmfAddressHash,
destination_hash: peerHash,
content: "New",
created_at: new Date().toISOString(),
state: "delivered",
method: "direct",
progress: 1.0,
delivery_attempts: 1,
id: n,
},
};
const t0 = performance.now();
wrapper.vm.chatItems.push(newMsg);
await wrapper.vm.$nextTick();
const ms = performance.now() - t0;
const t0 = performance.now();
wrapper.vm.chatItems.push(newMsg);
await wrapper.vm.$nextTick();
const ms = performance.now() - t0;
expect(wrapper.vm.selectedPeerChatDisplayGroups.length).toBe(n + 1);
expect(ms).toBeLessThan(8000);
},
60_000
);
expect(wrapper.vm.selectedPeerChatDisplayGroups.length).toBe(n + 1);
expect(ms).toBeLessThan(8000);
}, 60_000);
it(
"display groups computation alone stays bounded for large n",
async () => {
const wrapper = mountViewer();
const n = 2000;
await wrapper.setData({ chatItems: makeChatItems(n, myLxmfAddressHash, peerHash) });
await wrapper.vm.$nextTick();
it("display groups computation alone stays bounded for large n", async () => {
const wrapper = mountViewer();
const n = 2000;
await wrapper.setData({ chatItems: makeChatItems(n, myLxmfAddressHash, peerHash) });
await wrapper.vm.$nextTick();
const t0 = performance.now();
for (let k = 0; k < 20; k++) {
void wrapper.vm.selectedPeerChatDisplayGroups;
}
const ms = performance.now() - t0;
const t0 = performance.now();
for (let k = 0; k < 20; k++) {
void wrapper.vm.selectedPeerChatDisplayGroups;
}
const ms = performance.now() - t0;
expect(wrapper.vm.selectedPeerChatDisplayGroups.length).toBe(n);
expect(ms).toBeLessThan(2000);
},
60_000
);
expect(wrapper.vm.selectedPeerChatDisplayGroups.length).toBe(n);
expect(ms).toBeLessThan(2000);
}, 60_000);
});
+2 -2
View File
@@ -126,7 +126,7 @@ describe("KeyboardShortcuts", () => {
type: "keyboard_shortcuts.set",
action: "nav_messages",
keys: ["alt", "q"],
}),
})
);
});
@@ -136,7 +136,7 @@ describe("KeyboardShortcuts", () => {
JSON.stringify({
type: "keyboard_shortcuts.delete",
action: "nav_map",
}),
})
);
});
});
@@ -300,7 +300,7 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
message_inbound_bubble_color: null,
message_failed_bubble_color: "#ef4444",
message_waiting_bubble_color: "#e5e7eb",
}),
})
);
});
@@ -330,7 +330,7 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
expect.objectContaining({
announce_max_stored_lxmf_delivery: 900,
discovered_interfaces_max_return: 500,
}),
})
);
});
@@ -341,21 +341,21 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
"/api/v1/config",
expect.objectContaining({
auto_resend_failed_messages_when_announce_received: true,
}),
})
);
await w.vm.onAllowAutoResendingFailedMessagesWithAttachmentsChange();
expect(api.patch).toHaveBeenCalledWith(
"/api/v1/config",
expect.objectContaining({
allow_auto_resending_failed_messages_with_attachments: false,
}),
})
);
await w.vm.onAutoSendFailedMessagesToPropagationNodeChange();
expect(api.patch).toHaveBeenCalledWith(
"/api/v1/config",
expect.objectContaining({
auto_send_failed_messages_to_propagation_node: false,
}),
})
);
});
@@ -507,7 +507,7 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
await vi.advanceTimersByTimeAsync(1000);
expect(api.patch).toHaveBeenCalledWith(
"/api/v1/config",
expect.objectContaining({ banished_text: "OUT", banished_color: "#ff0000" }),
expect.objectContaining({ banished_text: "OUT", banished_color: "#ff0000" })
);
});
@@ -574,7 +574,7 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
expect.objectContaining({
gitea_base_url: "https://gitea.example",
docs_download_urls: "https://docs.example",
}),
})
);
});
@@ -592,7 +592,7 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
expect.objectContaining({
csp_extra_connect_src: "wss://a.example",
csp_extra_style_src: "https://css.example",
}),
})
);
});
@@ -772,9 +772,7 @@ describe("SettingsPage — maintenance, exports, telemetry trust, RNS reload", (
it("flushArchivedPages sends websocket flush after confirm", async () => {
const w = await mountSettingsPage(api);
await w.vm.flushArchivedPages();
expect(WebSocketConnection.send).toHaveBeenCalledWith(
JSON.stringify({ type: "nomadnet.page.archive.flush" }),
);
expect(WebSocketConnection.send).toHaveBeenCalledWith(JSON.stringify({ type: "nomadnet.page.archive.flush" }));
});
it("revokeTelemetryTrust PATCHes contact telemetry flag", async () => {