diff --git a/tests/e2e/keyboard-shortcuts.spec.js b/tests/e2e/keyboard-shortcuts.spec.js
new file mode 100644
index 0000000..a8b6617
--- /dev/null
+++ b/tests/e2e/keyboard-shortcuts.spec.js
@@ -0,0 +1,79 @@
+const { test, expect } = require("@playwright/test");
+const { prepareE2eSession, dismissMapOnboardingTooltip } = require("./helpers");
+
+test.describe("Keyboard shortcuts (global)", () => {
+ test.beforeEach(async ({ request }) => {
+ await prepareE2eSession(request);
+ });
+
+ test("Alt+2 opens Nomad Network", async ({ page }) => {
+ await page.goto("/#/messages");
+ await expect(page).toHaveURL(/#\/messages/);
+ await page.keyboard.press("Alt+2");
+ await expect(page).toHaveURL(/#\/nomadnetwork/, { timeout: 15000 });
+ });
+
+ test("Alt+3 opens Map", async ({ page }) => {
+ await page.goto("/#/messages");
+ await page.evaluate(() => {
+ localStorage.setItem("map_onboarding_seen", "true");
+ });
+ await page.keyboard.press("Alt+3");
+ await expect(page).toHaveURL(/#\/map/, { timeout: 15000 });
+ await dismissMapOnboardingTooltip(page);
+ });
+
+ test("Alt+4 opens Archives", async ({ page }) => {
+ await page.goto("/#/messages");
+ await page.keyboard.press("Alt+4");
+ await expect(page).toHaveURL(/#\/archives/, { timeout: 15000 });
+ });
+
+ test("Alt+5 opens Calls", async ({ page }) => {
+ await page.goto("/#/messages");
+ await page.keyboard.press("Alt+5");
+ await expect(page).toHaveURL(/#\/call/, { timeout: 15000 });
+ });
+
+ test("Alt+P opens Paper Message tool", async ({ page }) => {
+ await page.goto("/#/messages");
+ await page.keyboard.press("Alt+p");
+ await expect(page).toHaveURL(/#\/tools\/paper-message/, { timeout: 15000 });
+ await expect(page.getByRole("heading", { name: "Paper Message Generator", exact: true })).toBeVisible({
+ timeout: 20000,
+ });
+ });
+
+ test("Alt+N goes to Messages (compose flow)", async ({ page }) => {
+ await page.goto("/#/settings");
+ await expect(page).toHaveURL(/#\/settings/);
+ await page.keyboard.press("Alt+n");
+ await expect(page).toHaveURL(/#\/messages/, { timeout: 15000 });
+ });
+
+ test("Ctrl+B toggles sidebar collapsed width", async ({ page }) => {
+ await page.goto("/#/messages");
+ const classBefore = await page.evaluate(() => {
+ const ul = document.querySelector("ul.py-3");
+ const el = ul && ul.closest(".fixed");
+ return el ? el.className : "";
+ });
+ expect(classBefore).toMatch(/w-80|md:max-lg:w-64|lg:w-80/);
+
+ await page.keyboard.press("Control+b");
+ const classAfterCollapse = await page.evaluate(() => {
+ const ul = document.querySelector("ul.py-3");
+ const el = ul && ul.closest(".fixed");
+ return el ? el.className : "";
+ });
+ expect(classAfterCollapse).toContain("w-16");
+
+ await page.keyboard.press("Control+b");
+ const classAfterExpand = await page.evaluate(() => {
+ const ul = document.querySelector("ul.py-3");
+ const el = ul && ul.closest(".fixed");
+ return el ? el.className : "";
+ });
+ expect(classAfterExpand).toMatch(/w-80|md:max-lg:w-64|lg:w-80/);
+ });
+});
diff --git a/tests/e2e/messages-conversation-scroll.spec.js b/tests/e2e/messages-conversation-scroll.spec.js
new file mode 100644
index 0000000..48f36cd
--- /dev/null
+++ b/tests/e2e/messages-conversation-scroll.spec.js
@@ -0,0 +1,203 @@
+const { test, expect } = require("@playwright/test");
+const {
+ prepareE2eSession,
+ seedE2eLongConversationThread,
+ getE2eLocalLxmfHash,
+ E2E_SCROLL_PEER_HASH,
+} = require("./helpers");
+
+async function scrollMetrics(page) {
+ const loc = page.locator("#messages");
+ await expect(loc).toBeVisible({ timeout: 30000 });
+ return loc.evaluate((el) => ({
+ scrollTop: el.scrollTop,
+ scrollHeight: el.scrollHeight,
+ clientHeight: el.clientHeight,
+ }));
+}
+
+async function setMessagesToBottom(page) {
+ await page.locator("#messages").evaluate((el) => {
+ const inner = el.firstElementChild;
+ const reverse = inner && getComputedStyle(inner).flexDirection === "column-reverse";
+ if (reverse) {
+ el.scrollTop = 0;
+ return;
+ }
+ el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
+ });
+}
+
+async function messagesDistanceFromBottom(page) {
+ return page.locator("#messages").evaluate((el) => {
+ const inner = el.firstElementChild;
+ const reverse = inner && getComputedStyle(inner).flexDirection === "column-reverse";
+ const max = Math.max(0, el.scrollHeight - el.clientHeight);
+ if (reverse) {
+ return el.scrollTop;
+ }
+ return max - el.scrollTop;
+ });
+}
+
+async function messagesNearBottom(page) {
+ return page.locator("#messages").evaluate((el) => {
+ const inner = el.firstElementChild;
+ const reverse = inner && getComputedStyle(inner).flexDirection === "column-reverse";
+ const max = Math.max(0, el.scrollHeight - el.clientHeight);
+ const eps = 12;
+ if (reverse) {
+ return el.scrollTop <= eps;
+ }
+ return max - el.scrollTop <= eps;
+ });
+}
+
+async function waitForMessagesOverflow(page) {
+ await page.waitForFunction(
+ () => {
+ const el = document.getElementById("messages");
+ if (!el) {
+ return false;
+ }
+ return el.scrollHeight > el.clientHeight + 100;
+ },
+ null,
+ { timeout: 30000 }
+ );
+}
+
+test.describe("Messages conversation scroll", () => {
+ test.use({ viewport: { width: 1280, height: 360 } });
+
+ test.beforeAll(async ({ request }) => {
+ await prepareE2eSession(request);
+ await seedE2eLongConversationThread(request, { messageCount: 120 });
+ });
+
+ test.beforeEach(async ({ request }) => {
+ await prepareE2eSession(request);
+ });
+
+ 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 expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
+ await expect(page.locator("#messages").getByText(/E2E scroll seed 119/).first()).toBeVisible({
+ timeout: 25000,
+ });
+
+ await waitForMessagesOverflow(page);
+ const m = await scrollMetrics(page);
+ expect(m.scrollHeight).toBeGreaterThan(m.clientHeight + 80);
+ expect(await messagesNearBottom(page)).toBe(true);
+ });
+
+ test("does not jump to bottom when scrolled up and inbound arrives", async ({ page, request }) => {
+ 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 expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
+ await waitForMessagesOverflow(page);
+
+ await page.locator("#messages").evaluate(() => {
+ const el = document.getElementById("messages");
+ if (!el) {
+ return;
+ }
+ const max = Math.max(0, el.scrollHeight - el.clientHeight);
+ el.scrollTop = Math.max(0, max - 200);
+ });
+ await page.waitForTimeout(300);
+ const before = await scrollMetrics(page);
+ expect(await messagesNearBottom(page)).toBe(false);
+
+ await page.evaluate(
+ ({ peerHash, localHash: lh }) => {
+ const buf = new Uint8Array(16);
+ window.crypto.getRandomValues(buf);
+ const hash = Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
+ return import("/js/WebSocketConnection.js").then((mod) => {
+ mod.default.emit("message", {
+ data: JSON.stringify({
+ type: "lxmf.delivery",
+ lxmf_message: {
+ hash,
+ source_hash: peerHash,
+ destination_hash: lh,
+ content: "E2E synthetic inbound (scroll up)",
+ timestamp: Math.floor(Date.now() / 1000),
+ },
+ }),
+ });
+ });
+ },
+ { peerHash: E2E_SCROLL_PEER_HASH, localHash }
+ );
+
+ await page.waitForTimeout(500);
+ expect(await messagesNearBottom(page)).toBe(false);
+ });
+
+ test("stays pinned to bottom when new inbound arrives while at bottom", async ({ page, request }) => {
+ 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 expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
+ await waitForMessagesOverflow(page);
+
+ await setMessagesToBottom(page);
+ await page.waitForTimeout(200);
+ expect(await messagesNearBottom(page)).toBe(true);
+
+ await page.evaluate(
+ ({ peerHash, localHash: lh }) => {
+ const buf = new Uint8Array(16);
+ window.crypto.getRandomValues(buf);
+ const hash = Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
+ return import("/js/WebSocketConnection.js").then((mod) => {
+ mod.default.emit("message", {
+ data: JSON.stringify({
+ type: "lxmf.delivery",
+ lxmf_message: {
+ hash,
+ source_hash: peerHash,
+ destination_hash: lh,
+ content: "E2E synthetic inbound (at bottom)",
+ timestamp: Math.floor(Date.now() / 1000),
+ },
+ }),
+ });
+ });
+ },
+ { peerHash: E2E_SCROLL_PEER_HASH, localHash }
+ );
+
+ await page.waitForTimeout(600);
+ expect(await messagesNearBottom(page)).toBe(true);
+ });
+
+ 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 expect(page.locator("#messages")).toBeVisible({ timeout: 25000 });
+ await waitForMessagesOverflow(page);
+
+ const initial = await scrollMetrics(page);
+ expect(initial.scrollHeight).toBeGreaterThan(initial.clientHeight + 80);
+
+ await page.locator("#messages").hover();
+ for (let i = 0; i < 60; i++) {
+ await page.mouse.wheel(0, -500);
+ }
+ await page.waitForTimeout(2500);
+ const loaded = await scrollMetrics(page);
+ if (loaded.scrollHeight > initial.scrollHeight) {
+ expect(await messagesDistanceFromBottom(page)).toBeGreaterThan(8);
+ }
+ });
+});
diff --git a/tests/e2e/navigation.spec.js b/tests/e2e/navigation.spec.js
index 69c923a..de5440b 100644
--- a/tests/e2e/navigation.spec.js
+++ b/tests/e2e/navigation.spec.js
@@ -1,5 +1,10 @@
const { test, expect } = require("@playwright/test");
-const { PALETTE_PLACEHOLDER, dismissMapOnboardingTooltip, openCommandPalette, prepareE2eSession } = require("./helpers");
+const {
+ PALETTE_PLACEHOLDER,
+ dismissMapOnboardingTooltip,
+ openCommandPalette,
+ prepareE2eSession,
+} = require("./helpers");
test.describe("Getting started (tutorial page)", () => {
test("tutorial route shows welcome copy", async ({ page }) => {
@@ -102,15 +107,20 @@ test.describe("Sidebar and keyboard navigation", () => {
await expect(page.getByText("MeshChatX", { exact: true }).first()).toBeVisible({ timeout: 20000 });
});
- test("Alt+1 jumps to Messages from another route", async ({ page }) => {
+ test("Alt+1 jumps to Messages from another route", async ({ page, request }) => {
+ await prepareE2eSession(request);
await page.goto("/#/contacts");
await expect(page).toHaveURL(/#\/contacts/);
await page.keyboard.press("Alt+1");
await expect(page).toHaveURL(/#\/messages/, { timeout: 15000 });
});
- test("Alt+S opens Settings", async ({ page }) => {
+ test("Alt+S opens Settings", async ({ page, request }) => {
+ await prepareE2eSession(request);
await page.goto("/#/map");
+ await page.evaluate(() => {
+ localStorage.setItem("map_onboarding_seen", "true");
+ });
await expect(page).toHaveURL(/#\/map/);
await page.keyboard.press("Alt+s");
await expect(page).toHaveURL(/#\/settings/, { timeout: 15000 });
diff --git a/tests/frontend/ConversationViewer.scroll.test.js b/tests/frontend/ConversationViewer.scroll.test.js
new file mode 100644
index 0000000..24bdcf9
--- /dev/null
+++ b/tests/frontend/ConversationViewer.scroll.test.js
@@ -0,0 +1,125 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import ConversationViewer from "@/components/messages/ConversationViewer.vue";
+import WebSocketConnection from "@/js/WebSocketConnection";
+import GlobalState from "@/js/GlobalState";
+
+vi.mock("@/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn(() => Promise.resolve(true)),
+ },
+}));
+
+function makeMessagesScrollTarget({ reverse, scrollTop, scrollHeight, clientHeight }) {
+ const outer = document.createElement("div");
+ const inner = document.createElement("div");
+ inner.style.flexDirection = reverse ? "column-reverse" : "column";
+ outer.appendChild(inner);
+ document.body.appendChild(outer);
+ Object.defineProperty(outer, "scrollHeight", { value: scrollHeight, configurable: true });
+ Object.defineProperty(outer, "clientHeight", { value: clientHeight, configurable: true });
+ outer.scrollTop = scrollTop;
+ return outer;
+}
+
+describe("ConversationViewer.vue scroll behavior", () => {
+ beforeEach(() => {
+ GlobalState.config.theme = "light";
+ WebSocketConnection.connect();
+ window.api = {
+ get: vi.fn().mockImplementation((url) => {
+ if (url.includes("/path")) return Promise.resolve({ data: { path: [] } });
+ if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } });
+ if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } });
+ return Promise.resolve({ data: {} });
+ }),
+ post: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ });
+
+ afterEach(() => {
+ delete window.api;
+ WebSocketConnection.destroy();
+ });
+
+ const mountViewer = () =>
+ mount(ConversationViewer, {
+ props: {
+ selectedPeer: { destination_hash: "abcdabcdabcdabcdabcdabcdabcdabcd", display_name: "Peer" },
+ myLxmfAddressHash: "myhashmyhashmyhashmyhashmyhashmyha",
+ conversations: [],
+ },
+ global: {
+ directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
+ mocks: { $t: (key) => key },
+ stubs: {
+ MaterialDesignIcon: true,
+ AddImageButton: true,
+ AddAudioButton: true,
+ SendMessageButton: true,
+ ConversationDropDownMenu: true,
+ PaperMessageModal: true,
+ AudioWaveformPlayer: true,
+ LxmfUserIcon: true,
+ },
+ },
+ });
+
+ it("onMessagesScroll sets autoScrollOnNewMessage when near bottom (column-reverse)", () => {
+ const wrapper = mountViewer();
+ const el = makeMessagesScrollTarget({
+ reverse: true,
+ scrollTop: 0,
+ scrollHeight: 5000,
+ clientHeight: 100,
+ });
+ wrapper.vm.onMessagesScroll({ target: el });
+ expect(wrapper.vm.autoScrollOnNewMessage).toBe(true);
+ el.remove();
+ });
+
+ it("onMessagesScroll clears autoScroll when not near bottom (column-reverse)", () => {
+ const wrapper = mountViewer();
+ const el = makeMessagesScrollTarget({
+ reverse: true,
+ scrollTop: 2000,
+ scrollHeight: 5000,
+ clientHeight: 100,
+ });
+ wrapper.vm.onMessagesScroll({ target: el });
+ expect(wrapper.vm.autoScrollOnNewMessage).toBe(false);
+ el.remove();
+ });
+
+ it("onMessagesScroll calls loadPrevious when near older-history edge (column-reverse)", () => {
+ const wrapper = mountViewer();
+ const spy = vi.spyOn(wrapper.vm, "loadPrevious").mockImplementation(() => {});
+ const el = makeMessagesScrollTarget({
+ reverse: true,
+ scrollTop: 4450,
+ scrollHeight: 5000,
+ clientHeight: 100,
+ });
+ wrapper.vm.onMessagesScroll({ target: el });
+ expect(spy).toHaveBeenCalledTimes(1);
+ wrapper.vm.onMessagesScroll({ target: el });
+ expect(spy).toHaveBeenCalledTimes(1);
+ el.remove();
+ });
+
+ it("onMessagesScroll does not hammer loadPrevious while held at older-history edge", () => {
+ const wrapper = mountViewer();
+ const spy = vi.spyOn(wrapper.vm, "loadPrevious").mockImplementation(() => {});
+ const el = makeMessagesScrollTarget({
+ reverse: true,
+ scrollTop: 4450,
+ scrollHeight: 5000,
+ clientHeight: 100,
+ });
+ wrapper.vm.onMessagesScroll({ target: el });
+ wrapper.vm.onMessagesScroll({ target: el });
+ wrapper.vm.onMessagesScroll({ target: el });
+ expect(spy).toHaveBeenCalledTimes(1);
+ el.remove();
+ });
+});
diff --git a/tests/frontend/ConversationViewerPerformance.baseline.test.js b/tests/frontend/ConversationViewerPerformance.baseline.test.js
new file mode 100644
index 0000000..57f2da0
--- /dev/null
+++ b/tests/frontend/ConversationViewerPerformance.baseline.test.js
@@ -0,0 +1,180 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import ConversationViewer from "@/components/messages/ConversationViewer.vue";
+
+vi.mock("@/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn(() => Promise.resolve(true)),
+ },
+}));
+
+vi.mock("@/js/WebSocketConnection", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ connect: vi.fn(),
+ destroy: vi.fn(),
+ },
+}));
+
+vi.mock("@/js/GlobalEmitter", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ },
+}));
+
+function makeChatItems(count, myHash, peerHash) {
+ return Array.from({ length: count }, (_, i) => ({
+ type: "lxmf_message",
+ is_outbound: i % 2 === 0,
+ lxmf_message: {
+ hash: `msg_${i}`.padEnd(32, "0"),
+ source_hash: i % 2 === 0 ? myHash : peerHash,
+ destination_hash: i % 2 === 0 ? peerHash : myHash,
+ content: `Message content ${i}.`,
+ created_at: new Date().toISOString(),
+ state: "delivered",
+ method: "direct",
+ progress: 1.0,
+ delivery_attempts: 1,
+ id: i,
+ },
+ }));
+}
+
+function groupSignature(vm) {
+ return vm.selectedPeerChatDisplayGroups.map((g) => `${g.type}:${g.key}`);
+}
+
+describe("ConversationViewer performance baselines", () => {
+ const myLxmfAddressHash = "my_hash".padEnd(32, "0");
+ const peerHash = "peer_hash".padEnd(32, "0");
+ const selectedPeer = {
+ destination_hash: peerHash,
+ display_name: "Peer Name",
+ };
+
+ beforeEach(() => {
+ window.api = {
+ get: vi.fn(() => Promise.resolve({ data: {} })),
+ post: vi.fn(() => Promise.resolve({ data: {} })),
+ };
+ });
+
+ const mountViewer = () =>
+ mount(ConversationViewer, {
+ props: {
+ myLxmfAddressHash,
+ selectedPeer,
+ conversations: [selectedPeer],
+ config: { theme: "light", lxmf_address_hash: myLxmfAddressHash },
+ },
+ global: {
+ components: {
+ MaterialDesignIcon: { template: "" },
+ ConversationDropDownMenu: { template: "
" },
+ SendMessageButton: { template: "" },
+ IconButton: { template: "" },
+ AddImageButton: { template: "" },
+ AddAudioButton: { template: "" },
+ PaperMessageModal: { template: "" },
+ AudioWaveformPlayer: { template: "" },
+ LxmfUserIcon: { template: "" },
+ },
+ directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
+ mocks: {
+ $t: (key) => key,
+ $i18n: { locale: "en" },
+ },
+ stubs: {
+ MarkdownRenderer: true,
+ },
+ },
+ });
+
+ it("selectedPeerChatDisplayGroups signature is stable for a fixed thread (regression guard)", async () => {
+ const wrapper = mountViewer();
+ const items = makeChatItems(24, myLxmfAddressHash, peerHash);
+ await wrapper.setData({ chatItems: items });
+ await wrapper.vm.$nextTick();
+ const sig = groupSignature(wrapper.vm);
+ expect(sig.length).toBe(24);
+ 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);
+
+ 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
+ );
+
+ 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 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
+ );
+
+ 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;
+
+ 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
new file mode 100644
index 0000000..b78c64a
--- /dev/null
+++ b/tests/frontend/KeyboardShortcuts.test.js
@@ -0,0 +1,142 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
+
+const wsSend = vi.fn();
+
+vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
+ default: {
+ send: (...args) => wsSend(...args),
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ },
+}));
+
+import KeyboardShortcuts from "../../meshchatx/src/frontend/js/KeyboardShortcuts";
+
+function dispatchKeyDown(init) {
+ const ev = new KeyboardEvent("keydown", {
+ key: init.key,
+ code: init.code,
+ ctrlKey: !!init.ctrlKey,
+ altKey: !!init.altKey,
+ shiftKey: !!init.shiftKey,
+ metaKey: !!init.metaKey,
+ bubbles: true,
+ cancelable: true,
+ });
+ window.dispatchEvent(ev);
+ return ev;
+}
+
+describe("KeyboardShortcuts", () => {
+ let emitSpy;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ emitSpy = vi.spyOn(GlobalEmitter, "emit");
+ document.body.innerHTML = "";
+ if (document.activeElement && document.activeElement !== document.body) {
+ document.activeElement.blur();
+ }
+ });
+
+ afterEach(() => {
+ KeyboardShortcuts.setShortcuts(KeyboardShortcuts.getDefaultShortcuts());
+ });
+
+ it("emits nav_messages for Alt+1", () => {
+ dispatchKeyDown({ key: "1", altKey: true, code: "Digit1" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_messages");
+ });
+
+ it("emits nav_nomad for Alt+2", () => {
+ dispatchKeyDown({ key: "2", altKey: true, code: "Digit2" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_nomad");
+ });
+
+ it("emits nav_map for Alt+3", () => {
+ dispatchKeyDown({ key: "3", altKey: true, code: "Digit3" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_map");
+ });
+
+ it("emits nav_archives for Alt+4", () => {
+ dispatchKeyDown({ key: "4", altKey: true, code: "Digit4" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_archives");
+ });
+
+ it("emits nav_calls for Alt+5", () => {
+ dispatchKeyDown({ key: "5", altKey: true, code: "Digit5" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_calls");
+ });
+
+ it("emits nav_paper for Alt+P", () => {
+ dispatchKeyDown({ key: "p", altKey: true, code: "KeyP" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_paper");
+ });
+
+ it("emits nav_settings for Alt+S", () => {
+ dispatchKeyDown({ key: "s", altKey: true, code: "KeyS" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_settings");
+ });
+
+ it("emits compose_message for Alt+N", () => {
+ dispatchKeyDown({ key: "n", altKey: true, code: "KeyN" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "compose_message");
+ });
+
+ it("emits sync_messages for Alt+R", () => {
+ dispatchKeyDown({ key: "r", altKey: true, code: "KeyR" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "sync_messages");
+ });
+
+ it("emits toggle_sidebar for Ctrl+B", () => {
+ dispatchKeyDown({ key: "b", ctrlKey: true, code: "KeyB" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "toggle_sidebar");
+ });
+
+ it("does not emit command_palette from KeyboardShortcuts (handled by CommandPalette)", () => {
+ dispatchKeyDown({ key: "k", ctrlKey: true, code: "KeyK" });
+ expect(emitSpy).not.toHaveBeenCalledWith("keyboard-shortcut", "command_palette");
+ });
+
+ it("allows Alt+digit navigation while focus is in an input (modifier shortcuts)", () => {
+ const input = document.createElement("input");
+ document.body.appendChild(input);
+ input.focus();
+
+ dispatchKeyDown({ key: "2", altKey: true, code: "Digit2" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_nomad");
+ });
+
+ it("setShortcuts replaces shortcuts and still emits for updated actions", () => {
+ KeyboardShortcuts.setShortcuts([
+ { action: "nav_messages", keys: ["alt", "9"] },
+ ...KeyboardShortcuts.getDefaultShortcuts().filter((s) => s.action !== "nav_messages"),
+ ]);
+ dispatchKeyDown({ key: "9", altKey: true, code: "Digit9" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_messages");
+ KeyboardShortcuts.setShortcuts(KeyboardShortcuts.getDefaultShortcuts());
+ });
+
+ it("saveShortcut sends keyboard_shortcuts.set over WebSocket", async () => {
+ await KeyboardShortcuts.saveShortcut("nav_messages", ["alt", "q"]);
+ expect(wsSend).toHaveBeenCalledWith(
+ JSON.stringify({
+ type: "keyboard_shortcuts.set",
+ action: "nav_messages",
+ keys: ["alt", "q"],
+ }),
+ );
+ });
+
+ it("deleteShortcut sends keyboard_shortcuts.delete over WebSocket", async () => {
+ await KeyboardShortcuts.deleteShortcut("nav_map");
+ expect(wsSend).toHaveBeenCalledWith(
+ 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
new file mode 100644
index 0000000..9c8c79c
--- /dev/null
+++ b/tests/frontend/SettingsPage.config-persistence.test.js
@@ -0,0 +1,787 @@
+import { mount, flushPromises } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import SettingsPage from "../../meshchatx/src/frontend/components/settings/SettingsPage.vue";
+import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
+import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
+import GlobalState from "../../meshchatx/src/frontend/js/GlobalState";
+import WebSocketConnection from "../../meshchatx/src/frontend/js/WebSocketConnection";
+
+vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ send: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ loading: vi.fn(),
+ dismiss: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn().mockResolvedValue(true),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/KeyboardShortcuts", () => ({
+ default: {
+ getDefaultShortcuts: vi.fn(() => []),
+ send: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
+ default: {
+ isElectron: vi.fn(() => false),
+ },
+}));
+
+/**
+ * Mirrors meshchat `get_config_dict` keys used by SettingsPage so PATCH merges stay realistic.
+ */
+function buildFullServerConfig(overrides = {}) {
+ return {
+ display_name: "Test User",
+ identity_hash: "abc123",
+ identity_public_key: "00",
+ lxmf_address_hash: "def456",
+ telephone_address_hash: null,
+ is_transport_enabled: false,
+ auto_announce_enabled: false,
+ auto_announce_interval_seconds: 0,
+ last_announced_at: null,
+ theme: "dark",
+ language: "en",
+ auto_resend_failed_messages_when_announce_received: true,
+ allow_auto_resending_failed_messages_with_attachments: false,
+ auto_send_failed_messages_to_propagation_node: false,
+ show_suggested_community_interfaces: true,
+ lxmf_delivery_transfer_limit_in_bytes: 10_000_000,
+ lxmf_propagation_transfer_limit_in_bytes: 256_000,
+ lxmf_propagation_sync_limit_in_bytes: 10_240_000,
+ lxmf_local_propagation_node_enabled: false,
+ lxmf_local_propagation_node_address_hash: "localhash",
+ lxmf_preferred_propagation_node_destination_hash: "",
+ lxmf_preferred_propagation_node_auto_select: false,
+ lxmf_preferred_propagation_node_auto_sync_interval_seconds: 3600,
+ lxmf_preferred_propagation_node_last_synced_at: null,
+ lxmf_user_icon_name: "account",
+ lxmf_user_icon_foreground_colour: "#111827",
+ lxmf_user_icon_background_colour: "#e5e7eb",
+ lxmf_inbound_stamp_cost: 8,
+ lxmf_propagation_node_stamp_cost: 16,
+ page_archiver_enabled: false,
+ page_archiver_max_versions: 5,
+ archives_max_storage_gb: 1,
+ backup_max_count: 5,
+ crawler_enabled: false,
+ crawler_max_retries: 3,
+ crawler_retry_delay_seconds: 30,
+ crawler_max_concurrent: 2,
+ auth_enabled: false,
+ voicemail_enabled: false,
+ voicemail_greeting: "",
+ voicemail_auto_answer_delay_seconds: 0,
+ voicemail_max_recording_seconds: 120,
+ voicemail_tts_speed: 1,
+ voicemail_tts_pitch: 1,
+ voicemail_tts_voice: "",
+ voicemail_tts_word_gap: 0,
+ custom_ringtone_enabled: false,
+ ringtone_filename: "",
+ ringtone_preferred_id: null,
+ ringtone_volume: 50,
+ map_offline_enabled: false,
+ map_mbtiles_dir: "",
+ map_tile_cache_enabled: true,
+ map_default_lat: "0",
+ map_default_lon: "0",
+ map_default_zoom: 2,
+ map_tile_server_url: "",
+ map_nominatim_api_url: "",
+ do_not_disturb_enabled: false,
+ telephone_allow_calls_from_contacts_only: false,
+ telephone_audio_profile_id: null,
+ telephone_web_audio_enabled: true,
+ telephone_web_audio_allow_fallback: true,
+ call_recording_enabled: false,
+ block_attachments_from_strangers: true,
+ block_all_from_strangers: false,
+ show_unknown_contact_banner: true,
+ banished_effect_enabled: true,
+ banished_text: "BANISHED",
+ banished_color: "#dc2626",
+ message_font_size: 14,
+ message_icon_size: 28,
+ ui_transparency: 0,
+ ui_glass_enabled: true,
+ message_outbound_bubble_color: "#4f46e5",
+ message_inbound_bubble_color: null,
+ message_failed_bubble_color: "#ef4444",
+ message_waiting_bubble_color: "#e5e7eb",
+ translator_enabled: false,
+ libretranslate_url: "http://localhost:5000",
+ desktop_open_calls_in_separate_window: false,
+ desktop_hardware_acceleration_enabled: true,
+ blackhole_integration_enabled: true,
+ announce_max_stored_lxmf_delivery: 1000,
+ announce_max_stored_nomadnetwork_node: 1000,
+ announce_max_stored_lxmf_propagation: 1000,
+ announce_fetch_limit_lxmf_delivery: 500,
+ announce_fetch_limit_nomadnetwork_node: 500,
+ announce_fetch_limit_lxmf_propagation: 500,
+ announce_search_max_fetch: 2000,
+ discovered_interfaces_max_return: 500,
+ csp_extra_connect_src: "",
+ csp_extra_img_src: "",
+ csp_extra_frame_src: "",
+ csp_extra_script_src: "",
+ csp_extra_style_src: "",
+ telephone_tone_generator_enabled: true,
+ telephone_tone_generator_volume: 50,
+ location_source: "browser",
+ location_manual_lat: "0.0",
+ location_manual_lon: "0.0",
+ location_manual_alt: "0.0",
+ telemetry_enabled: false,
+ nomad_render_markdown_enabled: true,
+ nomad_render_html_enabled: true,
+ nomad_render_plaintext_enabled: true,
+ nomad_default_page_path: "/page/index.mu",
+ gitea_base_url: "https://git.quad4.io",
+ docs_download_urls: "",
+ ...overrides,
+ };
+}
+
+function createWindowApi(serverConfigRef) {
+ return {
+ get: vi.fn().mockImplementation((url) => {
+ if (String(url).includes("/api/v1/config")) {
+ return Promise.resolve({ data: { config: { ...serverConfigRef.current } } });
+ }
+ if (String(url).includes("/api/v1/telemetry/trusted-peers")) {
+ return Promise.resolve({ data: { trusted_peers: [] } });
+ }
+ if (String(url).includes("/api/v1/stickers/export")) {
+ return Promise.resolve({ data: { stickers: [] } });
+ }
+ if (String(url).includes("/api/v1/stickers") && !String(url).includes("export")) {
+ return Promise.resolve({ data: { stickers: [] } });
+ }
+ if (String(url).includes("/api/v1/maintenance/messages/export")) {
+ return Promise.resolve({ data: { messages: [] } });
+ }
+ if (String(url).includes("/api/v1/lxmf/folders/export")) {
+ return Promise.resolve({ data: { folders: [] } });
+ }
+ return Promise.resolve({ data: {} });
+ }),
+ patch: vi.fn().mockImplementation((url, body) => {
+ serverConfigRef.current = { ...serverConfigRef.current, ...body };
+ return Promise.resolve({ data: { config: { ...serverConfigRef.current } } });
+ }),
+ post: vi.fn().mockResolvedValue({ data: { message: "ok" } }),
+ delete: vi.fn().mockResolvedValue({ data: {} }),
+ };
+}
+
+async function mountSettingsPage(api, router = { push: vi.fn() }) {
+ window.api = api;
+ const wrapper = mount(SettingsPage, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: { template: "" },
+ Toggle,
+ ShortcutRecorder: { template: "" },
+ RouterLink: { template: "" },
+ },
+ mocks: {
+ $t: (key) => key,
+ $router: router,
+ },
+ },
+ });
+ await flushPromises();
+ await wrapper.vm.$nextTick();
+ return wrapper;
+}
+
+describe("SettingsPage — config persistence (PATCH and related)", () => {
+ let serverConfigRef;
+ let api;
+
+ beforeEach(() => {
+ serverConfigRef = { current: buildFullServerConfig() };
+ api = createWindowApi(serverConfigRef);
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ delete window.api;
+ vi.clearAllMocks();
+ });
+
+ it("onThemeChange PATCHes theme", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.theme = "light";
+ await w.vm.onThemeChange();
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { theme: "light" });
+ });
+
+ it("onLanguageChange PATCHes language", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.language = "de";
+ await w.vm.onLanguageChange();
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { language: "de" });
+ });
+
+ it("onMessageFontSizeChange PATCHes after debounce", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.message_font_size = 18;
+ await w.vm.onMessageFontSizeChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { message_font_size: 18 });
+ });
+
+ it("onDisplayNameChange PATCHes after debounce", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.display_name = "New Name";
+ await w.vm.onDisplayNameChange();
+ await vi.advanceTimersByTimeAsync(600);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { display_name: "New Name" });
+ });
+
+ it("onMessageIconSizeChange PATCHes after debounce", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.message_icon_size = 40;
+ await w.vm.onMessageIconSizeChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { message_icon_size: 40 });
+ });
+
+ it("onUiTransparencyChange PATCHes clamped value after debounce", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.ui_transparency = 77;
+ w.vm.onUiTransparencyChange();
+ await vi.advanceTimersByTimeAsync(400);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { ui_transparency: 77 });
+ });
+
+ it("onUiGlassEnabledChange PATCHes ui_glass_enabled", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.ui_glass_enabled = false;
+ await w.vm.onUiGlassEnabledChange();
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { ui_glass_enabled: false });
+ });
+
+ it("resetAppearanceDefaults PATCHes full appearance payload", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.resetAppearanceDefaults();
+ expect(api.patch).toHaveBeenCalledWith(
+ "/api/v1/config",
+ expect.objectContaining({
+ theme: "light",
+ message_font_size: 14,
+ message_icon_size: 28,
+ ui_transparency: 0,
+ ui_glass_enabled: true,
+ message_outbound_bubble_color: "#4f46e5",
+ message_inbound_bubble_color: null,
+ message_failed_bubble_color: "#ef4444",
+ message_waiting_bubble_color: "#e5e7eb",
+ }),
+ );
+ });
+
+ it("onMessageBubbleColorChange PATCHes outbound after debounce", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.message_outbound_bubble_color = "#112233";
+ await w.vm.onMessageBubbleColorChange("outbound");
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+ message_outbound_bubble_color: "#112233",
+ });
+ });
+
+ it("updateConfig can PATCH blackhole_integration_enabled (inline control)", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.blackhole_integration_enabled = false;
+ await w.vm.updateConfig({ blackhole_integration_enabled: false }, "blackhole_integration_enabled");
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { blackhole_integration_enabled: false });
+ });
+
+ it("onAnnounceLimitsChange PATCHes announce and discovery caps", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.announce_max_stored_lxmf_delivery = 900;
+ await w.vm.onAnnounceLimitsChange();
+ expect(api.patch).toHaveBeenCalledWith(
+ "/api/v1/config",
+ expect.objectContaining({
+ announce_max_stored_lxmf_delivery: 900,
+ discovered_interfaces_max_return: 500,
+ }),
+ );
+ });
+
+ it("message reliability toggles PATCH expected keys", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.onAutoResendFailedMessagesWhenAnnounceReceivedChange();
+ expect(api.patch).toHaveBeenCalledWith(
+ "/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,
+ }),
+ );
+ });
+
+ it("onShowSuggestedCommunityInterfacesChange PATCHes flag", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.show_suggested_community_interfaces = false;
+ await w.vm.onShowSuggestedCommunityInterfacesChange();
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+ show_suggested_community_interfaces: false,
+ });
+ });
+
+ it("onLxmfPreferredPropagationNodeDestinationHashChange PATCHes after debounce", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.lxmf_preferred_propagation_node_destination_hash = "deadbeef";
+ await w.vm.onLxmfPreferredPropagationNodeDestinationHashChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+ lxmf_preferred_propagation_node_destination_hash: "deadbeef",
+ });
+ });
+
+ it("onLxmfPreferredPropagationNodeAutoSelectChange PATCHes", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.lxmf_preferred_propagation_node_auto_select = true;
+ await w.vm.onLxmfPreferredPropagationNodeAutoSelectChange();
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+ lxmf_preferred_propagation_node_auto_select: true,
+ });
+ });
+
+ it("onLxmfLocalPropagationNodeEnabledChange PATCHes", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.onLxmfLocalPropagationNodeEnabledChange();
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+ lxmf_local_propagation_node_enabled: false,
+ });
+ });
+
+ it("onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange PATCHes", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds = 7200;
+ await w.vm.onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange();
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+ lxmf_preferred_propagation_node_auto_sync_interval_seconds: 7200,
+ });
+ });
+
+ it("LXMF transfer/sync limits PATCH after debounce", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.lxmf_delivery_transfer_limit_in_bytes = 9_000_000;
+ await w.vm.onLxmfDeliveryTransferLimitChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+ lxmf_delivery_transfer_limit_in_bytes: 9_000_000,
+ });
+
+ w.vm.config.lxmf_propagation_transfer_limit_in_bytes = 300_000;
+ await w.vm.onLxmfPropagationTransferLimitChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+ lxmf_propagation_transfer_limit_in_bytes: 300_000,
+ });
+
+ w.vm.config.lxmf_propagation_sync_limit_in_bytes = 9_000_000;
+ await w.vm.onLxmfPropagationSyncLimitChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+ lxmf_propagation_sync_limit_in_bytes: 9_000_000,
+ });
+ });
+
+ it("onLxmfInboundStampCostChange PATCHes after debounce", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.lxmf_inbound_stamp_cost = 12;
+ await w.vm.onLxmfInboundStampCostChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { lxmf_inbound_stamp_cost: 12 });
+ });
+
+ it("onInboundStampsEnabledChange(false) PATCHes zero stamp cost", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.lxmf_inbound_stamp_cost = 12;
+ await w.vm.onInboundStampsEnabledChange(false);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { lxmf_inbound_stamp_cost: 0 });
+ });
+
+ it("onInboundStampsEnabledChange(true) restores stamp cost", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.lastRememberedInboundStampCost = 16;
+ w.vm.config.lxmf_inbound_stamp_cost = 0;
+ await w.vm.onInboundStampsEnabledChange(true);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { lxmf_inbound_stamp_cost: 16 });
+ });
+
+ it("onLxmfPropagationNodeStampCostChange PATCHes after debounce", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.lxmf_propagation_node_stamp_cost = 20;
+ await w.vm.onLxmfPropagationNodeStampCostChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { lxmf_propagation_node_stamp_cost: 20 });
+ });
+
+ it("page archiver toggles and numeric config PATCH", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.onPageArchiverEnabledChangeWrapper(true);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { page_archiver_enabled: true });
+
+ w.vm.config.page_archiver_max_versions = 12;
+ w.vm.config.archives_max_storage_gb = 2;
+ await w.vm.onPageArchiverConfigChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+ page_archiver_max_versions: 12,
+ archives_max_storage_gb: 2,
+ });
+ });
+
+ it("Nomad renderer toggles and default path PATCH", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.onNomadRendererMarkdownToggle(false);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { nomad_render_markdown_enabled: false });
+ await w.vm.onNomadRendererHtmlToggle(false);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { nomad_render_html_enabled: false });
+ await w.vm.onNomadRendererPlaintextToggle(false);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { nomad_render_plaintext_enabled: false });
+ w.vm.config.nomad_default_page_path = "/page/custom.mu";
+ await w.vm.onNomadDefaultPagePathChange();
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { nomad_default_page_path: "/page/custom.mu" });
+ });
+
+ it("stranger protection PATCHes each flag", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.onStrangerAttachmentBlockChange(false);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { block_attachments_from_strangers: false });
+ await w.vm.onBlockAllFromStrangersChange(true);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { block_all_from_strangers: true });
+ await w.vm.onShowUnknownContactBannerChange(false);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { show_unknown_contact_banner: false });
+ });
+
+ it("banishment PATCHes toggle and debounced text/color", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.onBanishedEffectEnabledChange(false);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { banished_effect_enabled: false });
+ w.vm.config.banished_text = "OUT";
+ w.vm.config.banished_color = "#ff0000";
+ await w.vm.onBanishedConfigChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith(
+ "/api/v1/config",
+ expect.objectContaining({ banished_text: "OUT", banished_color: "#ff0000" }),
+ );
+ });
+
+ it("crawler enabled and debounced crawler fields PATCH", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.onCrawlerEnabledChange(true);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { crawler_enabled: true });
+ w.vm.config.crawler_max_retries = 5;
+ w.vm.config.crawler_retry_delay_seconds = 60;
+ w.vm.config.crawler_max_concurrent = 3;
+ await w.vm.onCrawlerConfigChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+ crawler_max_retries: 5,
+ crawler_retry_delay_seconds: 60,
+ crawler_max_concurrent: 3,
+ });
+ });
+
+ it("desktop toggles PATCH", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.onDesktopOpenCallsInSeparateWindowChange(true);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { desktop_open_calls_in_separate_window: true });
+ await w.vm.onDesktopHardwareAccelerationEnabledChange(false);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { desktop_hardware_acceleration_enabled: false });
+ });
+
+ it("onAuthEnabledChange PATCHes auth and does not push router when disabling", async () => {
+ const router = { push: vi.fn() };
+ const w = await mountSettingsPage(api, router);
+ await w.vm.onAuthEnabledChange(false);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { auth_enabled: false });
+ expect(router.push).not.toHaveBeenCalled();
+ });
+
+ it("onAuthEnabledChange pushes auth route when enabling", async () => {
+ const router = { push: vi.fn() };
+ const w = await mountSettingsPage(api, router);
+ await w.vm.onAuthEnabledChange(true);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { auth_enabled: true });
+ expect(router.push).toHaveBeenCalledWith({ name: "auth" });
+ });
+
+ it("translator toggle and debounced URL PATCH", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.onTranslatorEnabledChange(true);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { translator_enabled: true });
+ w.vm.config.libretranslate_url = "http://translate.example";
+ await w.vm.onTranslatorConfigChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+ libretranslate_url: "http://translate.example",
+ });
+ });
+
+ it("onGiteaConfigChange PATCHes after debounce", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.gitea_base_url = "https://gitea.example";
+ w.vm.config.docs_download_urls = "https://docs.example";
+ await w.vm.onGiteaConfigChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith(
+ "/api/v1/config",
+ expect.objectContaining({
+ gitea_base_url: "https://gitea.example",
+ docs_download_urls: "https://docs.example",
+ }),
+ );
+ });
+
+ it("onCspConfigChange PATCHes CSP fields after debounce", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.csp_extra_connect_src = "wss://a.example";
+ w.vm.config.csp_extra_img_src = "https://img.example";
+ w.vm.config.csp_extra_frame_src = "https://frame.example";
+ w.vm.config.csp_extra_script_src = "https://js.example";
+ w.vm.config.csp_extra_style_src = "https://css.example";
+ await w.vm.onCspConfigChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith(
+ "/api/v1/config",
+ expect.objectContaining({
+ csp_extra_connect_src: "wss://a.example",
+ csp_extra_style_src: "https://css.example",
+ }),
+ );
+ });
+
+ it("onBackupConfigChange PATCHes backup_max_count after debounce", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.backup_max_count = 8;
+ await w.vm.onBackupConfigChange();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { backup_max_count: 8 });
+ });
+
+ it("inline location and telemetry PATCH via updateConfig", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.location_source = "manual";
+ await w.vm.updateConfig({ location_source: "manual" }, "location_source");
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { location_source: "manual" });
+ await w.vm.updateConfig({ telemetry_enabled: true }, "telemetry");
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { telemetry_enabled: true });
+ });
+});
+
+describe("SettingsPage — transport mode (POST, not PATCH)", () => {
+ let serverConfigRef;
+ let api;
+
+ beforeEach(() => {
+ serverConfigRef = { current: buildFullServerConfig() };
+ api = createWindowApi(serverConfigRef);
+ });
+
+ afterEach(() => {
+ delete window.api;
+ vi.clearAllMocks();
+ });
+
+ it("onIsTransportEnabledChange POSTs enable when turning on", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.config.is_transport_enabled = true;
+ await w.vm.onIsTransportEnabledChange();
+ expect(api.post).toHaveBeenCalledWith("/api/v1/reticulum/enable-transport");
+ });
+
+ it("onIsTransportEnabledChange POSTs disable when turning off", async () => {
+ serverConfigRef.current = buildFullServerConfig({ is_transport_enabled: true });
+ const w = await mountSettingsPage(api);
+ w.vm.config.is_transport_enabled = false;
+ await w.vm.onIsTransportEnabledChange();
+ expect(api.post).toHaveBeenCalledWith("/api/v1/reticulum/disable-transport");
+ });
+});
+
+describe("SettingsPage — visualiser display prefs (localStorage + emitter)", () => {
+ let serverConfigRef;
+ let api;
+
+ beforeEach(() => {
+ serverConfigRef = { current: buildFullServerConfig() };
+ api = createWindowApi(serverConfigRef);
+ vi.spyOn(GlobalEmitter, "emit");
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ delete window.api;
+ GlobalEmitter.emit.mockRestore();
+ vi.clearAllMocks();
+ });
+
+ it("onVisualiserShowDisabledChange persists and emits", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.onVisualiserShowDisabledChange(true);
+ expect(localStorage.getItem("meshchatx.visualiser.showDisabledInterfaces")).toBe("true");
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith("visualiser-display-prefs-changed");
+ });
+
+ it("onVisualiserShowDiscoveredChange persists and emits", async () => {
+ const w = await mountSettingsPage(api);
+ w.vm.onVisualiserShowDiscoveredChange(true);
+ expect(localStorage.getItem("meshchatx.visualiser.showDiscoveredInterfaces")).toBe("true");
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith("visualiser-display-prefs-changed");
+ });
+
+ it("onDetailedOutboundSendStatusChange updates GlobalState and localStorage", async () => {
+ localStorage.removeItem("meshchatx_detailed_outbound_send_status");
+ const w = await mountSettingsPage(api);
+ await w.vm.onDetailedOutboundSendStatusChange({ target: { checked: true } });
+ expect(GlobalState.detailedOutboundSendStatus).toBe(true);
+ expect(localStorage.getItem("meshchatx_detailed_outbound_send_status")).toBe("true");
+ await w.vm.onDetailedOutboundSendStatusChange({ target: { checked: false } });
+ expect(GlobalState.detailedOutboundSendStatus).toBe(false);
+ expect(localStorage.getItem("meshchatx_detailed_outbound_send_status")).toBe("false");
+ });
+});
+
+describe("SettingsPage — maintenance, exports, telemetry trust, RNS reload", () => {
+ let serverConfigRef;
+ let api;
+
+ beforeEach(() => {
+ serverConfigRef = { current: buildFullServerConfig() };
+ api = createWindowApi(serverConfigRef);
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ delete window.api;
+ });
+
+ it("reloadRns POSTs reticulum reload", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.reloadRns();
+ expect(api.post).toHaveBeenCalledWith("/api/v1/reticulum/reload");
+ });
+
+ it("clearMessages DELETEs maintenance messages", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.clearMessages();
+ expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/messages");
+ });
+
+ it("clearAnnounces DELETEs announces", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.clearAnnounces();
+ expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/announces");
+ });
+
+ it("clearNomadnetFavorites DELETEs with aspect param", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.clearNomadnetFavorites();
+ expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/favourites", {
+ params: { aspect: "nomadnetwork.node" },
+ });
+ });
+
+ it("clearLxmfIcons DELETEs lxmf-icons", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.clearLxmfIcons();
+ expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/lxmf-icons");
+ });
+
+ it("clearStickers DELETEs stickers", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.clearStickers();
+ expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/stickers");
+ });
+
+ it("clearArchives DELETEs archives", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.clearArchives();
+ expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/archives");
+ });
+
+ it("clearReticulumDocs DELETEs docs", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.clearReticulumDocs();
+ expect(api.delete).toHaveBeenCalledWith("/api/v1/maintenance/docs/reticulum");
+ });
+
+ it("exportMessages GETs export endpoint", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.exportMessages();
+ expect(api.get).toHaveBeenCalledWith("/api/v1/maintenance/messages/export");
+ });
+
+ it("exportFolders GETs folders export", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.exportFolders();
+ expect(api.get).toHaveBeenCalledWith("/api/v1/lxmf/folders/export");
+ });
+
+ it("exportStickers GETs stickers export", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.exportStickers();
+ expect(api.get).toHaveBeenCalledWith("/api/v1/stickers/export");
+ });
+
+ 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" }),
+ );
+ });
+
+ it("revokeTelemetryTrust PATCHes contact telemetry flag", async () => {
+ const w = await mountSettingsPage(api);
+ await w.vm.revokeTelemetryTrust({ id: "c1", name: "Peer" });
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/telephone/contacts/c1", {
+ is_telemetry_trusted: false,
+ });
+ });
+});
diff --git a/tests/frontend/UIComponents.test.js b/tests/frontend/UIComponents.test.js
index 2fef65d..9af059e 100644
--- a/tests/frontend/UIComponents.test.js
+++ b/tests/frontend/UIComponents.test.js
@@ -8,6 +8,7 @@ import FormSubLabel from "../../meshchatx/src/frontend/components/forms/FormSubL
import DropDownMenu from "../../meshchatx/src/frontend/components/DropDownMenu.vue";
import DropDownMenuItem from "../../meshchatx/src/frontend/components/DropDownMenuItem.vue";
import SettingsPage from "../../meshchatx/src/frontend/components/settings/SettingsPage.vue";
+import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
default: {
@@ -22,6 +23,9 @@ vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
+ info: vi.fn(),
+ loading: vi.fn(),
+ dismiss: vi.fn(),
},
}));
@@ -537,6 +541,24 @@ describe("SettingsPage Component", () => {
}
});
+ it("updates reload status from websocket events", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.onWebsocketMessage({
+ data: JSON.stringify({
+ type: "reticulum_reload_status",
+ message: "Stopping services...",
+ level: "info",
+ in_progress: true,
+ }),
+ });
+
+ expect(wrapper.vm.reloadingRns).toBe(true);
+ expect(wrapper.vm.reloadRnsStatusMessage).toBe("Stopping services...");
+ expect(ToastUtils.info).toHaveBeenCalledWith("Stopping services...", 2500, "settings-rns-reload");
+ });
+
it("displays theme information correctly", async () => {
const wrapper = mountSettingsPage();
await wrapper.vm.$nextTick();
@@ -555,6 +577,60 @@ describe("SettingsPage Component", () => {
expect(wrapper.text()).toContain("app.transport");
});
+ it("shows RNS reload controls in settings", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("app.reload_rns");
+ });
+
+ it("enabling transport shows success toast after reload", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ axiosMock.post.mockResolvedValueOnce({
+ data: { message: "Transport mode enabled and RNS restarted successfully." },
+ });
+ wrapper.vm.config.is_transport_enabled = true;
+ await wrapper.vm.onIsTransportEnabledChange();
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/enable-transport");
+ expect(ToastUtils.success).toHaveBeenCalledWith("Transport mode enabled and RNS restarted successfully.");
+ });
+
+ it("disabling transport shows success toast after reload", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ axiosMock.post.mockResolvedValueOnce({
+ data: { message: "Transport mode disabled and RNS restarted successfully." },
+ });
+ wrapper.vm.config.is_transport_enabled = false;
+ await wrapper.vm.onIsTransportEnabledChange();
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/disable-transport");
+ expect(ToastUtils.success).toHaveBeenCalledWith("Transport mode disabled and RNS restarted successfully.");
+ });
+
+ it("shows error toast when enabling transport fails", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ axiosMock.post.mockRejectedValueOnce(new Error("boom"));
+ wrapper.vm.config.is_transport_enabled = true;
+ await wrapper.vm.onIsTransportEnabledChange();
+
+ expect(ToastUtils.error).toHaveBeenCalledWith("settings.failed_enable_transport");
+ });
+
it("handles multiple toggle changes without errors", async () => {
const wrapper = mountSettingsPage();
await wrapper.vm.$nextTick();
diff --git a/tests/frontend/conversationScroll.test.js b/tests/frontend/conversationScroll.test.js
new file mode 100644
index 0000000..df1cfa2
--- /dev/null
+++ b/tests/frontend/conversationScroll.test.js
@@ -0,0 +1,137 @@
+import { describe, it, expect } from "vitest";
+import {
+ SCROLL_BOTTOM_EPS_PX,
+ isNearBottom,
+ isScrollColumnReverse,
+ maxScrollTop,
+ scrollContainerToBottom,
+ shouldLoadPreviousMessages,
+} from "@/components/messages/conversationScroll.js";
+
+function makeScrollContainer({ reverse, scrollTop, scrollHeight, clientHeight }) {
+ const outer = document.createElement("div");
+ const inner = document.createElement("div");
+ if (reverse) {
+ inner.style.flexDirection = "column-reverse";
+ } else {
+ inner.style.flexDirection = "column";
+ }
+ outer.appendChild(inner);
+ document.body.appendChild(outer);
+ Object.defineProperty(outer, "scrollHeight", { value: scrollHeight, configurable: true });
+ Object.defineProperty(outer, "clientHeight", { value: clientHeight, configurable: true });
+ outer.scrollTop = scrollTop;
+ return outer;
+}
+
+describe("conversationScroll.js", () => {
+ it("maxScrollTop uses clientHeight", () => {
+ const el = document.createElement("div");
+ Object.defineProperty(el, "scrollHeight", { value: 500, configurable: true });
+ Object.defineProperty(el, "clientHeight", { value: 100, configurable: true });
+ expect(maxScrollTop(el)).toBe(400);
+ });
+
+ it("isNearBottom for column-reverse uses small scrollTop", () => {
+ const el = makeScrollContainer({
+ reverse: true,
+ scrollTop: 3,
+ scrollHeight: 5000,
+ clientHeight: 100,
+ });
+ expect(isScrollColumnReverse(el)).toBe(true);
+ expect(isNearBottom(el, SCROLL_BOTTOM_EPS_PX)).toBe(true);
+ el.scrollTop = 2000;
+ expect(isNearBottom(el, SCROLL_BOTTOM_EPS_PX)).toBe(false);
+ el.remove();
+ });
+
+ it("isNearBottom for normal column uses distance from max scrollTop", () => {
+ const el = makeScrollContainer({
+ reverse: false,
+ scrollTop: 392,
+ scrollHeight: 500,
+ clientHeight: 100,
+ });
+ expect(isScrollColumnReverse(el)).toBe(false);
+ expect(isNearBottom(el, SCROLL_BOTTOM_EPS_PX)).toBe(true);
+ el.scrollTop = 0;
+ expect(isNearBottom(el, SCROLL_BOTTOM_EPS_PX)).toBe(false);
+ el.remove();
+ });
+
+ it("isNearBottom tolerates fractional scroll metrics (normal column)", () => {
+ const el = document.createElement("div");
+ const inner = document.createElement("div");
+ inner.style.flexDirection = "column";
+ el.appendChild(inner);
+ document.body.appendChild(el);
+ Object.defineProperty(el, "scrollHeight", { value: 500.4, configurable: true });
+ Object.defineProperty(el, "clientHeight", { value: 100.2, configurable: true });
+ el.scrollTop = 400.19;
+ expect(isNearBottom(el, SCROLL_BOTTOM_EPS_PX)).toBe(true);
+ el.remove();
+ });
+
+ it("scrollContainerToBottom sets scrollTop for column-reverse", () => {
+ const el = makeScrollContainer({
+ reverse: true,
+ scrollTop: 300,
+ scrollHeight: 800,
+ clientHeight: 100,
+ });
+ scrollContainerToBottom(el);
+ expect(el.scrollTop).toBe(0);
+ el.remove();
+ });
+
+ it("scrollContainerToBottom sets scrollTop to max for normal column", () => {
+ const el = makeScrollContainer({
+ reverse: false,
+ scrollTop: 0,
+ scrollHeight: 600,
+ clientHeight: 100,
+ });
+ scrollContainerToBottom(el);
+ expect(el.scrollTop).toBe(500);
+ el.remove();
+ });
+
+ it("shouldLoadPreviousMessages mirrors edge for column-reverse", () => {
+ const el = makeScrollContainer({
+ reverse: true,
+ scrollTop: 4450,
+ scrollHeight: 5000,
+ clientHeight: 100,
+ });
+ expect(shouldLoadPreviousMessages(el)).toBe(true);
+ el.scrollTop = 0;
+ expect(shouldLoadPreviousMessages(el)).toBe(false);
+ el.remove();
+ });
+
+ it("shouldLoadPreviousMessages is false at visual bottom for short column-reverse threads", () => {
+ const el = makeScrollContainer({
+ reverse: true,
+ scrollTop: 0,
+ scrollHeight: 300,
+ clientHeight: 100,
+ });
+ expect(maxScrollTop(el)).toBe(200);
+ expect(shouldLoadPreviousMessages(el)).toBe(false);
+ el.remove();
+ });
+
+ it("shouldLoadPreviousMessages uses scrollTop for normal column", () => {
+ const el = makeScrollContainer({
+ reverse: false,
+ scrollTop: 100,
+ scrollHeight: 5000,
+ clientHeight: 100,
+ });
+ expect(shouldLoadPreviousMessages(el)).toBe(true);
+ el.scrollTop = 2000;
+ expect(shouldLoadPreviousMessages(el)).toBe(false);
+ el.remove();
+ });
+});
diff --git a/tests/frontend/designTokens.test.js b/tests/frontend/designTokens.test.js
index 842bdc0..f2b49c8 100644
--- a/tests/frontend/designTokens.test.js
+++ b/tests/frontend/designTokens.test.js
@@ -18,6 +18,7 @@ describe("design tokens", () => {
expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-canvas"]).toBe("#f8fafc");
expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-surface"]).toBe("#ffffff");
expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-accent"]).toBe("#2563eb");
+ expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-action-primary"]).toBe("#2563eb");
expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-error"]).toBe("#dc2626");
expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-info"]).toBe("#0284c7");
expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-success"]).toBe("#16a34a");
@@ -27,7 +28,8 @@ describe("design tokens", () => {
it("preserves legacy dark palette anchors", () => {
expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-canvas"]).toBe("#09090b");
expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-surface"]).toBe("#18181b");
- expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-accent"]).toBe("#2563eb");
+ expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-accent"]).toBe("#60a5fa");
+ expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-action-primary"]).toBe("#2563eb");
expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-error"]).toBe("#f87171");
expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-info"]).toBe("#38bdf8");
expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-success"]).toBe("#34d399");
@@ -38,11 +40,11 @@ describe("design tokens", () => {
const t = vuetifyThemesFromTokens();
expect(t.light.colors.background).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-canvas"]);
expect(t.light.colors.surface).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-surface"]);
- expect(t.light.colors.primary).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-accent"]);
+ expect(t.light.colors.primary).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-action-primary"]);
expect(t.light.colors.error).toBe(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-error"]);
expect(t.dark.colors.background).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-canvas"]);
expect(t.dark.colors.surface).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-surface"]);
- expect(t.dark.colors.primary).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-accent"]);
+ expect(t.dark.colors.primary).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-action-primary"]);
expect(t.dark.colors.error).toBe(MESHCHAT_THEME_VARIABLES_DARK["--mc-error"]);
});
diff --git a/tests/frontend/messageListVirtual.test.js b/tests/frontend/messageListVirtual.test.js
new file mode 100644
index 0000000..11773d6
--- /dev/null
+++ b/tests/frontend/messageListVirtual.test.js
@@ -0,0 +1,42 @@
+import { describe, it, expect } from "vitest";
+import {
+ MIN_VIRTUAL_DISPLAY_GROUPS,
+ displayGroupsOldestFirst,
+ estimateGroupHeight,
+ findDisplayGroupIndexForMessageHash,
+} from "@/components/messages/messageListVirtual.js";
+
+describe("messageListVirtual.js", () => {
+ it("displayGroupsOldestFirst reverses newest-first groups", () => {
+ const g = [
+ { type: "single", key: "a", chatItem: { lxmf_message: { hash: "a" } } },
+ { type: "single", key: "b", chatItem: { lxmf_message: { hash: "b" } } },
+ ];
+ const o = displayGroupsOldestFirst(g);
+ expect(o.map((x) => x.key).join(",")).toBe("b,a");
+ });
+
+ it("estimateGroupHeight returns larger size for image groups", () => {
+ expect(estimateGroupHeight({ type: "imageGroup", items: [] })).toBeGreaterThan(
+ estimateGroupHeight({ type: "single", chatItem: {} })
+ );
+ });
+
+ it("findDisplayGroupIndexForMessageHash finds single and image group members", () => {
+ const groups = [
+ { type: "single", key: "x", chatItem: { lxmf_message: { hash: "h1" } } },
+ {
+ type: "imageGroup",
+ key: "ig",
+ items: [{ lxmf_message: { hash: "h2" } }, { lxmf_message: { hash: "h3" } }],
+ },
+ ];
+ expect(findDisplayGroupIndexForMessageHash(groups, "h1")).toBe(0);
+ expect(findDisplayGroupIndexForMessageHash(groups, "h3")).toBe(1);
+ expect(findDisplayGroupIndexForMessageHash(groups, "missing")).toBe(-1);
+ });
+
+ it("MIN_VIRTUAL_DISPLAY_GROUPS is a positive threshold", () => {
+ expect(MIN_VIRTUAL_DISPLAY_GROUPS).toBeGreaterThan(10);
+ });
+});