feat(tests): add end-to-end tests for keyboard shortcuts and message conversation scrolling; enhance navigation tests for improved coverage

This commit is contained in:
Ivan
2026-04-16 00:39:39 -05:00
parent 7c1e99e861
commit d444daa073
11 changed files with 1789 additions and 6 deletions
+79
View File
@@ -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/);
});
});
@@ -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);
}
});
});
+13 -3
View File
@@ -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 });
@@ -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();
});
});
@@ -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: "<span></span>" },
ConversationDropDownMenu: { template: "<div></div>" },
SendMessageButton: { template: "<div></div>" },
IconButton: { template: "<button></button>" },
AddImageButton: { template: "<div></div>" },
AddAudioButton: { template: "<div></div>" },
PaperMessageModal: { template: "<div></div>" },
AudioWaveformPlayer: { template: "<div></div>" },
LxmfUserIcon: { template: "<div></div>" },
},
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
);
});
+142
View File
@@ -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",
}),
);
});
});
@@ -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: "<span class='mdi'></span>" },
Toggle,
ShortcutRecorder: { template: "<div></div>" },
RouterLink: { template: "<a><slot /></a>" },
},
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,
});
});
});
+76
View File
@@ -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();
+137
View File
@@ -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();
});
});
+5 -3
View File
@@ -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"]);
});
+42
View File
@@ -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);
});
});