mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-06-04 02:11:55 +00:00
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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user