mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-05-08 13:45:12 +00:00
feat(tests): add end-to-end tests for Conversations and Nomad Network, enhancing UI coverage and interaction validation
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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: "<div class=\"cv-stub\"></div>",
|
||||
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: '<div class="mdi-stub"><slot /></div>' },
|
||||
},
|
||||
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: '<div class="mdi-stub"><slot /></div>' },
|
||||
},
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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</span>");
|
||||
expect(html).toContain("class='Mu-mnt'>i</span>");
|
||||
expect(html).toContain("Mu-mnt-group");
|
||||
expect(html).not.toContain("class='Mu-mnt'>H</span>");
|
||||
expect(html).toContain("class='Mu-mnt-full'>\u4e2d</span>");
|
||||
expect(html).toContain("class='Mu-mnt-full'>\u6587</span>");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "<span>x</span>" };
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user