Cleanup
Some checks failed
Build Test / Build and Test (push) Failing after 6m1s
CI / lint (push) Failing after 1m27s
CI / build-frontend (push) Failing after 1m30s
CI / test-backend (push) Successful in 1m17s
CI / test-lang (push) Failing after 1m15s
Build and Publish Docker Image / build (push) Failing after 4m28s
Build and Publish Docker Image / build-dev (push) Failing after 5m38s
Security Scans / scan (push) Successful in 2m12s
Tests / test (push) Failing after 1m17s

This commit is contained in:
Sudo-Ivan
2026-02-17 17:59:46 -06:00
parent df0a7e1c9d
commit 39b8d5fef8
14 changed files with 157 additions and 138 deletions

View File

@@ -45,6 +45,7 @@ docker compose up -d
```
### Docker Permissions Note
If you encounter a `PermissionError` when running the Docker container, it's likely because the container's user (UID 1000) doesn't have permission to write to your host's `./meshchat-config` folder. You can fix this by running:
```bash
@@ -246,36 +247,36 @@ MeshChatX can be configured via command-line arguments or environment variables.
We use [Task](https://taskfile.dev/) for automation.
| Task | Description |
| :----------------------------- | :--------------------------------------------- |
| `task install` | Install all dependencies |
| `task run` | Run the application |
| `task dev` | Run the application in development mode |
| `task lint:all` | Run all linters (Python & Frontend) |
| `task lint:be` | Lint Python code only |
| `task lint:fe` | Lint frontend code only |
| `task fmt:all` | Format all code (Python & Frontend) |
| `task fmt:be` | Format Python code only |
| `task fmt:fe` | Format frontend code only |
| `task test:all` | Run all tests |
| `task test:cov` | Run tests with coverage reports |
| `task test:be` | Run Python tests only |
| `task test:fe` | Run frontend tests only |
| `task build:all` | Build frontend and backend |
| `task build:fe` | Build only the frontend |
| `task build:wheel` | Build Python wheel package |
| `task compile` | Compile Python code to check for syntax errors |
| `task docker:build` | Build Docker image using buildx |
| `task docker:run` | Run Docker container using docker-compose |
| `task dist:linux:appimage` | Build Linux AppImage |
| `task dist:win:exe` | Build Windows portable executable |
| `task dist:win:wine` | Build Windows portable (Wine cross-build) |
| `task dist:all` | Build all Electron apps |
| `task dist:all:wine` | Build all Electron apps (Wine cross-build) |
| `task android:prepare` | Prepare Android build |
| `task android:build` | Build Android APK |
| `task dist:fe:flatpak` | Build Flatpak package |
| `task clean` | Clean build artifacts and dependencies |
| Task | Description |
| :------------------------- | :--------------------------------------------- |
| `task install` | Install all dependencies |
| `task run` | Run the application |
| `task dev` | Run the application in development mode |
| `task lint:all` | Run all linters (Python & Frontend) |
| `task lint:be` | Lint Python code only |
| `task lint:fe` | Lint frontend code only |
| `task fmt:all` | Format all code (Python & Frontend) |
| `task fmt:be` | Format Python code only |
| `task fmt:fe` | Format frontend code only |
| `task test:all` | Run all tests |
| `task test:cov` | Run tests with coverage reports |
| `task test:be` | Run Python tests only |
| `task test:fe` | Run frontend tests only |
| `task build:all` | Build frontend and backend |
| `task build:fe` | Build only the frontend |
| `task build:wheel` | Build Python wheel package |
| `task compile` | Compile Python code to check for syntax errors |
| `task docker:build` | Build Docker image using buildx |
| `task docker:run` | Run Docker container using docker-compose |
| `task dist:linux:appimage` | Build Linux AppImage |
| `task dist:win:exe` | Build Windows portable executable |
| `task dist:win:wine` | Build Windows portable (Wine cross-build) |
| `task dist:all` | Build all Electron apps |
| `task dist:all:wine` | Build all Electron apps (Wine cross-build) |
| `task android:prepare` | Prepare Android build |
| `task android:build` | Build Android APK |
| `task dist:fe:flatpak` | Build Flatpak package |
| `task clean` | Clean build artifacts and dependencies |
## Security

View File

@@ -1095,8 +1095,7 @@ export default {
const status = this.propagationNodeStatus?.state;
const messagesReceived = this.propagationNodeStatus?.messages_received ?? 0;
const messagesStored = this.propagationNodeStatus?.messages_stored ?? 0;
const deliveryConfirmations =
this.propagationNodeStatus?.delivery_confirmations ?? 0;
const deliveryConfirmations = this.propagationNodeStatus?.delivery_confirmations ?? 0;
const messagesHidden = this.propagationNodeStatus?.messages_hidden ?? 0;
if (status === "complete" || status === "idle") {
const base = this.$t("app.sync_complete", { count: messagesReceived });

View File

@@ -38,7 +38,9 @@
</div>
<div class="flex-1 min-w-0 space-y-2 overflow-hidden">
<div class="flex items-center gap-2 flex-wrap">
<div class="text-lg font-semibold text-gray-900 dark:text-white truncate min-w-0">{{ iface._name }}</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white truncate min-w-0">
{{ iface._name }}
</div>
<span class="type-chip shrink-0">{{ iface.type }}</span>
<span :class="statusChipClass" class="shrink-0">{{
isInterfaceEnabled(iface) ? $t("app.enabled") : $t("app.disabled")
@@ -48,34 +50,40 @@
<div class="text-sm text-gray-600 dark:text-gray-300 break-words min-w-0">
{{ description }}
</div>
<div class="flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
<span v-if="iface._stats?.bitrate" class="stat-chip"
>{{ $t("interface.bitrate") }} {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</span
>
<span class="stat-chip">{{ $t("interface.tx") }} {{ formatBytes(iface._stats?.txb ?? 0) }}</span>
<span class="stat-chip">{{ $t("interface.rx") }} {{ formatBytes(iface._stats?.rxb ?? 0) }}</span>
<span v-if="iface.type === 'RNodeInterface' && iface._stats?.noise_floor" class="stat-chip"
>{{ $t("interface.noise") }} {{ iface._stats?.noise_floor }} dBm</span
>
<span v-if="iface._stats?.clients != null" class="stat-chip"
>{{ $t("interface.clients") }} {{ iface._stats?.clients }}</span
>
</div>
<div v-if="iface._stats?.ifac_signature" class="ifac-line">
<span class="text-emerald-500 font-semibold">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span>
<span v-if="iface._stats?.ifac_netname"> {{ iface._stats.ifac_netname }}</span>
<span></span>
<button
type="button"
class="text-blue-500 hover:underline"
@click="onIFACSignatureClick(iface._stats.ifac_signature)"
>
{{ iface._stats.ifac_signature.slice(0, 8) }}{{ iface._stats.ifac_signature.slice(-8) }}
</button>
</div>
<div class="flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
<span v-if="iface._stats?.bitrate" class="stat-chip"
>{{ $t("interface.bitrate") }} {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</span
>
<span class="stat-chip"
>{{ $t("interface.tx") }} {{ formatBytes(iface._stats?.txb ?? 0) }}</span
>
<span class="stat-chip"
>{{ $t("interface.rx") }} {{ formatBytes(iface._stats?.rxb ?? 0) }}</span
>
<span v-if="iface.type === 'RNodeInterface' && iface._stats?.noise_floor" class="stat-chip"
>{{ $t("interface.noise") }} {{ iface._stats?.noise_floor }} dBm</span
>
<span v-if="iface._stats?.clients != null" class="stat-chip"
>{{ $t("interface.clients") }} {{ iface._stats?.clients }}</span
>
</div>
<div v-if="iface._stats?.ifac_signature" class="ifac-line">
<span class="text-emerald-500 font-semibold">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span>
<span v-if="iface._stats?.ifac_netname"> {{ iface._stats.ifac_netname }}</span>
<span></span>
<button
type="button"
class="text-blue-500 hover:underline"
@click="onIFACSignatureClick(iface._stats.ifac_signature)"
>
{{ iface._stats.ifac_signature.slice(0, 8) }}{{ iface._stats.ifac_signature.slice(-8) }}
</button>
</div>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-2 sm:items-center items-end sm:shrink-0 justify-end sm:justify-end relative">
<div
class="flex flex-col sm:flex-row gap-2 sm:items-center items-end sm:shrink-0 justify-end sm:justify-end relative"
>
<button
v-if="isInterfaceEnabled(iface)"
type="button"

View File

@@ -210,8 +210,8 @@ def test_identity_loading_fallback(mock_rns, temp_dir):
mock_gen_id = MagicMock()
mock_gen_id.hash.hex.return_value = "generated_hash"
mock_gen_id.get_private_key.return_value = b"private_key"
mock_id_class.side_effect = (
lambda create_keys=False: mock_gen_id if create_keys else MagicMock()
mock_id_class.side_effect = lambda create_keys=False: (
mock_gen_id if create_keys else MagicMock()
)
# Mock sys.argv to use default behavior (random generation)

View File

@@ -82,7 +82,7 @@ describe("App propagation sync metrics", () => {
vi.advanceTimersByTime(600);
expect(ToastUtils.success).toHaveBeenCalledWith(
"Sync complete. 8 messages received. (3 stored, 2 confirmations, 3 hidden)",
"Sync complete. 8 messages received. (3 stored, 2 confirmations, 3 hidden)"
);
expect(ToastUtils.error).not.toHaveBeenCalled();
});

View File

@@ -2,13 +2,15 @@ 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/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"] };
const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
function mountBlockedPage() {
return mount(BlockedPage, {
@@ -23,10 +25,8 @@ 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: {} } });
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: {} });
});
});
@@ -41,7 +41,7 @@ describe("BlockedPage UI", () => {
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('input[type="text"]').exists()).toBe(true);
expect(wrapper.find("button").exists()).toBe(true);
});
@@ -56,8 +56,7 @@ describe("BlockedPage UI", () => {
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/reticulum/blackhole") return Promise.resolve({ data: { blackholed_identities: {} } });
if (url === "/api/v1/announces" && opts?.params?.identity_hash === "abc123")
return Promise.resolve({
data: {
@@ -82,7 +81,7 @@ describe("BlockedPage UI", () => {
it("search input binds to searchQuery", async () => {
const wrapper = mountBlockedPage();
await flushPromises();
const input = wrapper.find("input[type=\"text\"]");
const input = wrapper.find('input[type="text"]');
await input.setValue("test");
expect(wrapper.vm.searchQuery).toBe("test");
});

View File

@@ -8,7 +8,7 @@ vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
function mountDialog() {
return mount(ConfirmDialog, {

View File

@@ -5,8 +5,8 @@ import DropDownMenu from "../../meshchatx/src/frontend/components/DropDownMenu.v
function mountDropDown(slots = {}) {
return mount(DropDownMenu, {
slots: {
button: "<button type=\"button\">Menu</button>",
items: "<div class=\"menu-item\">Item 1</div>",
button: '<button type="button">Menu</button>',
items: '<div class="menu-item">Item 1</div>',
...slots,
},
global: {
@@ -38,7 +38,7 @@ describe("DropDownMenu UI", () => {
});
it("renders items slot when open", async () => {
const wrapper = mountDropDown({ items: "<div class=\"custom-item\">Custom</div>" });
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");

View File

@@ -4,13 +4,13 @@ import IconButton from "../../meshchatx/src/frontend/components/IconButton.vue";
function mountIconButton(slots = {}) {
return mount(IconButton, {
slots: { default: slots.default ?? "<span class=\"icon\">X</span>" },
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>" });
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("+");

View File

@@ -34,8 +34,8 @@ 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>" };
const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
const LxmfUserIcon = { template: '<div class="lxmf-icon"></div>' };
function makePropagationNode(i) {
return {
@@ -107,7 +107,9 @@ describe("Load time with prefilled data", () => {
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)`);
console.log(
`Propagation nodes: ${count} nodes loaded in ${loadMs.toFixed(0)}ms (max ${MAX_PROP_NODES_MS}ms)`
);
}
});
});
@@ -148,7 +150,9 @@ describe("Load time with prefilled data", () => {
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)`);
console.log(
`Messages: ${count} conversations in ${(end - start).toFixed(0)}ms (max ${MAX_MESSAGES_ANNOUNCES_MS}ms)`
);
}
});
});
@@ -190,7 +194,9 @@ describe("Load time with prefilled data", () => {
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)`);
console.log(
`Announces (messages): ${count} peers in ${(end - start).toFixed(0)}ms (max ${MAX_MESSAGES_ANNOUNCES_MS}ms)`
);
}
});
});
@@ -220,7 +226,7 @@ describe("Load time with prefilled data", () => {
components: {
MaterialDesignIcon,
IconButton: { template: "<button></button>" },
DropDownMenu: { template: "<div><slot name=\"button\"/><slot name=\"items\"/></div>" },
DropDownMenu: { template: '<div><slot name="button"/><slot name="items"/></div>' },
DropDownMenuItem: { template: "<div></div>" },
},
mocks: { $t: (key) => key },
@@ -233,7 +239,9 @@ describe("Load time with prefilled data", () => {
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)`);
console.log(
`NomadNet nodes: ${count} nodes in ${(end - start).toFixed(0)}ms (max ${MAX_NOMADNET_NODES_MS}ms)`
);
}
});
});

View File

@@ -20,8 +20,8 @@ vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
},
}));
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
const LxmfUserIcon = { template: "<div class=\"lxmf-icon\"></div>" };
const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
const LxmfUserIcon = { template: '<div class="lxmf-icon"></div>' };
function defaultProps(overrides = {}) {
return {
@@ -89,7 +89,9 @@ describe("MessagesSidebar UI", () => {
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/);
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 () => {
@@ -154,7 +156,7 @@ describe("MessagesSidebar UI", () => {
],
});
await wrapper.vm.$nextTick();
const selectionBtn = wrapper.find("button[title=\"Selection Mode\"]");
const selectionBtn = wrapper.find('button[title="Selection Mode"]');
expect(selectionBtn.exists()).toBe(true);
await selectionBtn.trigger("click");
await wrapper.vm.$nextTick();
@@ -182,6 +184,9 @@ describe("MessagesSidebar UI", () => {
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" });
expect(wrapper.emitted("conversation-click")[0][0]).toMatchObject({
destination_hash: "dest1",
display_name: "Bob",
});
});
});

View File

@@ -10,7 +10,7 @@ vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
default: { formatTimeAgo: (d) => "1h ago" },
}));
const MaterialDesignIcon = { template: "<div class=\"mdi\"></div>", props: ["iconName"] };
const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
function mountBell(options = {}) {
return mount(NotificationBell, {

View File

@@ -5,8 +5,7 @@ import SidebarLink from "../../meshchatx/src/frontend/components/SidebarLink.vue
const RouterLinkStub = {
name: "RouterLinkStub",
props: ["to"],
template:
'<a href="#" @click.prevent><slot :href="\'#\'" :navigate="navigate" :isActive="false"/></a>',
template: '<a href="#" @click.prevent><slot :href="\'#\'" :navigate="navigate" :isActive="false"/></a>',
methods: {
navigate(e) {
if (e) e.preventDefault();
@@ -18,7 +17,7 @@ function mountSidebarLink(props = {}, slots = {}) {
return mount(SidebarLink, {
props: { to: { name: "messages" }, ...props },
slots: {
icon: "<span class=\"icon-slot\">I</span>",
icon: '<span class="icon-slot">I</span>',
text: "Messages",
...slots,
},

View File

@@ -243,51 +243,51 @@ describe("NetworkVisualiser Optimization and Abort", () => {
expect(end - start).toBeLessThan(100); // Should be very fast
});
it("performance: icon cache hit vs miss for 500 nodes", async () => {
vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
const wrapper = mountVisualiser();
it("performance: icon cache hit vs miss for 500 nodes", async () => {
vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
const wrapper = mountVisualiser();
// Setup 500 nodes with the same icon
const iconInfo = { icon_name: "test", foreground_colour: "#000", background_colour: "#fff" };
wrapper.vm.pathTable = Array.from({ length: 500 }, (_, i) => ({ hash: `h${i}`, interface: "eth0", hops: 1 }));
wrapper.vm.announces = wrapper.vm.pathTable.reduce((acc, cur) => {
acc[cur.hash] = {
destination_hash: cur.hash,
aspect: "lxmf.delivery",
display_name: "node",
lxmf_user_icon: iconInfo,
};
return acc;
}, {});
wrapper.vm.conversations = wrapper.vm.pathTable.reduce((acc, cur) => {
acc[cur.hash] = { lxmf_user_icon: iconInfo };
return acc;
}, {});
// Setup 500 nodes with the same icon
const iconInfo = { icon_name: "test", foreground_colour: "#000", background_colour: "#fff" };
wrapper.vm.pathTable = Array.from({ length: 500 }, (_, i) => ({ hash: `h${i}`, interface: "eth0", hops: 1 }));
wrapper.vm.announces = wrapper.vm.pathTable.reduce((acc, cur) => {
acc[cur.hash] = {
destination_hash: cur.hash,
aspect: "lxmf.delivery",
display_name: "node",
lxmf_user_icon: iconInfo,
};
return acc;
}, {});
wrapper.vm.conversations = wrapper.vm.pathTable.reduce((acc, cur) => {
acc[cur.hash] = { lxmf_user_icon: iconInfo };
return acc;
}, {});
// Mock createIconImage to have some delay for the "miss" case
wrapper.vm.createIconImage = vi.fn().mockImplementation(async () => {
// Add a tiny delay to ensure "miss" is always measurable
await new Promise((r) => setTimeout(r, 0));
return "blob:mock-icon";
});
const startMiss = performance.now();
await wrapper.vm.processVisualization();
const endMiss = performance.now();
const missTime = endMiss - startMiss;
// Second run will hit the cache check in processVisualization
// so it won't even call createIconImage.
const startHit = performance.now();
await wrapper.vm.processVisualization();
const endHit = performance.now();
const hitTime = endHit - startHit;
console.log(`Icon cache MISS for 500 nodes: ${missTime.toFixed(2)}ms`);
console.log(`Icon cache HIT for 500 nodes: ${hitTime.toFixed(2)}ms`);
// Cache hit should be significantly faster, but we allow for some
// environmental noise in CI environments.
expect(hitTime).toBeLessThan(missTime + 200);
// Mock createIconImage to have some delay for the "miss" case
wrapper.vm.createIconImage = vi.fn().mockImplementation(async () => {
// Add a tiny delay to ensure "miss" is always measurable
await new Promise((r) => setTimeout(r, 0));
return "blob:mock-icon";
});
const startMiss = performance.now();
await wrapper.vm.processVisualization();
const endMiss = performance.now();
const missTime = endMiss - startMiss;
// Second run will hit the cache check in processVisualization
// so it won't even call createIconImage.
const startHit = performance.now();
await wrapper.vm.processVisualization();
const endHit = performance.now();
const hitTime = endHit - startHit;
console.log(`Icon cache MISS for 500 nodes: ${missTime.toFixed(2)}ms`);
console.log(`Icon cache HIT for 500 nodes: ${hitTime.toFixed(2)}ms`);
// Cache hit should be significantly faster, but we allow for some
// environmental noise in CI environments.
expect(hitTime).toBeLessThan(missTime + 200);
});
});