diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b36724d..840e37f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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) diff --git a/.gitignore b/.gitignore index 4be4511..e2cb697 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ docker-compose.override.yml .coverage coverage/ +coverage-electron/ test-results/ playwright-report/ diff --git a/Makefile b/Makefile index 28833ff..a4c360c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/Taskfile.yml b/Taskfile.yml index f475b1f..d130584 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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) diff --git a/electron/backendIntegrity.js b/electron/backendIntegrity.js new file mode 100644 index 0000000..a6d48c0 --- /dev/null +++ b/electron/backendIntegrity.js @@ -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 }; diff --git a/electron/main.js b/electron/main.js index 50d31a0..483ec6b 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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(", ")}`); } diff --git a/electron/mainHelpers.js b/electron/mainHelpers.js new file mode 100644 index 0000000..94f239e --- /dev/null +++ b/electron/mainHelpers.js @@ -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, +}; diff --git a/package.json b/package.json index b45cade..4e77cd7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/backend/test_announce_handler.py b/tests/backend/test_announce_handler.py new file mode 100644 index 0000000..dfd2874 --- /dev/null +++ b/tests/backend/test_announce_handler.py @@ -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") diff --git a/tests/backend/test_performance_bottlenecks.py b/tests/backend/test_performance_bottlenecks.py index 1a075b2..bee75b8 100644 --- a/tests/backend/test_performance_bottlenecks.py +++ b/tests/backend/test_performance_bottlenecks.py @@ -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 diff --git a/tests/backend/test_performance_hotpaths.py b/tests/backend/test_performance_hotpaths.py index f367659..baf7170 100644 --- a/tests/backend/test_performance_hotpaths.py +++ b/tests/backend/test_performance_hotpaths.py @@ -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 diff --git a/tests/electron/backendIntegrity.test.js b/tests/electron/backendIntegrity.test.js new file mode 100644 index 0000000..f259a73 --- /dev/null +++ b/tests/electron/backendIntegrity.test.js @@ -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 }); + } + }); +}); diff --git a/tests/electron/loadingStatusNotice.test.js b/tests/electron/loadingStatusNotice.test.js new file mode 100644 index 0000000..c3a306a --- /dev/null +++ b/tests/electron/loadingStatusNotice.test.js @@ -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"); + }); +}); diff --git a/tests/electron/mainHelpers.test.js b/tests/electron/mainHelpers.test.js new file mode 100644 index 0000000..df214ed --- /dev/null +++ b/tests/electron/mainHelpers.test.js @@ -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); + }); +}); diff --git a/tests/electron/preload.test.js b/tests/electron/preload.test.js new file mode 100644 index 0000000..ccb7711 --- /dev/null +++ b/tests/electron/preload.test.js @@ -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)); + }); +}); diff --git a/vitest.electron.config.js b/vitest.electron.config.js new file mode 100644 index 0000000..d39f44a --- /dev/null +++ b/vitest.electron.config.js @@ -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"], + }, + }, +});