From 2db12e0bde7ecc92688a8cc2e37ff71bab4d290f Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 16 Apr 2026 00:52:09 -0500 Subject: [PATCH] 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 --- electron/main.js | 16 +- meshchatx/src/frontend/js/GlobalState.js | 3 + meshchatx/src/frontend/theme/designTokens.js | 16 +- scripts/build-backend.js | 95 ++++- tests/backend/test_lxst_integration.py | 6 +- tests/backend/test_rns_lifecycle.py | 378 +++++++++++++++++- tests/backend/test_telephone_initiation.py | 70 +++- tests/e2e/helpers.js | 76 ++++ .../e2e/messages-conversation-scroll.spec.js | 31 +- ...ersationViewerPerformance.baseline.test.js | 116 +++--- tests/frontend/KeyboardShortcuts.test.js | 4 +- .../SettingsPage.config-persistence.test.js | 20 +- 12 files changed, 705 insertions(+), 126 deletions(-) diff --git a/electron/main.js b/electron/main.js index 5ce1ab6..156da78 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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"), diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.js index 8c4c82e..3d1d183 100644 --- a/meshchatx/src/frontend/js/GlobalState.js +++ b/meshchatx/src/frontend/js/GlobalState.js @@ -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, }, }); diff --git a/meshchatx/src/frontend/theme/designTokens.js b/meshchatx/src/frontend/theme/designTokens.js index 605f7fd..c2198f1 100644 --- a/meshchatx/src/frontend/theme/designTokens.js +++ b/meshchatx/src/frontend/theme/designTokens.js @@ -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"], diff --git a/scripts/build-backend.js b/scripts/build-backend.js index 5bd7b83..615f791 100755 --- a/scripts/build-backend.js +++ b/scripts/build-backend.js @@ -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) { diff --git a/tests/backend/test_lxst_integration.py b/tests/backend/test_lxst_integration.py index 05bf7b1..52a49ec 100644 --- a/tests/backend/test_lxst_integration.py +++ b/tests/backend/test_lxst_integration.py @@ -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() diff --git a/tests/backend/test_rns_lifecycle.py b/tests/backend/test_rns_lifecycle.py index 4b9e3c6..dfec552 100644 --- a/tests/backend/test_rns_lifecycle.py +++ b/tests/backend/test_rns_lifecycle.py @@ -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 diff --git a/tests/backend/test_telephone_initiation.py b/tests/backend/test_telephone_initiation.py index eb0a5b9..8d1633b 100644 --- a/tests/backend/test_telephone_initiation.py +++ b/tests/backend/test_telephone_initiation.py @@ -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) diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js index 55bf313..b8ac2f7 100644 --- a/tests/e2e/helpers.js +++ b/tests/e2e/helpers.js @@ -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} + */ +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, }; diff --git a/tests/e2e/messages-conversation-scroll.spec.js b/tests/e2e/messages-conversation-scroll.spec.js index 48f36cd..a40f5e4 100644 --- a/tests/e2e/messages-conversation-scroll.spec.js +++ b/tests/e2e/messages-conversation-scroll.spec.js @@ -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); diff --git a/tests/frontend/ConversationViewerPerformance.baseline.test.js b/tests/frontend/ConversationViewerPerformance.baseline.test.js index 57f2da0..5253802 100644 --- a/tests/frontend/ConversationViewerPerformance.baseline.test.js +++ b/tests/frontend/ConversationViewerPerformance.baseline.test.js @@ -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); }); diff --git a/tests/frontend/KeyboardShortcuts.test.js b/tests/frontend/KeyboardShortcuts.test.js index b78c64a..f0a5928 100644 --- a/tests/frontend/KeyboardShortcuts.test.js +++ b/tests/frontend/KeyboardShortcuts.test.js @@ -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", - }), + }) ); }); }); diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js index 9c8c79c..f853437 100644 --- a/tests/frontend/SettingsPage.config-persistence.test.js +++ b/tests/frontend/SettingsPage.config-persistence.test.js @@ -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 () => {