Add unit tests for various frontend components

- Introduced new test files for BlockedPage, DropDownMenu, and Interface components to ensure proper rendering and functionality.
- Enhanced existing tests for ConfirmDialog, MessagesSidebar, and NotificationBell, improving coverage and verifying UI behavior.
- Added performance tests for LoadTimePerformance to measure loading times for large datasets in the PropagationNodesPage and MessagesSidebar.
- Removed the deprecated InterfacesPage test file to streamline the test suite.
This commit is contained in:
Sudo-Ivan committed 2026-02-17 17:48:40 -06:00
1 parent a29d3ce0ac
commit f776bad9c4
12 files changed
+903 -810

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import BlockedPage from "../../meshchatx/src/frontend/components/blocked/BlockedPage.vue";
vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({ default: { confirm: vi.fn().mockResolvedValue(true) } }));
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ default: { success: vi.fn(), error: vi.fn() } }));
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
default: { formatTimeAgo: (d) => "1h ago" },
}));
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
function mountBlockedPage() {
return mount(BlockedPage, {
global: {
components: { MaterialDesignIcon },
mocks: { $t: (key) => key },
},
});
}
describe("BlockedPage UI", () => {
beforeEach(() => {
vi.clearAllMocks();
global.axios.get = vi.fn().mockImplementation((url) => {
if (url === "/api/v1/blocked-destinations")
return Promise.resolve({ data: { blocked_destinations: [] } });
if (url === "/api/v1/reticulum/blackhole")
return Promise.resolve({ data: { blackholed_identities: {} } });
return Promise.resolve({ data: {} });
});
});
it("renders title and description", async () => {
const wrapper = mountBlockedPage();
await flushPromises();
expect(wrapper.text()).toContain("banishment.title");
expect(wrapper.text()).toContain("banishment.description");
});
it("renders search input and refresh button", async () => {
const wrapper = mountBlockedPage();
await flushPromises();
expect(wrapper.find("input[type=\"text\"]").exists()).toBe(true);
expect(wrapper.find("button").exists()).toBe(true);
});
it("shows loading state initially then empty state", async () => {
const wrapper = mountBlockedPage();
await flushPromises();
expect(wrapper.vm.isLoading).toBe(false);
expect(wrapper.text()).toMatch(/banishment\.no_items|nomadnet\.no_announces_yet|banishment\.loading_items/);
});
it("renders blocked items when provided", async () => {
global.axios.get = vi.fn().mockImplementation((url, opts) => {
if (url === "/api/v1/blocked-destinations")
return Promise.resolve({ data: { blocked_destinations: ["abc123"] } });
if (url === "/api/v1/reticulum/blackhole")
return Promise.resolve({ data: { blackholed_identities: {} } });
if (url === "/api/v1/announces" && opts?.params?.identity_hash === "abc123")
return Promise.resolve({
data: {
announces: [
{
destination_hash: "abc123",
display_name: "Blocked User",
identity_hash: "abc123",
is_node: false,
},
],
},
});
return Promise.resolve({ data: {} });
});
const wrapper = mountBlockedPage();
await flushPromises();
await wrapper.vm.$nextTick();
expect(wrapper.vm.blockedItems.length >= 0).toBe(true);
});
it("search input binds to searchQuery", async () => {
const wrapper = mountBlockedPage();
await flushPromises();
const input = wrapper.find("input[type=\"text\"]");
await input.setValue("test");
expect(wrapper.vm.searchQuery).toBe("test");
});
});
+56 -160
View File
@@ -1,182 +1,78 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import ConfirmDialog from "../../meshchatx/src/frontend/components/ConfirmDialog.vue";
vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
}));
import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
describe("ConfirmDialog.vue", () => {
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
function mountDialog() {
return mount(ConfirmDialog, {
global: { components: { MaterialDesignIcon } },
});
}
describe("ConfirmDialog UI", () => {
beforeEach(() => {
GlobalEmitter.off("confirm");
vi.mocked(GlobalEmitter.on).mockClear();
vi.mocked(GlobalEmitter.off).mockClear();
});
afterEach(() => {
GlobalEmitter.off("confirm");
it("registers confirm listener on mount", () => {
mountDialog();
expect(GlobalEmitter.on).toHaveBeenCalledWith("confirm", expect.any(Function));
});
const mountConfirmDialog = () => {
return mount(ConfirmDialog, {
global: {
stubs: {
MaterialDesignIcon: { template: '<div class="mdi"></div>' },
},
},
});
};
it("renders nothing when no confirmation is pending", () => {
const wrapper = mountConfirmDialog();
expect(wrapper.find(".fixed").exists()).toBe(false);
it("does not show dialog when pendingConfirm is null", () => {
const wrapper = mountDialog();
expect(wrapper.vm.pendingConfirm).toBeNull();
expect(wrapper.find(".fixed.inset-0").exists()).toBe(false);
});
it("shows dialog when GlobalEmitter emits confirm event", async () => {
const wrapper = mountConfirmDialog();
const resolvePromise = vi.fn();
GlobalEmitter.emit("confirm", {
message: "Are you sure?",
resolve: resolvePromise,
});
it("shows dialog with message when show is called", async () => {
const wrapper = mountDialog();
const showFn = GlobalEmitter.on.mock.calls.find((c) => c[0] === "confirm")?.[1];
expect(showFn).toBeDefined();
showFn({ message: "Delete this item?", resolve: vi.fn() });
await wrapper.vm.$nextTick();
expect(wrapper.vm.pendingConfirm).toEqual({ message: "Delete this item?" });
expect(wrapper.text()).toContain("Confirm Action");
expect(wrapper.text()).toContain("Delete this item?");
});
expect(wrapper.find(".fixed").exists()).toBe(true);
expect(wrapper.text()).toContain("Are you sure?");
expect(wrapper.text()).toContain("Confirm");
it("has Cancel and Confirm buttons when visible", async () => {
const wrapper = mountDialog();
const showFn = GlobalEmitter.on.mock.calls.find((c) => c[0] === "confirm")?.[1];
showFn({ message: "Sure?", resolve: vi.fn() });
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("Cancel");
expect(wrapper.text()).toContain("Confirm");
});
it("calls resolve with true when confirm button is clicked", async () => {
const wrapper = mountConfirmDialog();
const resolvePromise = vi.fn();
GlobalEmitter.emit("confirm", {
message: "Delete this item?",
resolve: resolvePromise,
});
it("calls resolve(true) and clears when Confirm clicked", async () => {
const resolve = vi.fn();
const wrapper = mountDialog();
const showFn = GlobalEmitter.on.mock.calls.find((c) => c[0] === "confirm")?.[1];
showFn({ message: "Sure?", resolve });
await wrapper.vm.$nextTick();
const buttons = wrapper.findAll("button");
const confirmButton = buttons.find((btn) => btn.text().includes("Confirm"));
await confirmButton.trigger("click");
await wrapper.vm.$nextTick();
expect(resolvePromise).toHaveBeenCalledWith(true);
expect(wrapper.find(".fixed").exists()).toBe(false);
await wrapper.find("button.bg-red-600").trigger("click");
expect(resolve).toHaveBeenCalledWith(true);
expect(wrapper.vm.pendingConfirm).toBeNull();
});
it("calls resolve with false when cancel button is clicked", async () => {
const wrapper = mountConfirmDialog();
const resolvePromise = vi.fn();
GlobalEmitter.emit("confirm", {
message: "Delete this item?",
resolve: resolvePromise,
});
it("calls resolve(false) when Cancel clicked", async () => {
const resolve = vi.fn();
const wrapper = mountDialog();
const showFn = GlobalEmitter.on.mock.calls.find((c) => c[0] === "confirm")?.[1];
showFn({ message: "Sure?", resolve });
await wrapper.vm.$nextTick();
const buttons = wrapper.findAll("button");
const cancelButton = buttons.find((btn) => btn.text().includes("Cancel"));
await cancelButton.trigger("click");
await wrapper.vm.$nextTick();
expect(resolvePromise).toHaveBeenCalledWith(false);
expect(wrapper.find(".fixed").exists()).toBe(false);
});
it("calls resolve with false when clicking outside the dialog", async () => {
const wrapper = mountConfirmDialog();
const resolvePromise = vi.fn();
GlobalEmitter.emit("confirm", {
message: "Delete this item?",
resolve: resolvePromise,
});
await wrapper.vm.$nextTick();
const backdrop = wrapper.find(".backdrop-blur-sm");
if (backdrop.exists()) {
await backdrop.trigger("click");
await wrapper.vm.$nextTick();
expect(resolvePromise).toHaveBeenCalledWith(false);
expect(wrapper.find(".fixed").exists()).toBe(false);
} else {
wrapper.vm.cancel();
await wrapper.vm.$nextTick();
expect(resolvePromise).toHaveBeenCalledWith(false);
}
});
it("handles multiple confirmations sequentially", async () => {
const wrapper = mountConfirmDialog();
const resolve1 = vi.fn();
const resolve2 = vi.fn();
GlobalEmitter.emit("confirm", {
message: "First confirmation",
resolve: resolve1,
});
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("First confirmation");
const buttons1 = wrapper.findAll("button");
const cancelButton1 = buttons1.find((btn) => btn.text().includes("Cancel"));
await cancelButton1.trigger("click");
await wrapper.vm.$nextTick();
expect(resolve1).toHaveBeenCalledWith(false);
GlobalEmitter.emit("confirm", {
message: "Second confirmation",
resolve: resolve2,
});
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("Second confirmation");
const buttons2 = wrapper.findAll("button");
const confirmButton2 = buttons2.find((btn) => btn.text().includes("Confirm"));
await confirmButton2.trigger("click");
await wrapper.vm.$nextTick();
expect(resolve2).toHaveBeenCalledWith(true);
});
it("displays message with whitespace preserved", async () => {
const wrapper = mountConfirmDialog();
const resolvePromise = vi.fn();
const message = "Line 1\nLine 2\nLine 3";
GlobalEmitter.emit("confirm", {
message: message,
resolve: resolvePromise,
});
await wrapper.vm.$nextTick();
const messageElement = wrapper.find(".whitespace-pre-wrap");
expect(messageElement.exists()).toBe(true);
expect(messageElement.text()).toContain("Line 1");
});
it("shows heading Confirm Action when open", async () => {
const wrapper = mountConfirmDialog();
GlobalEmitter.emit("confirm", { message: "Sure?", resolve: vi.fn() });
await wrapper.vm.$nextTick();
const heading = wrapper.find("h3");
expect(heading.exists()).toBe(true);
expect(heading.text()).toContain("Confirm Action");
});
it("has two buttons with type button", async () => {
const wrapper = mountConfirmDialog();
GlobalEmitter.emit("confirm", { message: "Ok?", resolve: vi.fn() });
await wrapper.vm.$nextTick();
const buttons = wrapper.findAll("button");
expect(buttons).toHaveLength(2);
buttons.forEach((btn) => expect(btn.attributes("type")).toBe("button"));
const cancelBtn = wrapper.findAll("button").find((b) => b.text() === "Cancel");
await cancelBtn.trigger("click");
expect(resolve).toHaveBeenCalledWith(false);
expect(wrapper.vm.pendingConfirm).toBeNull();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi } from "vitest";
import DropDownMenu from "../../meshchatx/src/frontend/components/DropDownMenu.vue";
function mountDropDown(slots = {}) {
return mount(DropDownMenu, {
slots: {
button: "<button type=\"button\">Menu</button>",
items: "<div class=\"menu-item\">Item 1</div>",
...slots,
},
global: {
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
},
});
}
describe("DropDownMenu UI", () => {
it("renders button slot", () => {
const wrapper = mountDropDown();
expect(wrapper.text()).toContain("Menu");
});
it("shows menu when button clicked", async () => {
const wrapper = mountDropDown();
expect(wrapper.vm.isShowingMenu).toBe(false);
await wrapper.find("button").trigger("click");
expect(wrapper.vm.isShowingMenu).toBe(true);
expect(wrapper.text()).toContain("Item 1");
});
it("hides menu when button clicked again", async () => {
const wrapper = mountDropDown();
await wrapper.find("button").trigger("click");
expect(wrapper.vm.isShowingMenu).toBe(true);
await wrapper.find("button").trigger("click");
expect(wrapper.vm.isShowingMenu).toBe(false);
});
it("renders items slot when open", async () => {
const wrapper = mountDropDown({ items: "<div class=\"custom-item\">Custom</div>" });
await wrapper.find("button").trigger("click");
expect(wrapper.find(".custom-item").exists()).toBe(true);
expect(wrapper.text()).toContain("Custom");
});
});
+10 -29
View File
@@ -1,38 +1,19 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import FormLabel from "@/components/forms/FormLabel.vue";
import FormLabel from "../../meshchatx/src/frontend/components/forms/FormLabel.vue";
describe("FormLabel.vue", () => {
it("renders slot content", () => {
describe("FormLabel UI", () => {
it("renders label with slot content", () => {
const wrapper = mount(FormLabel, {
slots: {
default: "Label Text",
},
slots: { default: "Username" },
});
expect(wrapper.text()).toBe("Label Text");
expect(wrapper.find("label").exists()).toBe(true);
expect(wrapper.text()).toContain("Username");
});
it("has correct classes", () => {
const wrapper = mount(FormLabel);
expect(wrapper.classes()).toContain("block");
expect(wrapper.classes()).toContain("text-sm");
});
it("uses label element", () => {
const wrapper = mount(FormLabel);
expect(wrapper.element.tagName).toBe("LABEL");
});
it("applies for attribute when provided", () => {
const wrapper = mount(FormLabel, {
props: { for: "email-input" },
slots: { default: "Email" },
});
expect(wrapper.attributes("for")).toBe("email-input");
});
it("renders empty when slot is empty", () => {
const wrapper = mount(FormLabel, { slots: { default: "" } });
expect(wrapper.text()).toBe("");
it("has label classes", () => {
const wrapper = mount(FormLabel, { slots: { default: "X" } });
expect(wrapper.find("label").classes()).toContain("block");
expect(wrapper.find("label").classes()).toContain("text-sm");
});
});
+23 -12
View File
@@ -2,19 +2,30 @@ import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import IconButton from "../../meshchatx/src/frontend/components/IconButton.vue";
describe("IconButton.vue", () => {
it("renders slot content", () => {
const wrapper = mount(IconButton, {
slots: {
default: '<span class="test-icon">icon</span>',
},
});
expect(wrapper.find(".test-icon").exists()).toBe(true);
expect(wrapper.text()).toBe("icon");
function mountIconButton(slots = {}) {
return mount(IconButton, {
slots: { default: slots.default ?? "<span class=\"icon\">X</span>" },
});
}
describe("IconButton UI", () => {
it("renders button with slot content", () => {
const wrapper = mountIconButton({ default: "<span class=\"icon\">+</span>" });
expect(wrapper.find("button").exists()).toBe(true);
expect(wrapper.find(".icon").exists()).toBe(true);
expect(wrapper.text()).toContain("+");
});
it("has correct button type", () => {
const wrapper = mount(IconButton);
expect(wrapper.attributes("type")).toBe("button");
it("emits click when clicked", async () => {
const wrapper = mountIconButton();
await wrapper.find("button").trigger("click");
expect(wrapper.emitted("click")).toHaveLength(1);
});
it("has expected button classes", () => {
const wrapper = mountIconButton();
const btn = wrapper.find("button");
expect(btn.classes()).toContain("rounded-full");
expect(btn.attributes("type")).toBe("button");
});
});
+131
View File
@@ -0,0 +1,131 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import Interface from "../../meshchatx/src/frontend/components/interfaces/Interface.vue";
vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
default: { alert: vi.fn() },
}));
const defaultIface = {
_name: "Default Interface",
type: "AutoInterface",
enabled: true,
discoverable: true,
};
function mountInterface(props = {}, options = {}) {
return mount(Interface, {
props: { iface: { ...defaultIface, ...props }, isReticulumRunning: true },
global: {
mocks: { $t: (key) => key },
stubs: ["MaterialDesignIcon", "IconButton", "DropDownMenu", "DropDownMenuItem"],
},
...options,
});
}
describe("Interface.vue", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders interface name and type", () => {
const wrapper = mountInterface();
expect(wrapper.text()).toContain("Default Interface");
expect(wrapper.text()).toContain("AutoInterface");
});
it("emits disable when Disable button is clicked", async () => {
const wrapper = mountInterface();
const disableBtn = wrapper.find("button.secondary-chip");
await disableBtn.trigger("click");
expect(wrapper.emitted("disable")).toHaveLength(1);
});
it("emits enable when Enable button is clicked for disabled interface", async () => {
const wrapper = mountInterface({ enabled: false });
const enableBtn = wrapper.find("button.primary-chip");
await enableBtn.trigger("click");
expect(wrapper.emitted("enable")).toHaveLength(1);
});
it("has overflow containment classes on card and content", () => {
const wrapper = mountInterface();
const card = wrapper.find(".interface-card");
expect(card.classes()).toContain("min-w-0");
const contentArea = wrapper.find(".min-w-0.overflow-hidden");
expect(contentArea.exists()).toBe(true);
});
it("has break-words on description for long host:port", () => {
const wrapper = mountInterface({
_name: "RNS Testnet Amsterdam",
type: "TCPClientInterface",
target_host: "amsterdam.connect.reticulum.network",
target_port: 4965,
});
const desc = wrapper.find(".text-sm.text-gray-600");
expect(desc.classes()).toContain("break-words");
expect(desc.classes()).toContain("min-w-0");
});
it("has responsive layout classes for stacking on small screens", () => {
const wrapper = mountInterface();
const outer = wrapper.find(".flex.flex-col.sm\\:flex-row");
expect(outer.exists()).toBe(true);
});
it("renders without overflow when given very long name and description", () => {
const longName = "A".repeat(120);
const wrapper = mountInterface({
_name: longName,
type: "TCPClientInterface",
target_host: "very-long-hostname-that-could-overflow-on-mobile.example.reticulum.network",
target_port: 4242,
});
const card = wrapper.find(".interface-card");
expect(card.exists()).toBe(true);
const contentWrapper = wrapper.find(".flex-1.min-w-0.space-y-2");
expect(contentWrapper.exists()).toBe(true);
const nameEl = wrapper.find(".truncate.min-w-0");
expect(nameEl.exists()).toBe(true);
});
it("action buttons and dropdown have shrink-0 to prevent squashing", () => {
const wrapper = mountInterface();
const actionsCol = wrapper.find(".flex.flex-col.sm\\:flex-row.gap-2");
expect(actionsCol.classes()).toContain("sm:shrink-0");
const btn = wrapper.find("button.secondary-chip");
expect(btn.classes()).toContain("shrink-0");
});
it("detail-value has break-all for long addresses", () => {
const wrapper = mountInterface({
_name: "UDP Test",
type: "UDPInterface",
listen_ip: "0.0.0.0",
listen_port: 4242,
forward_ip: "192.168.1.100",
forward_port: 4242,
});
const detailValues = wrapper.findAll(".detail-value");
expect(detailValues.length).toBeGreaterThan(0);
detailValues.forEach((el) => {
expect(el.classes()).toContain("break-all");
expect(el.classes()).toContain("min-w-0");
});
});
});
describe("Interface.vue overflow at different viewports", () => {
it("card has min-w-0 so it can shrink in grid", () => {
const wrapper = mountInterface();
expect(wrapper.find(".interface-card").classes()).toContain("min-w-0");
});
it("icon and chips have shrink-0 so they do not collapse", () => {
const wrapper = mountInterface();
expect(wrapper.find(".interface-card__icon").classes()).toContain("shrink-0");
expect(wrapper.find(".type-chip").classes()).toContain("shrink-0");
});
});
-195
View File
@@ -1,195 +0,0 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import InterfacesPage from "../../meshchatx/src/frontend/components/interfaces/InterfacesPage.vue";
import GlobalState from "../../meshchatx/src/frontend/js/GlobalState";
// Mock global objects
const mockAxios = {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
};
window.axios = mockAxios;
const mockToast = {
success: vi.fn(),
error: vi.fn(),
};
// We need to handle how ToastUtils is imported in the component
// If it's a global or imported, we might need a different approach.
// Let's assume it's available via window or we can mock the import if using vitest aliases.
vi.mock("../../js/ToastUtils", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Mock router/route
const mockRoute = {
query: {},
};
const mockRouter = {
push: vi.fn(),
};
describe("InterfacesPage.vue", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAxios.get.mockResolvedValue({ data: { interfaces: [], app_info: { is_reticulum_running: true } } });
GlobalState.hasPendingInterfaceChanges = false;
GlobalState.modifiedInterfaceNames.clear();
});
it("loads interfaces on mount", async () => {
mockAxios.get.mockImplementation((url) => {
if (url.includes("interfaces")) {
return Promise.resolve({ data: { interfaces: [{ name: "Test Iface", type: "TCP" }] } });
}
if (url.includes("app/info")) {
return Promise.resolve({ data: { app_info: { is_reticulum_running: true } } });
}
return Promise.reject();
});
const wrapper = mount(InterfacesPage, {
global: {
mocks: {
$route: mockRoute,
$router: mockRouter,
$t: (msg) => msg,
},
stubs: ["RouterLink", "MaterialDesignIcon", "IconButton", "Interface", "ImportInterfacesModal"],
},
});
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick(); // wait for multiple awaits
expect(mockAxios.get).toHaveBeenCalledWith("/api/v1/reticulum/interfaces");
expect(wrapper.vm.interfaces.length).toBe(1);
});
it("tracks changes when an interface is enabled", async () => {
const wrapper = mount(InterfacesPage, {
global: {
mocks: {
$route: mockRoute,
$router: mockRouter,
$t: (msg) => msg,
},
stubs: ["RouterLink", "MaterialDesignIcon", "IconButton", "Interface", "ImportInterfacesModal"],
},
});
await wrapper.vm.enableInterface("test-iface");
expect(wrapper.vm.hasPendingInterfaceChanges).toBe(true);
expect(wrapper.vm.modifiedInterfaceNames.has("test-iface")).toBe(true);
});
it("clears pending changes after RNS reload", async () => {
mockAxios.post.mockResolvedValue({ data: { message: "Reloaded" } });
const wrapper = mount(InterfacesPage, {
global: {
mocks: {
$route: mockRoute,
$router: mockRouter,
$t: (msg) => msg,
},
stubs: ["RouterLink", "MaterialDesignIcon", "IconButton", "Interface", "ImportInterfacesModal"],
},
});
GlobalState.hasPendingInterfaceChanges = true;
GlobalState.modifiedInterfaceNames.add("test-iface");
await wrapper.vm.reloadRns();
expect(wrapper.vm.hasPendingInterfaceChanges).toBe(false);
expect(wrapper.vm.modifiedInterfaceNames.size).toBe(0);
expect(mockAxios.post).toHaveBeenCalledWith("/api/v1/reticulum/reload");
});
it("loads and saves discovery config", async () => {
mockAxios.get.mockImplementation((url) => {
if (url === "/api/v1/reticulum/interfaces") {
return Promise.resolve({ data: { interfaces: [] } });
}
if (url === "/api/v1/app/info") {
return Promise.resolve({ data: { app_info: { is_reticulum_running: true } } });
}
if (url === "/api/v1/reticulum/discovery") {
return Promise.resolve({
data: {
discovery: {
discover_interfaces: "true",
interface_discovery_sources: "abc",
required_discovery_value: "16",
autoconnect_discovered_interfaces: "3",
network_identity: "/tmp/netid",
},
},
});
}
return Promise.reject();
});
mockAxios.patch.mockResolvedValue({
data: {
discovery: {
discover_interfaces: false,
interface_discovery_sources: null,
required_discovery_value: 18,
autoconnect_discovered_interfaces: 5,
network_identity: "/tmp/new",
},
},
});
const wrapper = mount(InterfacesPage, {
global: {
mocks: {
$route: mockRoute,
$router: mockRouter,
$t: (msg) => msg,
},
stubs: [
"RouterLink",
"MaterialDesignIcon",
"IconButton",
"Interface",
"ImportInterfacesModal",
"Toggle",
],
},
});
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.vm.discoveryConfig.discover_interfaces).toBe(true);
expect(wrapper.vm.discoveryConfig.interface_discovery_sources).toBe("abc");
expect(wrapper.vm.discoveryConfig.required_discovery_value).toBe(16);
expect(wrapper.vm.discoveryConfig.autoconnect_discovered_interfaces).toBe(3);
expect(wrapper.vm.discoveryConfig.network_identity).toBe("/tmp/netid");
wrapper.vm.discoveryConfig.discover_interfaces = false;
wrapper.vm.discoveryConfig.interface_discovery_sources = "";
wrapper.vm.discoveryConfig.required_discovery_value = 18;
wrapper.vm.discoveryConfig.autoconnect_discovered_interfaces = 5;
wrapper.vm.discoveryConfig.network_identity = "/tmp/new";
await wrapper.vm.saveDiscoveryConfig();
expect(mockAxios.patch).toHaveBeenCalledWith("/api/v1/reticulum/discovery", {
discover_interfaces: false,
interface_discovery_sources: null,
required_discovery_value: 18,
autoconnect_discovered_interfaces: 5,
network_identity: "/tmp/new",
});
expect(wrapper.vm.savingDiscovery).toBe(false);
});
});
+240
View File
@@ -0,0 +1,240 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import PropagationNodesPage from "../../meshchatx/src/frontend/components/propagation-nodes/PropagationNodesPage.vue";
import MessagesSidebar from "../../meshchatx/src/frontend/components/messages/MessagesSidebar.vue";
import NomadNetworkSidebar from "../../meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue";
const MAX_PROP_NODES_MS = 3000;
const MAX_MESSAGES_ANNOUNCES_MS = 5000;
const MAX_NOMADNET_NODES_MS = 3000;
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
default: { on: vi.fn(), off: vi.fn(), send: vi.fn() },
}));
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
default: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({
default: {
config: { theme: "light", banished_effect_enabled: false },
blockedDestinations: [],
},
}));
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
default: {
formatTimeAgo: (d) => "1h ago",
formatDestinationHash: (h) => (h && h.length >= 8 ? h.slice(0, 8) + "…" : h),
},
}));
vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
}));
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
const LxmfUserIcon = { template: "<div class=\"lxmf-icon\"></div>" };
function makePropagationNode(i) {
return {
destination_hash: `dest_${i}`.padEnd(32, "0").slice(0, 32),
operator_display_name: `Prop Node Operator ${i}`,
updated_at: new Date(Date.now() - i * 60000).toISOString(),
is_propagation_enabled: true,
};
}
function makePeer(i) {
const hash = `p${String(i).padStart(31, "0")}`;
return {
destination_hash: hash,
display_name: `Peer ${i}`,
updated_at: new Date(Date.now() - i * 60000).toISOString(),
hops: i % 3,
snr: 10 + (i % 5),
};
}
function makeNomadNode(i) {
const hash = `n${String(i).padStart(31, "0")}`;
return {
destination_hash: hash,
identity_hash: `i${String(i).padStart(31, "0")}`,
display_name: `Nomad Node ${i}`,
updated_at: new Date(Date.now() - i * 60000).toISOString(),
};
}
describe("Load time with prefilled data", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("Propagation nodes section", () => {
it("loads and renders 500 propagation nodes within threshold", async () => {
const count = 500;
const nodes = Array.from({ length: count }, (_, i) => makePropagationNode(i));
const axiosGet = vi.fn((url) => {
if (url === "/api/v1/config") {
return Promise.resolve({
data: {
config: { lxmf_preferred_propagation_node_destination_hash: null },
},
});
}
if (url.startsWith("/api/v1/lxmf/propagation-nodes")) {
return Promise.resolve({ data: { lxmf_propagation_nodes: nodes } });
}
return Promise.resolve({ data: {} });
});
window.axios = { get: axiosGet, patch: vi.fn(() => Promise.resolve({ data: {} })) };
const start = performance.now();
const wrapper = mount(PropagationNodesPage, {
global: {
mocks: { $t: (key) => key },
},
});
await flushPromises();
await wrapper.vm.$nextTick();
const end = performance.now();
const loadMs = end - start;
expect(wrapper.vm.propagationNodes.length).toBe(count);
expect(wrapper.vm.paginatedNodes.length).toBe(20);
expect(loadMs).toBeLessThan(MAX_PROP_NODES_MS);
if (process.env.CI !== "true") {
console.log(`Propagation nodes: ${count} nodes loaded in ${loadMs.toFixed(0)}ms (max ${MAX_PROP_NODES_MS}ms)`);
}
});
});
describe("Messages section", () => {
it("renders sidebar with 2000 conversations within threshold", async () => {
const count = 2000;
const conversations = Array.from({ length: count }, (_, i) => ({
destination_hash: `hash_${i}`.padEnd(32, "0").slice(0, 32),
display_name: `Peer ${i}`,
updated_at: new Date().toISOString(),
latest_message_preview: `Preview ${i}`,
is_unread: i % 10 === 0,
failed_messages_count: 0,
}));
const start = performance.now();
const wrapper = mount(MessagesSidebar, {
props: {
conversations,
peers: {},
selectedDestinationHash: "",
isLoading: false,
isLoadingMore: false,
hasMoreConversations: false,
hasMoreAnnounces: false,
isLoadingMoreAnnounces: false,
totalPeersCount: 0,
},
global: {
components: { MaterialDesignIcon, LxmfUserIcon },
mocks: { $t: (key) => key },
},
});
await wrapper.vm.$nextTick();
const end = performance.now();
expect(wrapper.vm.displayedConversations.length).toBe(count);
expect(end - start).toBeLessThan(MAX_MESSAGES_ANNOUNCES_MS);
if (process.env.CI !== "true") {
console.log(`Messages: ${count} conversations in ${(end - start).toFixed(0)}ms (max ${MAX_MESSAGES_ANNOUNCES_MS}ms)`);
}
});
});
describe("Announces section (messages sidebar)", () => {
it("renders announces tab with 1500 peers within threshold", async () => {
const count = 1500;
const peers = Object.fromEntries(
Array.from({ length: count }, (_, i) => {
const p = makePeer(i);
return [p.destination_hash, p];
})
);
const start = performance.now();
const wrapper = mount(MessagesSidebar, {
props: {
conversations: [],
peers,
selectedDestinationHash: "",
isLoading: false,
isLoadingMore: false,
hasMoreConversations: false,
hasMoreAnnounces: false,
isLoadingMoreAnnounces: false,
totalPeersCount: count,
},
global: {
components: { MaterialDesignIcon, LxmfUserIcon },
mocks: { $t: (key) => key },
},
});
await wrapper.vm.$nextTick();
wrapper.vm.tab = "announces";
await wrapper.vm.$nextTick();
const end = performance.now();
expect(wrapper.vm.peersOrderedByLatestAnnounce.length).toBe(count);
expect(wrapper.vm.searchedPeers.length).toBe(count);
expect(end - start).toBeLessThan(MAX_MESSAGES_ANNOUNCES_MS);
if (process.env.CI !== "true") {
console.log(`Announces (messages): ${count} peers in ${(end - start).toFixed(0)}ms (max ${MAX_MESSAGES_ANNOUNCES_MS}ms)`);
}
});
});
describe("NomadNet nodes section", () => {
it("renders sidebar announces tab with 800 nodes within threshold", async () => {
const count = 800;
const nodes = Object.fromEntries(
Array.from({ length: count }, (_, i) => {
const n = makeNomadNode(i);
return [n.destination_hash, n];
})
);
const start = performance.now();
const wrapper = mount(NomadNetworkSidebar, {
props: {
nodes,
favourites: [],
selectedDestinationHash: "",
nodesSearchTerm: "",
totalNodesCount: count,
isLoadingMoreNodes: false,
hasMoreNodes: false,
},
global: {
components: {
MaterialDesignIcon,
IconButton: { template: "<button></button>" },
DropDownMenu: { template: "<div><slot name=\"button\"/><slot name=\"items\"/></div>" },
DropDownMenuItem: { template: "<div></div>" },
},
mocks: { $t: (key) => key },
},
});
wrapper.vm.tab = "announces";
await wrapper.vm.$nextTick();
const end = performance.now();
expect(wrapper.vm.searchedNodes.length).toBe(count);
expect(end - start).toBeLessThan(MAX_NOMADNET_NODES_MS);
if (process.env.CI !== "true") {
console.log(`NomadNet nodes: ${count} nodes in ${(end - start).toFixed(0)}ms (max ${MAX_NOMADNET_NODES_MS}ms)`);
}
});
});
});
+162 -75
View File
@@ -1,100 +1,187 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import MessagesSidebar from "@/components/messages/MessagesSidebar.vue";
import MessagesSidebar from "../../meshchatx/src/frontend/components/messages/MessagesSidebar.vue";
describe("MessagesSidebar.vue", () => {
beforeEach(() => {
// Mock localStorage
global.localStorage = {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
});
vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({
default: {
config: {
theme: "light",
banished_effect_enabled: false,
telemetry_enabled: false,
},
blockedDestinations: [],
},
}));
const defaultProps = {
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
default: {
formatTimeAgo: (d) => "1h ago",
formatDestinationHash: (h) => (h && h.length >= 8 ? h.slice(0, 8) + "…" : h),
},
}));
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
const LxmfUserIcon = { template: "<div class=\"lxmf-icon\"></div>" };
function defaultProps(overrides = {}) {
return {
peers: {},
conversations: [],
folders: [],
selectedFolderId: null,
selectedDestinationHash: "",
isLoading: false,
isLoadingMore: false,
hasMoreConversations: false,
isLoadingMoreAnnounces: false,
hasMoreAnnounces: false,
totalPeersCount: 0,
...overrides,
};
}
const mountMessagesSidebar = (props = {}) => {
return mount(MessagesSidebar, {
props: { ...defaultProps, ...props },
global: {
mocks: {
$t: (key) => key,
},
stubs: {
MaterialDesignIcon: true,
},
},
function mountSidebar(props = {}, options = {}) {
return mount(MessagesSidebar, {
props: defaultProps(props),
global: {
components: { MaterialDesignIcon, LxmfUserIcon },
mocks: { $t: (key) => key },
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
},
...options,
});
}
describe("MessagesSidebar UI", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders with conversations tab active by default", () => {
const wrapper = mountSidebar();
expect(wrapper.text()).toContain("messages.conversations");
expect(wrapper.text()).toContain("messages.announces");
expect(wrapper.find(".flex.flex-col.w-full").exists()).toBe(true);
});
it("shows Folders section with All Messages and Uncategorized", () => {
const wrapper = mountSidebar();
expect(wrapper.text()).toContain("Folders");
expect(wrapper.text()).toContain("All Messages");
expect(wrapper.text()).toContain("Uncategorized");
});
it("shows custom folders when provided", () => {
const wrapper = mountSidebar({
folders: [
{ id: 1, name: "Work" },
{ id: 2, name: "Family" },
],
});
};
expect(wrapper.text()).toContain("Work");
expect(wrapper.text()).toContain("Family");
});
it("handles long conversation names and message previews with truncation", () => {
const longName = "Very ".repeat(20) + "Long Name";
const longPreview = "Message ".repeat(50);
it("switches to announces tab when Announces tab is clicked", async () => {
const wrapper = mountSidebar();
const tabs = wrapper.findAll(".border-b-2.py-3");
const announcesTab = tabs[1];
await announcesTab.trigger("click");
await wrapper.vm.$nextTick();
expect(wrapper.vm.tab).toBe("announces");
expect(wrapper.text()).toMatch(/messages\.search_placeholder_announces|messages\.no_peers_discovered|messages\.waiting_for_announce/);
});
it("emits folder-click when All Messages is clicked", async () => {
const wrapper = mountSidebar();
const clickables = wrapper.findAll(".cursor-pointer");
const allMessagesRow = clickables.find((r) => r.text().includes("All Messages"));
expect(allMessagesRow.exists()).toBe(true);
await allMessagesRow.trigger("click");
expect(wrapper.emitted("folder-click")).toBeTruthy();
expect(wrapper.emitted("folder-click")[0]).toEqual([null]);
});
it("emits folder-click with folder id when folder row is clicked", async () => {
const wrapper = mountSidebar({
folders: [{ id: 10, name: "Archive" }],
});
await wrapper.vm.$nextTick();
const clickables = wrapper.findAll(".cursor-pointer");
const archiveRow = clickables.find((r) => r.text().includes("Archive"));
expect(archiveRow.exists()).toBe(true);
await archiveRow.trigger("click");
expect(wrapper.emitted("folder-click")).toBeTruthy();
expect(wrapper.emitted("folder-click").some((e) => e[0] === 10)).toBe(true);
});
it("renders conversation list when conversations are provided", async () => {
const conversations = [
{
destination_hash: "hash1",
display_name: longName,
latest_message_preview: longPreview,
destination_hash: "abc123",
display_name: "Alice",
updated_at: new Date().toISOString(),
is_unread: false,
failed_messages_count: 0,
},
];
const wrapper = mountMessagesSidebar({ conversations });
const nameElement = wrapper.find(".conversation-item .truncate");
expect(nameElement.exists()).toBe(true);
expect(nameElement.text()).toContain("Long Name");
const previewElement = wrapper
.findAll(".conversation-item .truncate")
.find((el) => el.text().includes("Message"));
expect(previewElement.exists()).toBe(true);
const wrapper = mountSidebar({ conversations, selectedDestinationHash: "" });
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("Alice");
});
it("handles a large number of conversations with scroll overflow", async () => {
const manyConversations = Array.from({ length: 100 }, (_, i) => ({
destination_hash: `hash${i}`,
display_name: `User ${i}`,
latest_message_preview: `Last message ${i}`,
updated_at: new Date().toISOString(),
}));
const wrapper = mountMessagesSidebar({ conversations: manyConversations });
const scrollContainer = wrapper.find(".overflow-y-auto");
expect(scrollContainer.exists()).toBe(true);
expect(scrollContainer.classes()).toContain("overflow-y-auto");
const conversationItems = wrapper.findAll(".conversation-item");
expect(conversationItems.length).toBe(100);
it("shows loading skeleton when isLoading is true", () => {
const wrapper = mountSidebar({ isLoading: true });
expect(wrapper.find(".animate-pulse").exists()).toBe(true);
});
it("handles long peer names in the announces tab", async () => {
const longPeerName = "Peer ".repeat(20) + "Extreme Name";
const peers = {
peer1: {
destination_hash: "peer1",
display_name: longPeerName,
it("shows no conversations empty state when conversations empty and not loading", () => {
const wrapper = mountSidebar({ conversations: [], isLoading: false });
expect(wrapper.text()).toContain("No Conversations");
expect(wrapper.text()).toContain("Discover peers on the Announces tab");
});
it("toggles selection mode when selection button is clicked", async () => {
const wrapper = mountSidebar({
conversations: [
{
destination_hash: "h1",
display_name: "Peer",
updated_at: new Date().toISOString(),
is_unread: false,
failed_messages_count: 0,
},
],
});
await wrapper.vm.$nextTick();
const selectionBtn = wrapper.find("button[title=\"Selection Mode\"]");
expect(selectionBtn.exists()).toBe(true);
await selectionBtn.trigger("click");
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectionMode).toBe(true);
});
it("conversations tab has correct layout classes", () => {
const wrapper = mountSidebar();
const conversationsPanel = wrapper.find(".flex-1.flex.flex-col.bg-white");
expect(conversationsPanel.exists()).toBe(true);
});
it("emits conversation-click when a conversation row is clicked", async () => {
const conversations = [
{
destination_hash: "dest1",
display_name: "Bob",
updated_at: new Date().toISOString(),
hops: 1,
is_unread: false,
failed_messages_count: 0,
},
};
const wrapper = mountMessagesSidebar({ peers });
// Switch to announces tab
await wrapper.find("div.cursor-pointer:last-child").trigger("click");
expect(wrapper.vm.tab).toBe("announces");
const peerNameElement = wrapper.find(".truncate");
expect(peerNameElement.exists()).toBe(true);
expect(peerNameElement.text()).toContain("Extreme Name");
];
const wrapper = mountSidebar({ conversations });
await wrapper.vm.$nextTick();
const row = wrapper.find(".conversation-item");
await row.trigger("click");
expect(wrapper.emitted("conversation-click")).toBeTruthy();
expect(wrapper.emitted("conversation-click")[0][0]).toMatchObject({ destination_hash: "dest1", display_name: "Bob" });
});
});
+67 -189
View File
@@ -1,216 +1,94 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import NotificationBell from "@/components/NotificationBell.vue";
import { nextTick } from "vue";
import { describe, it, expect, vi, beforeEach } from "vitest";
import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
describe("NotificationBell.vue", () => {
let axiosMock;
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
default: { on: vi.fn(), off: vi.fn() },
}));
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
default: { formatTimeAgo: (d) => "1h ago" },
}));
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
function mountBell(options = {}) {
return mount(NotificationBell, {
global: {
components: { MaterialDesignIcon },
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
},
...options,
});
}
describe("NotificationBell UI", () => {
beforeEach(() => {
axiosMock = {
get: vi.fn().mockResolvedValue({
data: {
notifications: [],
unread_count: 0,
},
}),
post: vi.fn().mockResolvedValue({ data: {} }),
};
window.axios = axiosMock;
vi.clearAllMocks();
global.axios.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
});
afterEach(() => {
delete window.axios;
it("renders bell button", () => {
const wrapper = mountBell();
const btn = wrapper.find("button.relative.rounded-full");
expect(btn.exists()).toBe(true);
});
const mountNotificationBell = () => {
return mount(NotificationBell, {
global: {
mocks: {
$t: (key) => key,
$router: { push: vi.fn() },
},
stubs: {
MaterialDesignIcon: true,
Teleport: true,
},
directives: {
"click-outside": {},
},
},
});
};
it("displays '9+' when unread count is greater than 9", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
notifications: [],
unread_count: 15,
},
});
const wrapper = mountNotificationBell();
await nextTick();
await nextTick();
it("shows unread badge when unreadCount > 0", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
wrapper.vm.unreadCount = 5;
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("5");
});
it("shows 9+ when unreadCount > 9", async () => {
const wrapper = mountBell();
wrapper.vm.unreadCount = 12;
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("9+");
});
it("handles long notification names with truncation", async () => {
const longName = "A".repeat(100);
axiosMock.get.mockResolvedValue({
it("opens dropdown on button click", async () => {
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
await wrapper.vm.$nextTick();
expect(wrapper.vm.isDropdownOpen).toBe(true);
expect(document.body.textContent).toContain("Notifications");
wrapper.unmount();
});
it("shows Clear button when dropdown open and notifications exist", async () => {
global.axios.get = vi.fn().mockResolvedValue({
data: {
notifications: [
{
type: "lxmf_message",
destination_hash: "hash1",
display_name: longName,
updated_at: new Date().toISOString(),
content: "Short content",
},
{ destination_hash: "h1", display_name: "A", updated_at: new Date().toISOString(), content: "Hi" },
],
unread_count: 1,
},
});
const wrapper = mountNotificationBell();
await nextTick();
// Open dropdown
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
await nextTick();
await nextTick();
const nameElement = wrapper.find(".truncate");
expect(nameElement.exists()).toBe(true);
expect(nameElement.text()).toBe(longName);
expect(nameElement.attributes("title")).toBe(longName);
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 50));
expect(document.body.textContent).toContain("Clear");
wrapper.unmount();
});
it("handles long notification content with line-clamp", async () => {
const longContent = "B".repeat(500);
axiosMock.get.mockResolvedValue({
data: {
notifications: [
{
type: "lxmf_message",
destination_hash: "hash1",
display_name: "User",
updated_at: new Date().toISOString(),
content: longContent,
},
],
unread_count: 1,
},
});
const wrapper = mountNotificationBell();
await nextTick();
// Open dropdown
it("shows No new notifications when empty", async () => {
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
await nextTick();
await nextTick();
const contentElement = wrapper.find(".line-clamp-2");
expect(contentElement.exists()).toBe(true);
expect(contentElement.text().trim()).toBe(longContent);
expect(contentElement.attributes("title")).toBe(longContent);
await wrapper.vm.$nextTick();
expect(document.body.textContent).toContain("No new notifications");
wrapper.unmount();
});
it("handles a large number of notifications without crashing", async () => {
const manyNotifications = Array.from({ length: 50 }, (_, i) => ({
type: "lxmf_message",
destination_hash: `hash${i}`,
display_name: `User ${i}`,
updated_at: new Date().toISOString(),
content: `Message ${i}`,
}));
axiosMock.get.mockResolvedValue({
data: {
notifications: manyNotifications,
unread_count: 50,
},
});
const wrapper = mountNotificationBell();
await nextTick();
// Open dropdown
it("dropdown has Notifications heading when open", async () => {
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
await nextTick();
await nextTick();
// The buttons are v-for="notification in notifications"
// Let's find them by class .w-full and hover:bg-gray-50 which are on the same element
const notificationButtons = wrapper.findAll("div.overflow-y-auto button.w-full");
expect(notificationButtons.length).toBe(50);
});
it("renders a button", () => {
const wrapper = mountNotificationBell();
expect(wrapper.find("button").exists()).toBe(true);
});
it("shows unread count when unread_count is 1", async () => {
axiosMock.get.mockResolvedValueOnce({
data: { notifications: [], unread_count: 1 },
});
const wrapper = mountNotificationBell();
await nextTick();
await nextTick();
expect(wrapper.text()).toContain("1");
});
it("navigates to voicemail tab when voicemail notification is clicked", async () => {
const routerPush = vi.fn();
axiosMock.get.mockResolvedValue({
data: {
notifications: [
{
type: "telephone_voicemail",
destination_hash: "hash1",
display_name: "User",
updated_at: new Date().toISOString(),
content: "New voicemail",
},
],
unread_count: 1,
},
});
const wrapper = mount(NotificationBell, {
global: {
mocks: {
$t: (key) => key,
$router: { push: routerPush },
},
stubs: {
MaterialDesignIcon: true,
Teleport: true,
},
directives: {
"click-outside": {},
},
},
});
await nextTick();
// Click bell to open dropdown
await wrapper.find("button").trigger("click");
await nextTick();
await nextTick();
// Click it
const button = wrapper.find("div.overflow-y-auto button.w-full");
expect(button.exists()).toBe(true);
await button.trigger("click");
expect(routerPush).toHaveBeenCalledWith({
name: "call",
query: { tab: "voicemail" },
});
await wrapper.vm.$nextTick();
const h3 = document.body.querySelector("h3");
expect(h3?.textContent).toBe("Notifications");
wrapper.unmount();
});
});
+47 -108
View File
@@ -1,121 +1,60 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi } from "vitest";
import SidebarLink from "@/components/SidebarLink.vue";
import { describe, it, expect } from "vitest";
import SidebarLink from "../../meshchatx/src/frontend/components/SidebarLink.vue";
describe("SidebarLink.vue", () => {
const defaultProps = {
to: { name: "test-route" },
isCollapsed: false,
};
const RouterLinkStub = {
name: "RouterLinkStub",
props: ["to"],
template:
'<a href="#" @click.prevent><slot :href="\'#\'" :navigate="navigate" :isActive="false"/></a>',
methods: {
navigate(e) {
if (e) e.preventDefault();
},
},
};
const RouterLinkStub = {
template: '<slot :href="\'/test\'" :navigate="navigate || (() => {})" :isActive="isActive || false" />',
props: ["to", "custom", "navigate", "isActive"],
};
function mountSidebarLink(props = {}, slots = {}) {
return mount(SidebarLink, {
props: { to: { name: "messages" }, ...props },
slots: {
icon: "<span class=\"icon-slot\">I</span>",
text: "Messages",
...slots,
},
global: {
stubs: { RouterLink: RouterLinkStub },
},
});
}
it("renders icon and text slots", () => {
const wrapper = mount(SidebarLink, {
props: defaultProps,
slots: {
icon: '<span class="icon">icon</span>',
text: '<span class="text">Link Text</span>',
},
global: {
stubs: {
RouterLink: RouterLinkStub,
},
},
});
expect(wrapper.find(".icon").exists()).toBe(true);
expect(wrapper.find(".text").exists()).toBe(true);
expect(wrapper.text()).toContain("Link Text");
describe("SidebarLink UI", () => {
it("renders link with icon and text slots", () => {
const wrapper = mountSidebarLink();
expect(wrapper.text()).toContain("Messages");
expect(wrapper.find(".icon-slot").exists()).toBe(true);
});
it("applies collapsed class when isCollapsed is true", () => {
const wrapper = mount(SidebarLink, {
props: { ...defaultProps, isCollapsed: true },
slots: {
icon: '<span class="icon">icon</span>',
text: '<span class="text">Link Text</span>',
},
global: {
stubs: {
RouterLink: RouterLinkStub,
},
},
});
// v-if="!isCollapsed" means the span with the text won't exist
expect(wrapper.find(".text").exists()).toBe(false);
it("emits click when link is clicked", async () => {
const wrapper = mountSidebarLink();
const innerLink = wrapper.find("a.rounded-r-full");
if (innerLink.exists()) {
await innerLink.trigger("click");
} else {
await wrapper.find("a").trigger("click");
}
expect(wrapper.emitted("click")).toBeDefined();
expect(wrapper.emitted("click").length).toBeGreaterThanOrEqual(1);
});
it("emits click event and calls navigate when clicked", async () => {
const navigate = vi.fn();
const wrapper = mount(SidebarLink, {
props: defaultProps,
global: {
stubs: {
RouterLink: {
template: '<slot :href="\'/test\'" :navigate="navigate" :isActive="false" />',
props: ["to", "custom"],
setup() {
return { navigate };
},
},
},
},
});
await wrapper.find("a").trigger("click");
expect(wrapper.emitted("click")).toBeTruthy();
expect(navigate).toHaveBeenCalled();
it("renders text slot when not collapsed", () => {
const wrapper = mountSidebarLink({ isCollapsed: false });
expect(wrapper.text()).toContain("Messages");
});
it("applies active classes when isActive is true", () => {
const wrapper = mount(SidebarLink, {
props: defaultProps,
global: {
stubs: {
RouterLink: {
template: '<slot :href="\'/test\'" :navigate="() => {}" :isActive="true" />',
props: ["to", "custom"],
},
},
},
});
expect(wrapper.find("a").classes()).toContain("bg-blue-100");
});
it("renders a link element", () => {
const wrapper = mount(SidebarLink, {
props: defaultProps,
slots: { icon: "<span></span>", text: "<span>Link</span>" },
global: { stubs: { RouterLink: RouterLinkStub } },
});
it("renders when isCollapsed true", () => {
const wrapper = mountSidebarLink({ isCollapsed: true });
expect(wrapper.find("a").exists()).toBe(true);
});
it("link href comes from router slot when stubbed", () => {
const to = { name: "settings" };
const wrapper = mount(SidebarLink, {
props: { ...defaultProps, to },
slots: { icon: "<span></span>", text: "<span>Settings</span>" },
global: {
stubs: {
RouterLink: {
template:
'<slot :href="slotHref" :navigate="() => {}" :isActive="false"></slot>',
props: ["to"],
setup(props) {
const slotHref =
props.to && props.to.name
? `/route-${props.to.name}`
: "#";
return { slotHref };
},
},
},
},
});
expect(wrapper.find("a").attributes("href")).toBe("/route-settings");
expect(wrapper.vm.isCollapsed).toBe(true);
});
});
+32 -42
View File
@@ -2,58 +2,48 @@ import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
describe("Toggle.vue", () => {
it("renders label when provided", () => {
const wrapper = mount(Toggle, {
props: { id: "test-toggle", label: "Test Label" },
});
expect(wrapper.text()).toContain("Test Label");
function mountToggle(props = {}, options = {}) {
return mount(Toggle, {
props: { id: "test-toggle", ...props },
...options,
});
}
it("emits update:modelValue on change", async () => {
const wrapper = mount(Toggle, {
props: { id: "test-toggle", modelValue: false },
});
describe("Toggle UI", () => {
it("renders with id", () => {
const wrapper = mountToggle({ id: "my-toggle" });
const input = wrapper.find("input");
await input.setChecked(true);
expect(wrapper.emitted("update:modelValue")[0]).toEqual([true]);
});
it("reflects modelValue prop", () => {
const wrapper = mount(Toggle, {
props: { id: "test-toggle", modelValue: true },
});
expect(wrapper.find("input").element.checked).toBe(true);
});
it("uses checkbox input with correct id", () => {
const wrapper = mount(Toggle, {
props: { id: "my-toggle", modelValue: false },
});
const input = wrapper.find("input");
expect(input.attributes("type")).toBe("checkbox");
expect(input.attributes("id")).toBe("my-toggle");
});
it("binds for attribute on label to id", () => {
const wrapper = mount(Toggle, {
props: { id: "toggle-1", modelValue: false },
});
expect(wrapper.find("label").attributes("for")).toBe("toggle-1");
it("renders label when provided", () => {
const wrapper = mountToggle({ label: "Enable feature" });
expect(wrapper.text()).toContain("Enable feature");
});
it("disables input when disabled prop is true", () => {
const wrapper = mount(Toggle, {
props: { id: "t", modelValue: false, disabled: true },
});
it("does not render label when not provided", () => {
const wrapper = mountToggle();
expect(wrapper.find("span.ml-3").exists()).toBe(false);
});
it("emits update:modelValue when toggled", async () => {
const wrapper = mountToggle({ modelValue: false });
await wrapper.find("input").setValue(true);
expect(wrapper.emitted("update:modelValue")).toEqual([[true]]);
});
it("checkbox is checked when modelValue true", () => {
const wrapper = mountToggle({ modelValue: true });
expect(wrapper.find("input").element.checked).toBe(true);
});
it("checkbox is disabled when disabled true", () => {
const wrapper = mountToggle({ disabled: true });
expect(wrapper.find("input").attributes("disabled")).toBeDefined();
});
it("does not emit when toggled while disabled", async () => {
const wrapper = mount(Toggle, {
props: { id: "t", modelValue: false, disabled: true },
});
await wrapper.find("input").trigger("change");
expect(wrapper.emitted("update:modelValue")).toBeFalsy();
it("label has cursor-not-allowed when disabled", () => {
const wrapper = mountToggle({ disabled: true, label: "Off" });
expect(wrapper.find("label").classes()).toContain("cursor-not-allowed");
});
});