feat(tests): update testing framework for Electron and backend integrity checks, add new tests for loading status and main helpers

This commit is contained in:
Ivan
2026-04-18 00:06:21 -05:00
parent 89d2b1cc0f
commit 44c652699d
16 changed files with 397 additions and 99 deletions
+3 -5
View File
@@ -50,7 +50,7 @@ jobs:
timeout: 45
- name: Backend tests
id: backend-tests
timeout: 55
timeout: 120
- name: Localization tests
id: lang-tests
timeout: 20
@@ -93,13 +93,11 @@ jobs:
poetry run ruff format --check .
;;
frontend-tests)
pnpm run test -- --exclude tests/frontend/i18n.test.js
pnpm exec vitest run --exclude tests/frontend/LoadTimePerformance.test.js --exclude tests/frontend/i18n.test.js
pnpm exec vitest run --config vitest.electron.config.js
;;
backend-tests)
poetry run python -m pytest tests/backend -n auto \
--ignore=tests/backend/test_performance_hotpaths.py \
--ignore=tests/backend/test_memory_profiling.py \
--ignore=tests/backend/test_performance_bottlenecks.py \
--cov=meshchatx/src/backend
;;
lang-tests)
+1
View File
@@ -100,6 +100,7 @@ docker-compose.override.yml
.coverage
coverage/
coverage-electron/
test-results/
playwright-report/
+1 -1
View File
@@ -40,7 +40,7 @@ lint:
test:
pnpm run test
poetry run python -m pytest tests/backend -n auto --ignore=tests/backend/test_performance_hotpaths.py --ignore=tests/backend/test_memory_profiling.py --ignore=tests/backend/test_performance_bottlenecks.py --cov=meshchatx/src/backend
poetry run python -m pytest tests/backend -n auto --cov=meshchatx/src/backend
test-be-perf:
poetry run python -m pytest tests/backend/test_performance_hotpaths.py tests/backend/test_performance_bottlenecks.py
+9 -8
View File
@@ -161,22 +161,22 @@ tasks:
- "{{.NPM}} run test:e2e"
test:be:
desc: Run Python tests (pytest; excludes timing-sensitive perf and memory profiling — use test:be:perf / profile:mem locally)
desc: Run Python tests (pytest; includes perf/memory profiling — same as GitHub CI)
cmds:
- poetry run pytest tests/backend -n auto --ignore=tests/backend/test_performance_hotpaths.py --ignore=tests/backend/test_memory_profiling.py --ignore=tests/backend/test_performance_bottlenecks.py --cov=meshchatx/src/backend
- poetry run pytest tests/backend -n auto --cov=meshchatx/src/backend
test:be:cov:
desc: Run Python tests with detailed coverage (excludes performance hot-path, bottleneck timing, and memory profiling tests)
desc: Run Python tests with detailed coverage (includes performance and memory profiling tests)
cmds:
- poetry run pytest tests/backend -n auto --ignore=tests/backend/test_performance_hotpaths.py --ignore=tests/backend/test_memory_profiling.py --ignore=tests/backend/test_performance_bottlenecks.py --cov=meshchatx/src/backend --cov-report=term-missing
- poetry run pytest tests/backend -n auto --cov=meshchatx/src/backend --cov-report=term-missing
test:be:perf:
desc: Backend performance regression tests (not run in CI; run locally when needed)
desc: Backend performance regression tests only (hot paths + bottlenecks)
cmds:
- poetry run pytest tests/backend/test_performance_hotpaths.py tests/backend/test_performance_bottlenecks.py
test:be:full:
desc: All backend tests including performance hot paths (local)
desc: Same as test:be (full backend suite)
cmds:
- poetry run pytest tests/backend -n auto --cov=meshchatx/src/backend
@@ -186,9 +186,10 @@ tasks:
- poetry run mutmut run "meshchatx.src.backend.meshchat_utils*"
test:fe:
desc: Run frontend tests (vitest; excludes i18n — see test:lang; excludes LoadTimePerformance — use test:fe:loadtime locally)
desc: Run frontend + Electron shell unit tests (vitest; excludes i18n — see test:lang; excludes LoadTimePerformance — use test:fe:loadtime locally)
cmds:
- "{{.NPM}} run test -- --exclude tests/frontend/i18n.test.js"
- "{{.NPM}} exec vitest run --exclude tests/frontend/LoadTimePerformance.test.js --exclude tests/frontend/i18n.test.js"
- "{{.NPM}} exec vitest run --config vitest.electron.config.js"
test:fe:loadtime:
desc: Optional frontend load-time / sidebar performance tests (not run in CI)
+52
View File
@@ -0,0 +1,52 @@
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("node:path");
/**
* Verify SHA-256 hashes of backend files against backend-manifest.json next to the executable.
* @param {string} exeDir Directory containing the backend binary and manifest.
* @returns {{ ok: boolean, issues: string[] }}
*/
function verifyBackendIntegrity(exeDir) {
const manifestPath = path.join(exeDir, "backend-manifest.json");
if (!fs.existsSync(manifestPath)) {
return { ok: true, issues: ["Manifest missing"] };
}
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const issues = [];
const filesToVerify = manifest.files || manifest;
const metadata = manifest._metadata || {};
for (const [relPath, expectedHash] of Object.entries(filesToVerify)) {
const fullPath = path.join(exeDir, relPath);
if (!fs.existsSync(fullPath)) {
issues.push(`Missing: ${relPath}`);
continue;
}
const fileBuffer = fs.readFileSync(fullPath);
const actualHash = crypto.createHash("sha256").update(fileBuffer).digest("hex");
if (actualHash !== expectedHash) {
issues.push(`Modified: ${relPath}`);
}
}
if (issues.length > 0 && metadata.date && metadata.time) {
issues.unshift(`Backend build timestamp: ${metadata.date} ${metadata.time}`);
}
return {
ok: issues.length === 0,
issues: issues,
};
} catch (error) {
return { ok: false, issues: [error.message] };
}
}
module.exports = { verifyBackendIntegrity };
+11 -78
View File
@@ -17,7 +17,8 @@ const { spawn } = require("child_process");
const fs = require("fs");
const path = require("node:path");
const crypto = require("crypto");
const { verifyBackendIntegrity } = require("./backendIntegrity");
const { getUserProvidedArguments, formatRenderProcessGoneDetails, isLocalBackendUrl } = require("./mainHelpers");
// remember main window
var mainWindow = null;
@@ -121,50 +122,6 @@ app.on("open-url", (event, url) => {
}
});
function verifyBackendIntegrity(exeDir) {
const manifestPath = path.join(exeDir, "backend-manifest.json");
if (!fs.existsSync(manifestPath)) {
log("Backend integrity manifest missing, skipping check.");
return { ok: true, issues: ["Manifest missing"] };
}
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const issues = [];
const filesToVerify = manifest.files || manifest;
const metadata = manifest._metadata || {};
// The exeDir is build/exe when running or unpacked
// we only care about files in the manifest
for (const [relPath, expectedHash] of Object.entries(filesToVerify)) {
const fullPath = path.join(exeDir, relPath);
if (!fs.existsSync(fullPath)) {
issues.push(`Missing: ${relPath}`);
continue;
}
const fileBuffer = fs.readFileSync(fullPath);
const actualHash = crypto.createHash("sha256").update(fileBuffer).digest("hex");
if (actualHash !== expectedHash) {
issues.push(`Modified: ${relPath}`);
}
}
if (issues.length > 0 && metadata.date && metadata.time) {
issues.unshift(`Backend build timestamp: ${metadata.date} ${metadata.time}`);
}
return {
ok: issues.length === 0,
issues: issues,
};
} catch (error) {
log(`Backend integrity check failed: ${error.message}`);
return { ok: false, issues: [error.message] };
}
}
// allow fetching app version via ipc
ipcMain.handle("app-version", () => {
return app.getVersion();
@@ -217,13 +174,8 @@ ipcMain.handle("set-power-save-blocker", (event, enabled) => {
// ignore ssl errors
app.commandLine.appendSwitch("ignore-certificate-errors");
function getUserProvidedArguments() {
const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"];
return process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg));
}
ipcMain.handle("backend-http-only", () => {
return getUserProvidedArguments().includes("--no-https");
return getUserProvidedArguments(process.argv).includes("--no-https");
});
ipcMain.handle("backend-runtime-state", () => {
@@ -437,20 +389,6 @@ function log(message) {
mainWindow.webContents.send("log", message);
}
function formatRenderProcessGoneDetails(details) {
if (!details) {
return "no details";
}
return JSON.stringify(
{
reason: details.reason || "unknown",
exitCode: details.exitCode,
},
null,
2
);
}
function getDefaultStorageDir() {
// if we are running a windows portable exe, we want to use .reticulum-meshchat in the portable exe dir
// e.g if we launch "E:\Some\Path\MeshChat.exe" we want to use "E:\Some\Path\.reticulum-meshchat"
@@ -483,18 +421,6 @@ function getAppIconPath() {
return fs.existsSync(iconPath) ? iconPath : fallbackIconPath;
}
function isLocalBackendUrl(url) {
if (!url || typeof url !== "string") {
return false;
}
return (
url.startsWith("http://127.0.0.1:9337") ||
url.startsWith("https://127.0.0.1:9337") ||
url.startsWith("http://localhost:9337") ||
url.startsWith("https://localhost:9337")
);
}
function createTray() {
tray = new Tray(getAppIconPath());
const contextMenu = Menu.buildFromTemplate([
@@ -569,7 +495,7 @@ app.whenReady().then(async () => {
createTray();
// get arguments passed to application, and remove the provided application path
const userProvidedArguments = getUserProvidedArguments();
const userProvidedArguments = getUserProvidedArguments(process.argv);
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
if (!shouldLaunchHeadless) {
@@ -739,6 +665,13 @@ app.whenReady().then(async () => {
// Verify backend integrity before spawning
const exeDir = path.dirname(exe);
integrityStatus.backend = verifyBackendIntegrity(exeDir);
if (
integrityStatus.backend.ok &&
integrityStatus.backend.issues.length === 1 &&
integrityStatus.backend.issues[0] === "Manifest missing"
) {
log("Backend integrity manifest missing, skipping check.");
}
if (!integrityStatus.backend.ok) {
log(`INTEGRITY WARNING: Backend tampering detected! Issues: ${integrityStatus.backend.issues.join(", ")}`);
}
+54
View File
@@ -0,0 +1,54 @@
"use strict";
const IGNORED_CLI_ARGUMENTS = new Set(["--no-sandbox", "--ozone-platform-hint=auto"]);
/**
* Arguments after argv[0], excluding known Chromium/Electron noise flags.
* @param {string[]} argv Typically process.argv
* @returns {string[]}
*/
function getUserProvidedArguments(argv) {
const list = Array.isArray(argv) ? argv : [];
return list.slice(1).filter((arg) => !IGNORED_CLI_ARGUMENTS.has(arg));
}
/**
* @param {unknown} details Electron render-process-gone details
* @returns {string}
*/
function formatRenderProcessGoneDetails(details) {
if (!details) {
return "no details";
}
return JSON.stringify(
{
reason: details.reason || "unknown",
exitCode: details.exitCode,
},
null,
2
);
}
/**
* Whether the URL is the MeshChatX local backend origin (loading / API checks).
* @param {unknown} url
* @returns {boolean}
*/
function isLocalBackendUrl(url) {
if (!url || typeof url !== "string") {
return false;
}
return (
url.startsWith("http://127.0.0.1:9337") ||
url.startsWith("https://127.0.0.1:9337") ||
url.startsWith("http://localhost:9337") ||
url.startsWith("https://localhost:9337")
);
}
module.exports = {
getUserProvidedArguments,
formatRenderProcessGoneDetails,
isLocalBackendUrl,
};
+2 -2
View File
@@ -18,8 +18,8 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"test": "vitest run --exclude tests/frontend/LoadTimePerformance.test.js",
"test:coverage": "vitest run --coverage --exclude tests/frontend/LoadTimePerformance.test.js",
"test": "vitest run --exclude tests/frontend/LoadTimePerformance.test.js && vitest run --config vitest.electron.config.js",
"test:coverage": "vitest run --coverage --exclude tests/frontend/LoadTimePerformance.test.js && vitest run --config vitest.electron.config.js --coverage",
"test:fuzz": "vitest run -t fuzzing",
"test:watch": "vitest --exclude tests/frontend/LoadTimePerformance.test.js",
"test:loadtime": "vitest run tests/frontend/LoadTimePerformance.test.js",
+21
View File
@@ -0,0 +1,21 @@
# SPDX-License-Identifier: 0BSD
import unittest
from unittest.mock import MagicMock
from meshchatx.src.backend.announce_handler import AnnounceHandler
class TestAnnounceHandler(unittest.TestCase):
def test_forwards_to_callback_with_aspect_filter(self):
cb = MagicMock()
handler = AnnounceHandler("test.aspect", cb)
handler.received_announce(b"d", b"id", b"app", b"hash")
cb.assert_called_once_with("test.aspect", b"d", b"id", b"app", b"hash")
def test_swallows_callback_exception(self):
def bad_cb(_aspect, *_args):
raise RuntimeError("simulated handler failure")
handler = AnnounceHandler("a", bad_cb)
handler.received_announce(b"d", b"id", b"app", b"h")
@@ -2,9 +2,8 @@
"""Wall-clock database throughput tests (large seeds + strict ms ceilings).
Excluded from default `task test:be` / CI (like test_performance_hotpaths.py and
test_memory_profiling.py). Run locally: `task test:be:perf` or
`pytest tests/backend/test_performance_bottlenecks.py`.
Included in the full backend suite (`task test:be`, GitHub CI). For perf-only:
`task test:be:perf` or `pytest tests/backend/test_performance_bottlenecks.py`.
"""
import os
+2 -2
View File
@@ -2,8 +2,8 @@
"""Performance regression tests for the critical hot paths.
Excluded from default `task test:be` / CI; run locally with `task test:be:perf`
or `task test:be:full` (see Taskfile.yml).
Run as part of the full backend suite (`task test:be`, `make test`, GitHub CI).
For perf-only: `task test:be:perf`.
Focus areas (user priority):
- NomadNet browser: load announces, search announces, favourites
+73
View File
@@ -0,0 +1,73 @@
import fs from "fs";
import os from "os";
import path from "path";
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
import crypto from "crypto";
const require = createRequire(import.meta.url);
const { verifyBackendIntegrity } = require("../../electron/backendIntegrity.js");
describe("electron/backendIntegrity", () => {
it("returns ok when manifest is absent", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcx-int-"));
try {
const r = verifyBackendIntegrity(dir);
expect(r.ok).toBe(true);
expect(r.issues).toContain("Manifest missing");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("detects missing file from manifest", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcx-int-"));
try {
const manifest = { files: { "missing.bin": "abc" } };
fs.writeFileSync(path.join(dir, "backend-manifest.json"), JSON.stringify(manifest), "utf8");
const r = verifyBackendIntegrity(dir);
expect(r.ok).toBe(false);
expect(r.issues.some((i) => i.includes("Missing:"))).toBe(true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("detects hash mismatch", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcx-int-"));
try {
const fileRel = "blob.bin";
const full = path.join(dir, fileRel);
fs.writeFileSync(full, "hello", "utf8");
const wrongHash = "0".repeat(64);
const manifest = { files: { [fileRel]: wrongHash } };
fs.writeFileSync(path.join(dir, "backend-manifest.json"), JSON.stringify(manifest), "utf8");
const r = verifyBackendIntegrity(dir);
expect(r.ok).toBe(false);
expect(r.issues.some((i) => i.includes("Modified:"))).toBe(true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("accepts matching manifest", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcx-int-"));
try {
const fileRel = "blob.bin";
const full = path.join(dir, fileRel);
const data = Buffer.from("integrity-test", "utf8");
fs.writeFileSync(full, data);
const hash = crypto.createHash("sha256").update(data).digest("hex");
const manifest = {
files: { [fileRel]: hash },
_metadata: { date: "2020-01-01", time: "12:00:00" },
};
fs.writeFileSync(path.join(dir, "backend-manifest.json"), JSON.stringify(manifest), "utf8");
const r = verifyBackendIntegrity(dir);
expect(r.ok).toBe(true);
expect(r.issues).toHaveLength(0);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const notice = require("../../electron/loadingStatusNotice.js");
describe("electron/loadingStatusNotice", () => {
it("classifyConnectionIssue prefers backend-exited when runtime not running", () => {
const r = notice.classifyConnectionIssue([], {
running: false,
lastExitCode: 1,
});
expect(r.reason).toBe("backend-exited");
});
it("classifyConnectionIssue flags loopback blocked", () => {
const r = notice.classifyConnectionIssue([{ kind: "address-unreachable" }], { running: true });
expect(r.reason).toBe("loopback-blocked");
});
it("classifyConnectionIssue uses starting before network threshold", () => {
const r = notice.classifyConnectionIssue([{ kind: "network-error" }], { running: true }, { attemptCount: 1 });
expect(r.reason).toBe("starting");
});
it("classifyFetchError maps address unreachable", () => {
expect(notice.classifyFetchError({ name: "Error", message: "net::ERR_ADDRESS_UNREACHABLE" })).toBe(
"address-unreachable"
);
});
it("classifyFetchError defaults to network-error", () => {
expect(notice.classifyFetchError({ name: "TypeError", message: "fetch failed" })).toBe("network-error");
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const {
getUserProvidedArguments,
formatRenderProcessGoneDetails,
isLocalBackendUrl,
} = require("../../electron/mainHelpers.js");
describe("electron/mainHelpers", () => {
it("getUserProvidedArguments filters ignored flags and skips argv[0]", () => {
const argv = ["/app/electron", "--no-https", "--no-sandbox", "--ozone-platform-hint=auto", "--port", "1"];
expect(getUserProvidedArguments(argv)).toEqual(["--no-https", "--port", "1"]);
});
it("formatRenderProcessGoneDetails handles null/undefined", () => {
expect(formatRenderProcessGoneDetails(null)).toBe("no details");
expect(formatRenderProcessGoneDetails(undefined)).toBe("no details");
});
it("formatRenderProcessGoneDetails serializes reason and exitCode", () => {
const s = formatRenderProcessGoneDetails({ reason: "crashed", exitCode: 5 });
expect(s).toContain("crashed");
expect(s).toContain("5");
});
it("isLocalBackendUrl matches localhost backends only", () => {
expect(isLocalBackendUrl("https://127.0.0.1:9337/api")).toBe(true);
expect(isLocalBackendUrl("http://localhost:9337/")).toBe(true);
expect(isLocalBackendUrl("https://example.com")).toBe(false);
expect(isLocalBackendUrl("")).toBe(false);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { createRequire } from "module";
import path from "path";
import { fileURLToPath } from "url";
import { afterEach, describe, expect, it, vi } from "vitest";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const preloadPath = path.resolve(__dirname, "../../electron/preload.js");
const rootRequire = createRequire(import.meta.url);
const nodeModule = rootRequire("module");
function loadPreloadWithElectronMock(mockElectron) {
const orig = nodeModule.prototype.require;
nodeModule.prototype.require = function patchedRequire(id) {
if (id === "electron") {
return mockElectron;
}
return orig.apply(this, arguments);
};
try {
delete rootRequire.cache[preloadPath];
rootRequire(preloadPath);
} finally {
nodeModule.prototype.require = orig;
}
}
describe("electron/preload", () => {
afterEach(() => {
delete rootRequire.cache[preloadPath];
});
it("registers contextBridge API and forwards invoke to ipcRenderer", async () => {
const exposeInMainWorld = vi.fn();
const invoke = vi.fn();
const on = vi.fn();
const mockElectron = {
contextBridge: { exposeInMainWorld },
ipcRenderer: { invoke, on },
};
loadPreloadWithElectronMock(mockElectron);
expect(exposeInMainWorld).toHaveBeenCalledWith("electron", expect.any(Object));
const api = exposeInMainWorld.mock.calls[0][1];
invoke.mockResolvedValueOnce("9.9.9");
await expect(api.appVersion()).resolves.toBe("9.9.9");
expect(invoke).toHaveBeenCalledWith("app-version");
invoke.mockResolvedValueOnce(true);
await expect(api.isHardwareAccelerationEnabled()).resolves.toBe(true);
expect(invoke).toHaveBeenCalledWith("is-hardware-acceleration-enabled");
api.showNotification("t", "b", true);
expect(invoke).toHaveBeenCalledWith("show-notification", { title: "t", body: "b", silent: true });
});
it("onProtocolLink registers ipc listener for open-protocol-link", () => {
const exposeInMainWorld = vi.fn();
const invoke = vi.fn();
const on = vi.fn();
loadPreloadWithElectronMock({
contextBridge: { exposeInMainWorld },
ipcRenderer: { invoke, on },
});
const api = exposeInMainWorld.mock.calls[0][1];
const cb = vi.fn();
api.onProtocolLink(cb);
const handler = on.mock.calls.find((c) => c[0] === "open-protocol-link")?.[1];
expect(handler).toEqual(expect.any(Function));
handler({}, "rns://x");
expect(cb).toHaveBeenCalledWith("rns://x");
});
it("subscribes to log channel on load", () => {
const exposeInMainWorld = vi.fn();
const on = vi.fn();
loadPreloadWithElectronMock({
contextBridge: { exposeInMainWorld },
ipcRenderer: { invoke: vi.fn(), on },
});
expect(on).toHaveBeenCalledWith("log", expect.any(Function));
});
});
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/electron/**/*.test.js"],
coverage: {
provider: "v8",
reporter: ["text", "json-summary"],
reportsDirectory: "./coverage-electron",
include: ["electron/**/*.js"],
exclude: ["electron/assets/**", "electron/main-legacy.js"],
},
},
});