Files
MeshChatX/tests/frontend/NetworkVisualiser.test.js

335 lines
12 KiB
JavaScript

import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock vis-network
vi.mock("vis-network", () => {
return {
Network: vi.fn().mockImplementation(() => ({
on: vi.fn(),
off: vi.fn(),
destroy: vi.fn(),
setOptions: vi.fn(),
setData: vi.fn(),
getPositions: vi.fn(),
storePositions: vi.fn(),
fit: vi.fn(),
focus: vi.fn(),
})),
};
});
// Mock vis-data
vi.mock("vis-data", () => {
class MockDataSet {
constructor(data = []) {
this._data = new Map(data.map((item) => [item.id, item]));
}
add(data) {
const arr = Array.isArray(data) ? data : [data];
arr.forEach((item) => this._data.set(item.id, item));
}
update(data) {
const arr = Array.isArray(data) ? data : [data];
arr.forEach((item) => this._data.set(item.id, item));
}
remove(ids) {
const arr = Array.isArray(ids) ? ids : [ids];
arr.forEach((id) => this._data.delete(id));
}
get(id) {
if (id === undefined) return Array.from(this._data.values());
return this._data.get(id) || null;
}
getIds() {
return Array.from(this._data.keys());
}
get length() {
return this._data.size;
}
}
return { DataSet: MockDataSet };
});
// Mock canvas for createIconImage
HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({
createLinearGradient: vi.fn().mockReturnValue({
addColorStop: vi.fn(),
}),
beginPath: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
drawImage: vi.fn(),
});
import NetworkVisualiser from "@/components/network-visualiser/NetworkVisualiser.vue";
describe("NetworkVisualiser.vue", () => {
let axiosMock;
beforeEach(() => {
axiosMock = {
get: vi.fn().mockImplementation((url) => {
if (url.includes("/api/v1/config")) {
return Promise.resolve({
data: { config: { display_name: "Test Node", identity_hash: "deadbeef" } },
});
}
if (url.includes("/api/v1/interface-stats")) {
return Promise.resolve({
data: {
interface_stats: {
interfaces: [{ name: "eth0", status: true, bitrate: 1000, txb: 100, rxb: 200 }],
},
},
});
}
if (url.includes("/api/v1/lxmf/conversations")) {
return Promise.resolve({ data: { conversations: [] } });
}
if (url.includes("/api/v1/path-table")) {
return Promise.resolve({
data: { path_table: [{ hash: "node1", interface: "eth0", hops: 1 }], total_count: 1 },
});
}
if (url.includes("/api/v1/announces")) {
return Promise.resolve({
data: {
announces: [
{
destination_hash: "node1",
aspect: "lxmf.delivery",
display_name: "Remote Node",
updated_at: new Date().toISOString(),
},
],
total_count: 1,
},
});
}
return Promise.resolve({ data: {} });
}),
};
window.axios = axiosMock;
// Mock URL.createObjectURL and URL.revokeObjectURL
global.URL.createObjectURL = vi.fn().mockReturnValue("blob:mock-url");
global.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
delete window.axios;
vi.clearAllMocks();
});
const mountVisualiser = () => {
return mount(NetworkVisualiser, {
global: {
stubs: {
Toggle: {
template:
'<input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)" />',
props: ["modelValue"],
},
},
},
});
};
it("renders the component and loads initial data", async () => {
const wrapper = mountVisualiser();
await wrapper.vm.$nextTick();
// Wait for all async data loading to finish
// We might need several nextTicks or a wait
await new Promise((resolve) => setTimeout(resolve, 100));
expect(wrapper.text()).toContain("Reticulum Mesh");
expect(wrapper.text()).toContain("Nodes");
expect(wrapper.text()).toContain("Links");
});
it("shows loading overlay with batch indication during update", async () => {
const wrapper = mountVisualiser();
wrapper.vm.isLoading = true;
wrapper.vm.totalNodesToLoad = 100;
wrapper.vm.loadedNodesCount = 50;
wrapper.vm.currentBatch = 2;
wrapper.vm.totalBatches = 4;
wrapper.vm.loadingStatus = "Processing Batch 2 / 4...";
await wrapper.vm.$nextTick();
const overlay = wrapper.find(".absolute.inset-0.z-20");
expect(overlay.exists()).toBe(true);
expect(overlay.text()).toContain("Batch 2 / 4");
expect(overlay.text()).toContain("50%");
});
it("filters nodes based on search query", async () => {
const wrapper = mountVisualiser();
await new Promise((resolve) => setTimeout(resolve, 100));
const searchInput = wrapper.find('input[type="text"]');
await searchInput.setValue("Remote Node");
// processVisualization is called via watcher on searchQuery
await wrapper.vm.$nextTick();
// The number of nodes in the DataSet should match the search
// In our mock initial data, we have 'me', 'eth0', and 'node1' (Remote Node)
// If we search for 'Remote Node', 'me' and 'eth0' might be filtered out depending on their labels
expect(wrapper.vm.nodes.length).toBeGreaterThan(0);
});
it("fuzzing: handles large and messy network data without crashing", async () => {
const wrapper = mountVisualiser();
// Generate messy path table
const nodeCount = 500;
const pathTable = Array.from({ length: nodeCount }, (_, i) => ({
hash: `hash_${i}_${Math.random().toString(36).substring(7)}`,
interface: i % 2 === 0 ? "eth0" : "wlan0",
hops: Math.floor(Math.random() * 10),
}));
// Generate messy announces
const announces = {};
pathTable.forEach((entry, i) => {
announces[entry.hash] = {
destination_hash: entry.hash,
aspect: i % 2 === 0 ? "lxmf.delivery" : "nomadnetwork.node",
display_name: i % 5 === 0 ? null : `Node ${i} ${"!@#$%^&*()".charAt(i % 10)}`,
custom_display_name: i % 7 === 0 ? "Custom Name" : undefined,
updated_at: i % 10 === 0 ? "invalid-date" : new Date().toISOString(),
identity_hash: `id_${i}`,
};
});
wrapper.vm.pathTable = pathTable;
wrapper.vm.announces = announces;
// Trigger processVisualization
// We set a smaller chunkSize in the test or just let it run
// We can mock createIconImage to be faster
wrapper.vm.createIconImage = vi.fn().mockResolvedValue("mock-icon");
await wrapper.vm.processVisualization();
expect(wrapper.vm.nodes.length).toBeGreaterThan(0);
// Ensure no crash happened and cleanup worked
expect(wrapper.vm.isLoading).toBe(false);
});
it("fuzzing: handles missing announce data gracefully", async () => {
const wrapper = mountVisualiser();
// Set interfaces so eth0 exists
wrapper.vm.interfaces = [{ name: "eth0", status: true }];
// Path table with hashes that don't exist in announces
wrapper.vm.pathTable = [
{ hash: "ghost1", interface: "eth0", hops: 1 },
{ hash: "ghost2", interface: "eth0", hops: 2 },
];
wrapper.vm.announces = {}; // Empty announces
await wrapper.vm.processVisualization();
// Should only have 'me' and 'eth0' nodes
expect(wrapper.vm.nodes.getIds()).toContain("me");
expect(wrapper.vm.nodes.getIds()).toContain("eth0");
expect(wrapper.vm.nodes.getIds()).not.toContain("ghost1");
});
it("fuzzing: handles circular or malformed links", async () => {
const wrapper = mountVisualiser();
wrapper.vm.interfaces = [{ name: "eth0", status: true }];
wrapper.vm.announces = {
node1: {
destination_hash: "node1",
aspect: "lxmf.delivery",
display_name: "Node 1",
updated_at: new Date().toISOString(),
},
};
// Malformed path table entries
wrapper.vm.pathTable = [
{ hash: "node1", interface: "node1", hops: 1 }, // Circular link
{ hash: "node1", interface: null, hops: 1 }, // Missing interface
{ hash: null, interface: "eth0", hops: 1 }, // Missing hash
];
await wrapper.vm.processVisualization();
// Should still render 'me' and 'eth0'
expect(wrapper.vm.nodes.getIds()).toContain("me");
expect(wrapper.vm.nodes.getIds()).toContain("eth0");
});
it("performance: measures time to process 1000 nodes", async () => {
const wrapper = mountVisualiser();
const nodeCount = 1000;
const pathTable = Array.from({ length: nodeCount }, (_, i) => ({
hash: `hash_${i}`,
interface: "eth0",
hops: 1,
}));
const announces = {};
pathTable.forEach((entry, i) => {
announces[entry.hash] = {
destination_hash: entry.hash,
aspect: "lxmf.delivery",
display_name: `Node ${i}`,
updated_at: new Date().toISOString(),
};
});
wrapper.vm.pathTable = pathTable;
wrapper.vm.announces = announces;
wrapper.vm.createIconImage = vi.fn().mockResolvedValue("mock-icon");
const start = performance.now();
await wrapper.vm.processVisualization();
const end = performance.now();
console.log(`Processed ${nodeCount} nodes in visualizer in ${(end - start).toFixed(2)}ms`);
expect(end - start).toBeLessThan(5000); // 5 seconds is generous for 1000 nodes with batching
});
it("memory: tracks icon cache growth", async () => {
const wrapper = mountVisualiser();
// Mock createIconImage to skip the Image loading part which times out in JSDOM
const originalCreateIconImage = wrapper.vm.createIconImage;
wrapper.vm.createIconImage = vi.fn().mockImplementation(async (iconName, fg, bg, size) => {
const cacheKey = `${iconName}-${fg}-${bg}-${size}`;
const mockDataUrl = `data:image/png;base64,${iconName}`;
wrapper.vm.iconCache[cacheKey] = mockDataUrl;
return mockDataUrl;
});
const getMemory = () => process.memoryUsage().heapUsed / (1024 * 1024);
const initialMem = getMemory();
// Generate many unique icons to fill cache
for (let i = 0; i < 1000; i++) {
await wrapper.vm.createIconImage(`icon-${i}`, "#ff0000", "#000000", 64);
}
const afterIconMem = getMemory();
expect(Object.keys(wrapper.vm.iconCache).length).toBe(1000);
console.log(`Memory growth after 1000 unique icons in cache: ${(afterIconMem - initialMem).toFixed(2)}MB`);
// Save reference to check if it's cleared after unmount
const cacheRef = wrapper.vm.iconCache;
wrapper.unmount();
// After unmount, the cache should be empty or the reference should be cleared
expect(Object.keys(cacheRef).length).toBe(0);
});
});