feat(tests): add comprehensive benchmarks for database performance, memory usage, and application stability, including new test files for various frontend and backend functionalities
This commit is contained in:
334
tests/frontend/NetworkVisualiser.test.js
Normal file
334
tests/frontend/NetworkVisualiser.test.js
Normal file
@@ -0,0 +1,334 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user