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 DialogUtils from "@/js/DialogUtils"; import GlobalEmitter from "@/js/GlobalEmitter"; import GlobalState from "@/js/GlobalState"; const RENDER_THRESHOLD_MS = 500; 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(), }, })); describe("ConversationViewer.vue button interactions", () => { let axiosMock; beforeEach(() => { WebSocketConnection.connect(); vi.clearAllMocks(); axiosMock = { 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: {} }), 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(); }); const mountViewer = (overrides = {}) => mount(ConversationViewer, { props: { selectedPeer: { destination_hash: "a".repeat(32), display_name: "Test Peer" }, myLxmfAddressHash: "b".repeat(32), conversations: [], ...overrides, }, 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, }, }, }); it("mounts within render threshold", () => { const start = performance.now(); const wrapper = mountViewer(); const elapsed = performance.now() - start; expect(wrapper.find(".flex").exists()).toBe(true); expect(elapsed).toBeLessThan(RENDER_THRESHOLD_MS); }); it("banish button calls API when confirmed", async () => { const emitSpy = vi.spyOn(GlobalEmitter, "emit"); const wrapper = mountViewer(); await wrapper.vm.$nextTick(); await wrapper.vm.onBanishHeaderClick(); expect(DialogUtils.confirm).toHaveBeenCalled(); expect(axiosMock.post).toHaveBeenCalledWith( "/api/v1/blocked-destinations", expect.objectContaining({ destination_hash: "a".repeat(32) }) ); expect(emitSpy).toHaveBeenCalledWith("block-status-changed"); emitSpy.mockRestore(); }); it("banish button hidden when peer is blocked", async () => { GlobalState.blockedDestinations = [{ destination_hash: "a".repeat(32) }]; const wrapper = mountViewer(); await wrapper.vm.$nextTick(); wrapper.vm.checkIfSelectedPeerBlocked(); await wrapper.vm.$nextTick(); const banishBtn = wrapper.findAll("button").find((b) => b.attributes("title")?.includes("banish")); expect(banishBtn).toBeUndefined(); }); it("telemetry history modal can be opened", async () => { const wrapper = mountViewer(); await wrapper.vm.$nextTick(); wrapper.vm.isTelemetryHistoryModalOpen = true; await wrapper.vm.$nextTick(); expect(wrapper.vm.isTelemetryHistoryModalOpen).toBe(true); }); it("close button emits close", async () => { const wrapper = mountViewer(); await wrapper.vm.$nextTick(); const closeBtn = wrapper.findAll("button").find((b) => b.attributes("title") === "Close"); expect(closeBtn).toBeDefined(); await closeBtn.trigger("click"); expect(wrapper.emitted("close")).toHaveLength(1); }); it("onMessageContextMenu opens menu and Reply works", async () => { const wrapper = mountViewer(); const chatItem = { type: "lxmf_message", is_outbound: false, lxmf_message: { hash: "msg-1", content: "Hello", state: "delivered", fields: {}, }, }; wrapper.vm.chatItems = [chatItem]; await wrapper.vm.$nextTick(); const replySpy = vi.spyOn(wrapper.vm, "replyToMessage"); wrapper.vm.onMessageContextMenu({ clientX: 100, clientY: 100 }, chatItem); await wrapper.vm.$nextTick(); expect(wrapper.vm.messageContextMenu.show).toBe(true); const menuEl = Array.from(document.body.querySelectorAll(".context-menu-panel")).find( (el) => el.textContent?.includes("Reply") && el.textContent?.includes("Delete") ); expect(menuEl).toBeTruthy(); const replyBtn = menuEl?.querySelector("button"); expect(replyBtn?.textContent).toContain("Reply"); replyBtn?.click(); await wrapper.vm.$nextTick(); expect(replySpy).toHaveBeenCalledWith(chatItem); }); it("message context menu Delete calls deleteChatItem", async () => { const wrapper = mountViewer(); const chatItem = { type: "lxmf_message", is_outbound: false, lxmf_message: { hash: "msg-del", content: "Hi", state: "delivered", fields: {} }, }; wrapper.vm.chatItems = [chatItem]; await wrapper.vm.$nextTick(); const deleteSpy = vi.spyOn(wrapper.vm, "deleteChatItem"); wrapper.vm.messageContextMenu.chatItem = chatItem; wrapper.vm.messageContextMenu.show = true; await wrapper.vm.$nextTick(); const menuEl = Array.from(document.body.querySelectorAll(".context-menu-panel")).find( (el) => el.textContent?.includes("Reply") && el.textContent?.includes("Delete") ); const deleteBtn = menuEl ? Array.from(menuEl.querySelectorAll("button")).find((b) => b.textContent.includes("Delete")) : null; expect(deleteBtn).toBeTruthy(); deleteBtn?.click(); await wrapper.vm.$nextTick(); expect(deleteSpy).toHaveBeenCalledWith(chatItem); }); it("call button exists and onStartCall is callable", async () => { const wrapper = mountViewer(); expect(typeof wrapper.vm.onStartCall).toBe("function"); await wrapper.vm.onStartCall(); }); it("share contact button exists and openShareContactModal is callable", async () => { const wrapper = mountViewer(); expect(typeof wrapper.vm.openShareContactModal).toBe("function"); wrapper.vm.openShareContactModal(); }); describe("compose area: clipboard, file attachments, and toolbar actions", () => { it("add files button triggers the hidden file input click", async () => { const wrapper = mountViewer(); await wrapper.vm.$nextTick(); const fileInput = wrapper.find('input[type="file"]'); const clickSpy = vi.spyOn(fileInput.element, "click").mockImplementation(() => {}); const actionButtons = wrapper.findAll(".attachment-action-button"); await actionButtons[0].trigger("click"); expect(clickSpy).toHaveBeenCalled(); clickSpy.mockRestore(); }); it("onFileInputChange appends selected files to newMessageFiles", () => { const wrapper = mountViewer(); const f = new File(["x"], "attach.txt", { type: "text/plain" }); wrapper.vm.onFileInputChange({ target: { files: [f] } }); expect(wrapper.vm.newMessageFiles).toContain(f); }); it("removeFileAttachment removes one file from newMessageFiles", () => { const wrapper = mountViewer(); const f1 = new File(["a"], "a.txt", { type: "text/plain" }); const f2 = new File(["b"], "b.txt", { type: "text/plain" }); wrapper.vm.newMessageFiles = [f1, f2]; wrapper.vm.removeFileAttachment(f1); expect(wrapper.vm.newMessageFiles).toEqual([f2]); }); it("paste toolbar button reads clipboard text into the message field", async () => { const readText = vi.fn(() => Promise.resolve("from-clipboard")); vi.stubGlobal("navigator", { ...navigator, clipboard: { readText }, }); const wrapper = mountViewer(); await wrapper.vm.$nextTick(); const ta = wrapper.find("#message-input").element; ta.selectionStart = 0; ta.selectionEnd = 0; wrapper.vm.newMessageText = ""; const actionButtons = wrapper.findAll(".attachment-action-button"); await actionButtons[1].trigger("click"); await vi.waitFor(() => expect(wrapper.vm.newMessageText).toBe("from-clipboard")); }); it("translateMessage replaces text when the translator API succeeds", async () => { axiosMock.post.mockImplementation((url) => { if (url.includes("/translator/translate")) { return Promise.resolve({ data: { translated_text: "translated" } }); } return Promise.resolve({ data: {} }); }); const wrapper = mountViewer(); wrapper.vm.newMessageText = "hello"; await wrapper.vm.translateMessage(); expect(wrapper.vm.newMessageText).toBe("translated"); expect(axiosMock.post).toHaveBeenCalledWith( "/api/v1/translator/translate", expect.objectContaining({ text: "hello", target_lang: "en", }) ); }); it("generatePaperMessageFromComposition sends lxm.generate_paper_uri over the websocket", async () => { const sendSpy = vi.spyOn(WebSocketConnection, "send").mockImplementation(() => {}); const wrapper = mountViewer(); wrapper.vm.newMessageText = "paper body"; await wrapper.vm.generatePaperMessageFromComposition(); expect(sendSpy).toHaveBeenCalled(); const payload = JSON.parse(sendSpy.mock.calls[0][0]); expect(payload.type).toBe("lxm.generate_paper_uri"); expect(payload.destination_hash).toBe("a".repeat(32)); expect(payload.content).toBe("paper body"); sendSpy.mockRestore(); }); it("requestLocation posts a telemetry request command", async () => { const wrapper = mountViewer(); await wrapper.vm.requestLocation(); expect(axiosMock.post).toHaveBeenCalledWith( "/api/v1/lxmf-messages/send", expect.objectContaining({ lxmf_message: expect.objectContaining({ destination_hash: "a".repeat(32), fields: { commands: [{ "0x01": expect.any(Number) }], }, }), }) ); }); it("shareLocation with manual coordinates sets telemetry and calls sendMessage", async () => { const wrapper = mountViewer({ config: { location_source: "manual", location_manual_lat: "12.34", location_manual_lon: "56.78", location_manual_alt: "9", }, }); const sendSpy = vi.spyOn(wrapper.vm, "sendMessage").mockResolvedValue(undefined); await wrapper.vm.shareLocation(); expect(sendSpy).toHaveBeenCalled(); expect(wrapper.vm.newMessageTelemetry).toMatchObject({ latitude: 12.34, longitude: 56.78, altitude: 9, }); sendSpy.mockRestore(); }); it("openConversationPopout opens a popout URL with the peer hash", () => { const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); const wrapper = mountViewer(); wrapper.vm.openConversationPopout(); expect(openSpy).toHaveBeenCalledWith( expect.stringContaining(encodeURIComponent("a".repeat(32))), "_blank", expect.stringContaining("width=") ); openSpy.mockRestore(); }); }); });