diff --git a/tests/e2e/conversations-nomad-visualiser.spec.js b/tests/e2e/conversations-nomad-visualiser.spec.js
new file mode 100644
index 0000000..7df1cd1
--- /dev/null
+++ b/tests/e2e/conversations-nomad-visualiser.spec.js
@@ -0,0 +1,80 @@
+const { test, expect } = require("@playwright/test");
+const { prepareE2eSession } = require("./helpers");
+
+test.describe("Messages (conversations)", () => {
+ test.beforeEach(async ({ request }) => {
+ await prepareE2eSession(request);
+ });
+
+ test("shows conversation sidebar and empty chat state", async ({ page }) => {
+ await page.goto("/#/messages");
+ await expect(page).toHaveURL(/#\/messages/);
+ await expect(page.getByText("Conversations", { exact: true }).first()).toBeVisible({ timeout: 25000 });
+ await expect(page.getByText("No Conversations", { exact: true })).toBeVisible({ timeout: 25000 });
+ await expect(page.getByText("No Active Chat", { exact: true })).toBeVisible({ timeout: 25000 });
+ await expect(page.getByText(/Select a peer from the sidebar/i)).toBeVisible({ timeout: 15000 });
+ });
+
+ test("switches sidebar to Announces tab and shows peer discovery copy", async ({ page }) => {
+ await page.goto("/#/messages");
+ await page.getByText("Announces", { exact: true }).first().click();
+ await expect(page.getByPlaceholder(/Search .* recent announces/i)).toBeVisible({ timeout: 20000 });
+ await expect(
+ page.getByText(/No Peers Discovered|Waiting for someone to announce/i).first()
+ ).toBeVisible({ timeout: 20000 });
+ });
+
+ test("sidebar navigates between Nomad Network and Messages", async ({ page }) => {
+ await page.goto("/#/nomadnetwork");
+ await expect(page).toHaveURL(/#\/nomadnetwork/);
+ await expect(page.getByText("No Active Node", { exact: true })).toBeVisible({ timeout: 30000 });
+
+ const sideNav = page.locator("ul.py-3");
+ await sideNav.locator('a[href*="#/messages"]').click();
+ await expect(page).toHaveURL(/#\/messages/, { timeout: 15000 });
+ await expect(page.getByText("No Active Chat", { exact: true })).toBeVisible({ timeout: 25000 });
+
+ await sideNav.locator('a[href*="#/nomadnetwork"]').click();
+ await expect(page).toHaveURL(/#\/nomadnetwork/, { timeout: 15000 });
+ await expect(page.getByText("No Active Node", { exact: true })).toBeVisible({ timeout: 25000 });
+ });
+});
+
+test.describe("Nomad Network", () => {
+ test.beforeEach(async ({ request }) => {
+ await prepareE2eSession(request);
+ });
+
+ test("shows empty state and sidebar tabs", async ({ page }) => {
+ await page.goto("/#/nomadnetwork");
+ await expect(page).toHaveURL(/#\/nomadnetwork/);
+ await expect(page.getByText("No Active Node", { exact: true })).toBeVisible({ timeout: 25000 });
+ await expect(page.getByText(/Select a Node to start browsing/i)).toBeVisible({ timeout: 15000 });
+ await expect(page.getByText("Favourites", { exact: true }).first()).toBeVisible({ timeout: 15000 });
+ await expect(page.getByText("Announces", { exact: true }).first()).toBeVisible({ timeout: 15000 });
+ });
+});
+
+test.describe("Network visualiser", () => {
+ test.beforeEach(async ({ request }) => {
+ await prepareE2eSession(request);
+ });
+
+ test("shows mesh header, graph container, and node search", async ({ page }) => {
+ await page.goto("/#/network-visualiser");
+ await expect(page).toHaveURL(/#\/network-visualiser/);
+ await expect(page.getByText("Reticulum Mesh", { exact: true })).toBeVisible({ timeout: 30000 });
+ await expect(page.locator("#network")).toBeAttached();
+ await expect(page.getByPlaceholder(/Search nodes \(\d+\)/)).toBeVisible({ timeout: 30000 });
+ });
+
+ test("collapses and expands control panel via header", async ({ page }) => {
+ await page.goto("/#/network-visualiser");
+ await expect(page.getByText("Reticulum Mesh", { exact: true })).toBeVisible({ timeout: 30000 });
+ await expect(page.getByText("Auto Update", { exact: true })).toBeVisible({ timeout: 30000 });
+ await page.getByText("Reticulum Mesh", { exact: true }).click();
+ await expect(page.getByText("Auto Update", { exact: true })).toBeHidden({ timeout: 10000 });
+ await page.getByText("Reticulum Mesh", { exact: true }).click();
+ await expect(page.getByText("Auto Update", { exact: true })).toBeVisible({ timeout: 10000 });
+ });
+});
diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index e317f71..2cf5103 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -15,6 +15,7 @@ describe("ConversationViewer.vue", () => {
beforeEach(() => {
GlobalState.config.message_outbound_bubble_color = "#4f46e5";
+ GlobalState.config.message_waiting_bubble_color = "#e5e7eb";
WebSocketConnection.connect();
axiosMock = {
get: vi.fn().mockImplementation((url) => {
@@ -502,6 +503,29 @@ describe("ConversationViewer.vue", () => {
expect(wrapper.vm.isThemeOutboundBubble(chatItem)).toBe(false);
});
+ it("applies waiting bubble color when pathfinding", () => {
+ GlobalState.config.message_waiting_bubble_color = "#ccddff";
+ const wrapper = mountConversationViewer();
+ const chatItem = {
+ type: "lxmf_message",
+ is_outbound: true,
+ lxmf_message: {
+ hash: "h-wait",
+ state: "sending",
+ content: "hi",
+ destination_hash: "test-hash",
+ source_hash: "my-hash",
+ fields: {},
+ _pendingPathfinding: true,
+ },
+ };
+ expect(wrapper.vm.bubbleStyles(chatItem)).toMatchObject({
+ "background-color": "#ccddff",
+ color: "#111827",
+ });
+ expect(wrapper.vm.outboundBubbleSurfaceClass(chatItem)).toBe("");
+ });
+
it("marks inbound messages with markdown-content--inbound for link styling", async () => {
GlobalState.config.message_outbound_bubble_color = "#4f46e5";
const wrapper = mountConversationViewer();
diff --git a/tests/frontend/InterfacesPerformance.test.js b/tests/frontend/InterfacesPerformance.test.js
index 71139af..da3445b 100644
--- a/tests/frontend/InterfacesPerformance.test.js
+++ b/tests/frontend/InterfacesPerformance.test.js
@@ -103,7 +103,7 @@ describe("InterfacesPage Performance", () => {
const disconnectedBadges = wrapper.findAll(".bg-red-500\\/90");
expect(disconnectedBadges.length).toBe(numDiscovered);
- expect(end - start).toBeLessThan(6000);
+ expect(end - start).toBeLessThan(12000);
});
it("disconnected discovered interfaces render without pulse animation", async () => {
diff --git a/tests/frontend/LoadTimePerformance.test.js b/tests/frontend/LoadTimePerformance.test.js
index 3f8ec20..1578e7f 100644
--- a/tests/frontend/LoadTimePerformance.test.js
+++ b/tests/frontend/LoadTimePerformance.test.js
@@ -5,7 +5,7 @@ import MessagesSidebar from "../../meshchatx/src/frontend/components/messages/Me
import NomadNetworkSidebar from "../../meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue";
const MAX_PROP_NODES_MS = 3000;
-const MAX_MESSAGES_ANNOUNCES_MS = 5000;
+const MAX_MESSAGES_ANNOUNCES_MS = 12000;
const MAX_NOMADNET_NODES_MS = 3000;
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
diff --git a/tests/frontend/MessagesPageSidebarIntegration.test.js b/tests/frontend/MessagesPageSidebarIntegration.test.js
new file mode 100644
index 0000000..f10b9f9
--- /dev/null
+++ b/tests/frontend/MessagesPageSidebarIntegration.test.js
@@ -0,0 +1,149 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import MessagesPage from "@/components/messages/MessagesPage.vue";
+
+vi.mock("@/js/GlobalState", async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ default: {
+ ...mod.default,
+ blockedDestinations: [],
+ config: {
+ ...mod.default.config,
+ banished_effect_enabled: false,
+ telemetry_enabled: false,
+ },
+ },
+ };
+});
+
+vi.mock("@/js/Utils", () => ({
+ default: {
+ formatTimeAgo: vi.fn(() => "1h ago"),
+ formatDestinationHash: (h) => (h && h.length >= 8 ? h.slice(0, 8) + "…" : h),
+ },
+}));
+
+const ConversationViewerStub = {
+ name: "ConversationViewer",
+ template: "
",
+ methods: {
+ markConversationAsRead: vi.fn(),
+ },
+};
+
+describe("MessagesPage with MessagesSidebar integration", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn(),
+ post: vi.fn(),
+ };
+ window.api = axiosMock;
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config")
+ return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
+ if (url === "/api/v1/lxmf/conversations")
+ return Promise.resolve({ data: { conversations: [] } });
+ if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } });
+ if (url === "/api/v1/lxmf/conversation-pins")
+ return Promise.resolve({ data: { peer_hashes: [] } });
+ if (url === "/api/v1/lxmf/folders") return Promise.resolve({ data: [] });
+ return Promise.resolve({ data: {} });
+ });
+ });
+
+ afterEach(() => {
+ delete window.api;
+ });
+
+ it("renders live MessagesSidebar and switches to Announces tab", async () => {
+ const wrapper = mount(MessagesPage, {
+ props: { destinationHash: "" },
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $route: { query: {} },
+ $router: { replace: vi.fn() },
+ },
+ stubs: {
+ ConversationViewer: ConversationViewerStub,
+ MaterialDesignIcon: { template: '
' },
+ },
+ directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("messages.conversations");
+
+ const tabs = wrapper.findAll("div.flex.w-full.cursor-pointer.border-b-2");
+ expect(tabs.length).toBeGreaterThanOrEqual(2);
+ await tabs[1].trigger("click");
+ await wrapper.vm.$nextTick();
+
+ const sidebar = wrapper.findComponent({ name: "MessagesSidebar" });
+ expect(sidebar.vm.tab).toBe("announces");
+ });
+
+ it("selects peer from sidebar conversation row and updates MessagesPage selectedPeer", async () => {
+ const destHash = "0123456789abcdef0123456789abcdef";
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config")
+ return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
+ if (url === "/api/v1/lxmf/conversations")
+ return Promise.resolve({
+ data: {
+ conversations: [
+ {
+ destination_hash: destHash,
+ display_name: "Integration Peer",
+ updated_at: new Date().toISOString(),
+ is_unread: false,
+ failed_messages_count: 0,
+ },
+ ],
+ },
+ });
+ if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } });
+ if (url === "/api/v1/lxmf/conversation-pins")
+ return Promise.resolve({ data: { peer_hashes: [] } });
+ if (url === "/api/v1/lxmf/folders") return Promise.resolve({ data: [] });
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mount(MessagesPage, {
+ props: { destinationHash: "" },
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $route: { query: {} },
+ $router: { replace: vi.fn() },
+ },
+ stubs: {
+ ConversationViewer: ConversationViewerStub,
+ MaterialDesignIcon: { template: '
' },
+ },
+ directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ const row = wrapper.find(".conversation-item");
+ expect(row.exists()).toBe(true);
+ await row.trigger("click");
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.selectedPeer).toMatchObject({
+ destination_hash: destHash,
+ display_name: "Integration Peer",
+ });
+ });
+});
diff --git a/tests/frontend/MessagesSidebar.test.js b/tests/frontend/MessagesSidebar.test.js
index 82fb33f..0cebbbc 100644
--- a/tests/frontend/MessagesSidebar.test.js
+++ b/tests/frontend/MessagesSidebar.test.js
@@ -86,7 +86,7 @@ describe("MessagesSidebar UI", () => {
it("switches to announces tab when Announces tab is clicked", async () => {
const wrapper = mountSidebar();
- const tabs = wrapper.findAll(".border-b-2.py-3");
+ const tabs = wrapper.findAll("div.flex.w-full.cursor-pointer.border-b-2");
const announcesTab = tabs[1];
await announcesTab.trigger("click");
await wrapper.vm.$nextTick();
diff --git a/tests/frontend/MicronParser.test.js b/tests/frontend/MicronParser.test.js
index 9a886bc..00b113a 100644
--- a/tests/frontend/MicronParser.test.js
+++ b/tests/frontend/MicronParser.test.js
@@ -62,14 +62,22 @@ describe("MicronParser.js", () => {
});
describe("forceMonospace wide cells", () => {
- it("uses Mu-mnt-full for CJK and Mu-mnt for Latin in the same line", () => {
+ it("uses Mu-mnt-group for Latin-only words and Mu-mnt-full per grapheme for CJK", () => {
const monoParser = new MicronParser(true, true);
const html = monoParser.splitAtSpaces("Hi \u4e2d\u6587");
- expect(html).toContain("class='Mu-mnt'>H");
- expect(html).toContain("class='Mu-mnt'>i");
+ expect(html).toContain("Mu-mnt-group");
+ expect(html).not.toContain("class='Mu-mnt'>H");
expect(html).toContain("class='Mu-mnt-full'>\u4e2d");
expect(html).toContain("class='Mu-mnt-full'>\u6587");
});
+
+ it("uses Mu-mnt-group for Cyrillic without per-character Mu-mnt spans", () => {
+ const monoParser = new MicronParser(true, true);
+ const html = monoParser.forceMonospace("\u041f\u0440\u0438\u0432\u0435\u0442");
+ expect(html).toContain("Mu-mnt-group");
+ expect(html).toContain("\u041f\u0440\u0438\u0432\u0435\u0442");
+ expect(html).not.toMatch(/class='Mu-mnt'>/);
+ });
});
describe("convertMicronToHtml", () => {
diff --git a/tests/frontend/NetworkVisualiser.test.js b/tests/frontend/NetworkVisualiser.test.js
index 827d1e3..34d6dbd 100644
--- a/tests/frontend/NetworkVisualiser.test.js
+++ b/tests/frontend/NetworkVisualiser.test.js
@@ -170,6 +170,19 @@ describe("NetworkVisualiser.vue", () => {
expect(wrapper.text()).toContain("Links");
});
+ it("toggles control panel when the header strip is clicked", async () => {
+ const wrapper = mountVisualiser();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const before = wrapper.vm.isShowingControls;
+ const headerRow = wrapper
+ .findAll("div")
+ .find((d) => d.text().includes("Reticulum Mesh") && d.classes().includes("cursor-pointer"));
+ expect(headerRow).toBeDefined();
+ await headerRow.trigger("click");
+ expect(wrapper.vm.isShowingControls).toBe(!before);
+ });
+
it("shows loading overlay with batch indication during update", async () => {
const wrapper = mountVisualiser();
wrapper.vm.isLoading = true;
diff --git a/tests/frontend/NomadNetworkPage.test.js b/tests/frontend/NomadNetworkPage.test.js
index 7f1a4b2..7656bfe 100644
--- a/tests/frontend/NomadNetworkPage.test.js
+++ b/tests/frontend/NomadNetworkPage.test.js
@@ -1,7 +1,16 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import MicronParser from "@/js/MicronParser.js";
import NomadNetworkPage from "@/components/nomadnetwork/NomadNetworkPage.vue";
+vi.mock("@/js/WebSocketConnection", () => ({
+ default: {
+ send: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ },
+}));
+
describe("NomadNetworkPage.vue", () => {
let axiosMock;
@@ -98,6 +107,7 @@ describe("NomadNetworkPage.vue", () => {
it("clearPartials resets partial state and timers", () => {
const wrapper = mountNomadNetworkPage();
wrapper.vm.pagePartials = { "partial-0": "x" };
+ wrapper.vm.loadedPartialIds = { "partial-0": true };
wrapper.vm.partialIdsByKey = { "abc:path": [] };
wrapper.vm.partialRefreshByKey = { "abc:path": 10 };
wrapper.vm.partialRefreshTimers = { "abc:path": 12345 };
@@ -105,11 +115,62 @@ describe("NomadNetworkPage.vue", () => {
wrapper.vm.clearPartials();
expect(wrapper.vm.pagePartials).toEqual({});
+ expect(wrapper.vm.loadedPartialIds).toEqual({});
expect(wrapper.vm.partialIdsByKey).toEqual({});
expect(wrapper.vm.partialRefreshByKey).toEqual({});
expect(wrapper.vm.partialRefreshTimers).toEqual({});
});
+ it("processPartials does not call downloadNomadNetPage again after partials are marked loaded", async () => {
+ const dest = "a".repeat(32);
+ const wrapper = mountNomadNetworkPage();
+ wrapper.vm.selectedNode = { destination_hash: dest, display_name: "Test" };
+ wrapper.vm.nodePagePath = `${dest}:/page/index.mu`;
+ wrapper.vm.nodePageContent = "`{" + dest + ":/page/nested.mu}";
+ wrapper.vm.isShowingNodePageSource = false;
+
+ const downloadSpy = vi
+ .spyOn(wrapper.vm, "downloadNomadNetPage")
+ .mockImplementation((_d, _p, _f, onSuccess) => {
+ onSuccess("# ok");
+ });
+
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.processPartials();
+ await wrapper.vm.$nextTick();
+
+ const afterFirst = downloadSpy.mock.calls.length;
+ expect(afterFirst).toBeGreaterThanOrEqual(1);
+
+ wrapper.vm.processPartials();
+ await wrapper.vm.$nextTick();
+
+ expect(downloadSpy.mock.calls.length).toBe(afterFirst);
+
+ downloadSpy.mockRestore();
+ });
+
+ it("does not re-run Micron conversion when only favourites list updates", async () => {
+ const dest = "b".repeat(32);
+ const wrapper = mountNomadNetworkPage();
+ wrapper.vm.selectedNode = { destination_hash: dest, display_name: "Test" };
+ wrapper.vm.nodePagePath = `${dest}:/page/index.mu`;
+ wrapper.vm.nodePageContent = "# line one\n# line two";
+ wrapper.vm.isShowingNodePageSource = false;
+
+ await wrapper.vm.$nextTick();
+
+ const parseSpy = vi.spyOn(MicronParser.prototype, "convertMicronToHtml");
+
+ wrapper.vm.favourites = [{ destination_hash: "x", display_name: "Fav" }];
+ await wrapper.vm.$nextTick();
+
+ expect(parseSpy).not.toHaveBeenCalled();
+ parseSpy.mockRestore();
+ });
+
it("renderPageContent with .mu and pagePartials injects partial content", () => {
const dest = "a".repeat(32);
const wrapper = mountNomadNetworkPage();
diff --git a/tests/frontend/Performance.test.js b/tests/frontend/Performance.test.js
index dab036f..e152d4a 100644
--- a/tests/frontend/Performance.test.js
+++ b/tests/frontend/Performance.test.js
@@ -122,7 +122,7 @@ describe("UI Performance and Memory Tests", () => {
);
expect(wrapper.findAll(".conversation-item").length).toBe(numConvs);
- expect(renderTime).toBeLessThan(5000);
+ expect(renderTime).toBeLessThan(12000);
expect(memGrowth).toBeLessThan(200); // Adjusted for JSDOM/Node.js overhead with 2000 items
});
diff --git a/tests/frontend/Stickers.test.js b/tests/frontend/Stickers.test.js
new file mode 100644
index 0000000..ad21546
--- /dev/null
+++ b/tests/frontend/Stickers.test.js
@@ -0,0 +1,397 @@
+import { mount, flushPromises } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import ConversationViewer from "@/components/messages/ConversationViewer.vue";
+import SettingsPage from "@/components/settings/SettingsPage.vue";
+import WebSocketConnection from "@/js/WebSocketConnection";
+import ToastUtils from "@/js/ToastUtils";
+import Utils from "@/js/Utils";
+import GlobalState from "@/js/GlobalState";
+
+vi.mock("@/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn(() => Promise.resolve(true)),
+ alert: vi.fn(),
+ },
+}));
+
+vi.mock("@/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ loading: vi.fn(),
+ info: vi.fn(),
+ },
+}));
+
+function mountConversationViewer(axiosMock) {
+ return mount(ConversationViewer, {
+ props: {
+ selectedPeer: { destination_hash: "a".repeat(32), display_name: "Peer" },
+ myLxmfAddressHash: "b".repeat(32),
+ conversations: [],
+ },
+ global: {
+ directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
+ mocks: { $t: (key) => key, $i18n: { locale: "en" } },
+ stubs: {
+ MaterialDesignIcon: true,
+ AddImageButton: true,
+ AddAudioButton: true,
+ SendMessageButton: true,
+ ConversationDropDownMenu: true,
+ PaperMessageModal: true,
+ AudioWaveformPlayer: true,
+ LxmfUserIcon: true,
+ },
+ },
+ });
+}
+
+describe("Stickers (ConversationViewer)", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ WebSocketConnection.connect();
+ vi.clearAllMocks();
+ axiosMock = {
+ get: vi.fn().mockImplementation((url, config) => {
+ 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: {} } });
+ if (url === "/api/v1/stickers") {
+ return Promise.resolve({ data: { stickers: [{ id: 7, image_type: "png", name: "S" }] } });
+ }
+ if (url.includes("/api/v1/stickers/") && url.endsWith("/image")) {
+ return Promise.resolve({
+ data: new Blob([Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], {
+ type: "image/png",
+ }),
+ });
+ }
+ if (url.includes("/lxmf-messages/attachment/") && url.includes("/image")) {
+ return Promise.resolve({ data: new ArrayBuffer(8) });
+ }
+ return Promise.resolve({ data: {} });
+ }),
+ post: vi.fn().mockResolvedValue({ data: {} }),
+ delete: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ window.api = axiosMock;
+
+ GlobalState.blockedDestinations = [];
+ GlobalState.config = { banished_effect_enabled: false };
+
+ vi.stubGlobal("localStorage", {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ });
+ window.URL.createObjectURL = vi.fn(() => "mock-url");
+ vi.stubGlobal(
+ "FileReader",
+ vi.fn(() => ({
+ readAsDataURL: vi.fn(function () {
+ this.result = "data:image/png;base64,mock";
+ this.onload?.({ target: { result: this.result } });
+ }),
+ }))
+ );
+ });
+
+ afterEach(() => {
+ delete window.api;
+ vi.unstubAllGlobals();
+ WebSocketConnection.destroy();
+ });
+
+ it("loadUserStickers populates userStickers from GET /api/v1/stickers", async () => {
+ const wrapper = mountConversationViewer(axiosMock);
+ await wrapper.vm.loadUserStickers();
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/stickers");
+ expect(wrapper.vm.userStickers).toHaveLength(1);
+ expect(wrapper.vm.userStickers[0].id).toBe(7);
+ });
+
+ it("mounted calls loadUserStickers so stickers can be listed", async () => {
+ mountConversationViewer(axiosMock);
+ await flushPromises();
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/stickers");
+ });
+
+ it("stickerImageUrl builds attachment URL for a sticker id", () => {
+ const wrapper = mountConversationViewer(axiosMock);
+ expect(wrapper.vm.stickerImageUrl(42)).toBe("/api/v1/stickers/42/image");
+ });
+
+ it("toggleStickerPicker loads stickers when opening", async () => {
+ const wrapper = mountConversationViewer(axiosMock);
+ axiosMock.get.mockClear();
+ wrapper.vm.isStickerPickerOpen = false;
+ await wrapper.vm.toggleStickerPicker();
+ expect(wrapper.vm.isStickerPickerOpen).toBe(true);
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/stickers");
+ });
+
+ it("addStickerFromLibrary fetches image blob and attaches via onImageSelected", async () => {
+ const wrapper = mountConversationViewer(axiosMock);
+ const onSpy = vi.spyOn(wrapper.vm, "onImageSelected").mockImplementation(() => {});
+ await wrapper.vm.addStickerFromLibrary({ id: 7, image_type: "png" });
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/stickers/7/image", { responseType: "blob" });
+ expect(onSpy).toHaveBeenCalled();
+ expect(wrapper.vm.isStickerPickerOpen).toBe(false);
+ onSpy.mockRestore();
+ });
+
+ it("saveMessageImageToStickers POSTs image_bytes when present on message", async () => {
+ const wrapper = mountConversationViewer(axiosMock);
+ const chatItem = {
+ lxmf_message: {
+ hash: "h1",
+ fields: {
+ image: {
+ image_type: "png",
+ image_bytes: "cG5nLWRhdGE=",
+ },
+ },
+ },
+ };
+ await wrapper.vm.saveMessageImageToStickers(chatItem);
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ "/api/v1/stickers",
+ expect.objectContaining({
+ image_bytes: "cG5nLWRhdGE=",
+ image_type: "png",
+ source_message_hash: "h1",
+ })
+ );
+ expect(ToastUtils.success).toHaveBeenCalled();
+ });
+
+ it("saveMessageImageToStickers fetches attachment when image_bytes missing", async () => {
+ const wrapper = mountConversationViewer(axiosMock);
+ vi.mocked(ToastUtils.success).mockClear();
+
+ const chatItem = {
+ lxmf_message: {
+ hash: "abc123hash",
+ fields: {
+ image: { image_type: "png" },
+ },
+ },
+ };
+
+ const b64Spy = vi.spyOn(Utils, "arrayBufferToBase64").mockReturnValue("YmFzZTY0");
+
+ await wrapper.vm.saveMessageImageToStickers(chatItem);
+
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf-messages/attachment/abc123hash/image", {
+ responseType: "arraybuffer",
+ });
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ "/api/v1/stickers",
+ expect.objectContaining({
+ image_bytes: "YmFzZTY0",
+ image_type: "png",
+ })
+ );
+ b64Spy.mockRestore();
+ });
+
+ it("saveMessageImageToStickers shows duplicate info when API returns duplicate_sticker", async () => {
+ const dup = { response: { data: { error: "duplicate_sticker" } } };
+ axiosMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/stickers") {
+ return Promise.reject(dup);
+ }
+ return Promise.resolve({ data: {} });
+ });
+ const wrapper = mountConversationViewer(axiosMock);
+ vi.mocked(ToastUtils.info).mockClear();
+ await wrapper.vm.saveMessageImageToStickers({
+ lxmf_message: {
+ hash: "h2",
+ fields: { image: { image_type: "png", image_bytes: "QQ==" } },
+ },
+ });
+ expect(ToastUtils.info).toHaveBeenCalledWith("stickers.duplicate");
+ });
+
+ it("onStickerPickerClickOutside closes the picker", () => {
+ const wrapper = mountConversationViewer(axiosMock);
+ wrapper.vm.isStickerPickerOpen = true;
+ wrapper.vm.onStickerPickerClickOutside();
+ expect(wrapper.vm.isStickerPickerOpen).toBe(false);
+ });
+});
+
+describe("Stickers (SettingsPage)", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ WebSocketConnection.connect();
+ vi.clearAllMocks();
+ axiosMock = {
+ get: vi.fn().mockImplementation((url) => {
+ if (url.includes("/api/v1/config")) {
+ return Promise.resolve({
+ data: {
+ config: {
+ display_name: "User",
+ identity_hash: "c".repeat(64),
+ lxmf_address_hash: "d".repeat(64),
+ theme: "dark",
+ is_transport_enabled: false,
+ backup_max_count: 5,
+ block_attachments_from_strangers: true,
+ block_all_from_strangers: false,
+ show_unknown_contact_banner: true,
+ banished_effect_enabled: false,
+ banished_text: "BANISHED",
+ banished_color: "#dc2626",
+ },
+ },
+ });
+ }
+ if (url === "/api/v1/stickers") {
+ return Promise.resolve({
+ data: { stickers: [{ id: 1 }, { id: 2 }, { id: 3 }] },
+ });
+ }
+ if (url.includes("/api/v1/telemetry/trusted-peers")) {
+ return Promise.resolve({ data: { trusted_peers: [] } });
+ }
+ return Promise.resolve({ data: {} });
+ }),
+ post: vi.fn().mockResolvedValue({
+ data: { imported: 2, skipped_duplicates: 1, skipped_invalid: 0 },
+ }),
+ patch: vi.fn().mockResolvedValue({ data: { config: {} } }),
+ delete: vi.fn().mockResolvedValue({ data: { deleted: 3 } }),
+ };
+ window.api = axiosMock;
+ });
+
+ afterEach(() => {
+ delete window.api;
+ WebSocketConnection.destroy();
+ });
+
+ function mountSettings() {
+ return mount(SettingsPage, {
+ global: {
+ directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
+ mocks: { $t: (key) => key, $i18n: { locale: "en" } },
+ stubs: {
+ MaterialDesignIcon: true,
+ Toggle: true,
+ ShortcutRecorder: true,
+ LxmfUserIcon: true,
+ },
+ },
+ });
+ }
+
+ it("loadStickerCount sets stickerCount from GET /api/v1/stickers", async () => {
+ const wrapper = mountSettings();
+ await flushPromises();
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/stickers");
+ expect(wrapper.vm.stickerCount).toBe(3);
+ });
+
+ it("exportStickers downloads JSON from GET /api/v1/stickers/export", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url.includes("/api/v1/config")) {
+ return Promise.resolve({
+ data: {
+ config: {
+ display_name: "User",
+ identity_hash: "c".repeat(64),
+ lxmf_address_hash: "d".repeat(64),
+ theme: "dark",
+ is_transport_enabled: false,
+ backup_max_count: 5,
+ block_attachments_from_strangers: true,
+ block_all_from_strangers: false,
+ show_unknown_contact_banner: true,
+ banished_effect_enabled: false,
+ banished_text: "BANISHED",
+ banished_color: "#dc2626",
+ },
+ },
+ });
+ }
+ if (url === "/api/v1/stickers/export") {
+ return Promise.resolve({
+ data: { format: "meshchatx-stickers", version: 1, stickers: [] },
+ });
+ }
+ if (url === "/api/v1/stickers") {
+ return Promise.resolve({ data: { stickers: [] } });
+ }
+ if (url.includes("/api/v1/telemetry/trusted-peers")) {
+ return Promise.resolve({ data: { trusted_peers: [] } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountSettings();
+ await flushPromises();
+ await wrapper.vm.exportStickers();
+
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/stickers/export");
+ expect(ToastUtils.success).toHaveBeenCalledWith("stickers.export_done");
+ });
+
+ it("importStickers posts sticker file JSON with replace_duplicates", async () => {
+ const doc = {
+ format: "meshchatx-stickers",
+ version: 1,
+ stickers: [],
+ };
+ class MockFileReader {
+ readAsText() {
+ queueMicrotask(() => {
+ this.onload({ target: { result: JSON.stringify(doc) } });
+ });
+ }
+ }
+ const OriginalFileReader = globalThis.FileReader;
+ globalThis.FileReader = MockFileReader;
+
+ try {
+ const wrapper = mountSettings();
+ await flushPromises();
+ wrapper.vm.stickerImportReplaceDuplicates = true;
+
+ const file = new File([JSON.stringify(doc)], "stickers.json", { type: "application/json" });
+ const ev = { target: { files: [file], value: "" } };
+ await wrapper.vm.importStickers(ev);
+ await flushPromises();
+
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ "/api/v1/stickers/import",
+ expect.objectContaining({
+ format: "meshchatx-stickers",
+ version: 1,
+ replace_duplicates: true,
+ })
+ );
+ expect(ToastUtils.success).toHaveBeenCalled();
+ } finally {
+ globalThis.FileReader = OriginalFileReader;
+ }
+ });
+
+ it("clearStickers calls DELETE maintenance/stickers and refreshes count", async () => {
+ const wrapper = mountSettings();
+ await flushPromises();
+ axiosMock.get.mockClear();
+
+ await wrapper.vm.clearStickers();
+ await flushPromises();
+
+ expect(axiosMock.delete).toHaveBeenCalledWith("/api/v1/maintenance/stickers");
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/stickers");
+ expect(ToastUtils.success).toHaveBeenCalledWith("maintenance.stickers_cleared");
+ });
+});
diff --git a/tests/frontend/UIThemeAndVisibility.test.js b/tests/frontend/UIThemeAndVisibility.test.js
index 50a3f47..f6013e2 100644
--- a/tests/frontend/UIThemeAndVisibility.test.js
+++ b/tests/frontend/UIThemeAndVisibility.test.js
@@ -485,7 +485,7 @@ describe("Visibility Checks", () => {
await wrapper.vm.$nextTick();
const colorInputs = wrapper.findAll('input[type="color"]');
- expect(colorInputs.length).toBe(2);
+ expect(colorInputs.length).toBe(3);
delete window.api;
});
diff --git a/tests/frontend/setup.js b/tests/frontend/setup.js
index 53aea47..3cec5aa 100644
--- a/tests/frontend/setup.js
+++ b/tests/frontend/setup.js
@@ -1,7 +1,36 @@
+import "fake-indexeddb/auto";
+import { readFileSync, existsSync } from "fs";
+import { join } from "path";
import { vi } from "vitest";
import { config } from "@vue/test-utils";
import createDOMPurify from "dompurify";
+const EMOJI_PICKER_DATA_PATH = join(
+ process.cwd(),
+ "node_modules",
+ "emoji-picker-element-data",
+ "en",
+ "emojibase",
+ "data.json"
+);
+
+const origFetch = globalThis.fetch;
+globalThis.fetch = async (input, init) => {
+ const reqUrl = typeof input === "string" ? input : input?.url ?? "";
+ if (
+ reqUrl.includes("emoji-picker-element-data") &&
+ reqUrl.includes("data.json") &&
+ existsSync(EMOJI_PICKER_DATA_PATH)
+ ) {
+ const data = readFileSync(EMOJI_PICKER_DATA_PATH, "utf8");
+ return new Response(data, { status: 200, headers: { "Content-Type": "application/json" } });
+ }
+ if (typeof origFetch === "function") {
+ return origFetch(input, init);
+ }
+ throw new Error(`fetch not available for ${reqUrl}`);
+};
+
// Initialize DOMPurify with the jsdom window
let DOMPurify;
try {