import { mount } from "@vue/test-utils"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import MicronParser from "@/js/MicronParser.js"; // Mock vuetify components before importing the component under test vi.mock("vuetify/components/VTooltip", () => ({ VTooltip: { name: "VTooltip", template: '
', }, })); import NomadNetworkPage from "@/components/nomadnetwork/NomadNetworkPage.vue"; vi.mock("@/js/WebSocketConnection", () => ({ default: { send: vi.fn(), on: vi.fn(), off: vi.fn(), }, })); describe("NomadNetworkPage.vue", () => { let axiosMock; beforeEach(() => { axiosMock = { get: vi.fn(), post: vi.fn(), delete: vi.fn(), }; window.api = axiosMock; axiosMock.get.mockImplementation((url) => { if (url === "/api/v1/favourites") return Promise.resolve({ data: { favourites: [] } }); if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } }); if (url.includes("/path")) return Promise.resolve({ data: { path: { hops: 1 } } }); return Promise.resolve({ data: {} }); }); }); afterEach(() => { delete window.api; }); const mountNomadNetworkPage = (props = { destinationHash: "" }) => { return mount(NomadNetworkPage, { props, global: { mocks: { $t: (key) => key, $route: { query: {} }, $router: { replace: vi.fn() }, }, stubs: { MaterialDesignIcon: { template: '
', props: ["iconName"], }, LoadingSpinner: true, NomadNetworkSidebar: { template: '', props: ["nodes", "selectedDestinationHash"], }, VTooltip: { template: '
', }, }, }, }); }; it("displays 'No active node' by default", () => { const wrapper = mountNomadNetworkPage(); expect(wrapper.text()).toContain("nomadnet.no_active_node"); }); it("debounces node search and passes search param to announces API", async () => { vi.useFakeTimers(); axiosMock.isCancel = vi.fn(() => false); const wrapper = mountNomadNetworkPage(); await wrapper.vm.$nextTick(); axiosMock.get.mockClear(); wrapper.vm.onNodesSearchChanged("nodequery"); await vi.advanceTimersByTimeAsync(500); const calls = axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/announces"); expect(calls.length).toBeGreaterThanOrEqual(1); const last = calls[calls.length - 1]; expect(last[1].params.aspect).toBe("nomadnetwork.node"); expect(last[1].params.search).toBe("nodequery"); vi.useRealTimers(); }); it("loads node when destinationHash prop is provided", async () => { const destHash = "0123456789abcdef0123456789abcdef"; axiosMock.get.mockImplementation((url) => { if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [{ destination_hash: destHash, display_name: "Test Node" }] }, }); if (url === "/api/v1/favourites") return Promise.resolve({ data: { favourites: [] } }); return Promise.resolve({ data: {} }); }); const wrapper = mountNomadNetworkPage({ destinationHash: destHash }); // Manually set favourites to avoid undefined error if mock fails wrapper.vm.favourites = []; await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick(); // Wait for fetch expect(wrapper.vm.selectedNode.destination_hash).toBe(destHash); }); it("toggles source view", async () => { const destHash = "0123456789abcdef0123456789abcdef"; const wrapper = mountNomadNetworkPage({ destinationHash: destHash }); wrapper.vm.favourites = []; wrapper.setData({ selectedNode: { destination_hash: destHash, display_name: "Test Node" }, nodePageContent: "Page Content", nodePagePath: "test:path", }); await wrapper.vm.$nextTick(); // Find toggle source button by icon name const buttons = wrapper.findAll("button"); const toggleSourceButton = buttons.find((b) => b.html().includes('data-icon-name="code-tags"')); if (toggleSourceButton) { await toggleSourceButton.trigger("click"); expect(wrapper.vm.isShowingNodePageSource).toBe(true); } }); describe("showMicronRendererInMobileMenu", () => { it("is true on .mu page when wasm bundled and not in source view", async () => { const dest = "a".repeat(32); const wrapper = mountNomadNetworkPage(); await wrapper.setData({ wasmBundled: true, selectedNode: { destination_hash: dest, display_name: "N" }, nodePagePath: `${dest}:/page/index.mu`, isShowingNodePageSource: false, }); expect(wrapper.vm.showMicronRendererInMobileMenu).toBe(true); }); it("is false without selectedNode", async () => { const dest = "c".repeat(32); const wrapper = mountNomadNetworkPage(); await wrapper.setData({ wasmBundled: true, selectedNode: null, nodePagePath: `${dest}:/page/index.mu`, isShowingNodePageSource: false, }); expect(wrapper.vm.showMicronRendererInMobileMenu).toBe(false); }); it("is false in source view", async () => { const dest = "b".repeat(32); const wrapper = mountNomadNetworkPage(); await wrapper.setData({ wasmBundled: true, selectedNode: { destination_hash: dest, display_name: "N" }, nodePagePath: `${dest}:/page/index.mu`, isShowingNodePageSource: true, }); expect(wrapper.vm.showMicronRendererInMobileMenu).toBe(false); }); it("is false when wasm is not bundled", async () => { const dest = "d".repeat(32); const wrapper = mountNomadNetworkPage(); await wrapper.setData({ wasmBundled: false, selectedNode: { destination_hash: dest, display_name: "N" }, nodePagePath: `${dest}:/page/index.mu`, isShowingNodePageSource: false, }); expect(wrapper.vm.showMicronRendererInMobileMenu).toBe(false); }); it("is true on .mu page when URL has Nomad data suffix after backtick", async () => { const dest = "e".repeat(32); const wrapper = mountNomadNetworkPage(); await wrapper.setData({ wasmBundled: true, selectedNode: { destination_hash: dest, display_name: "N" }, nodePagePath: `${dest}:/page/repo.mu\`g=reticulum|r=nomadnet`, isShowingNodePageSource: false, }); expect(wrapper.vm.nodePagePathIsMicronMu).toBe(true); expect(wrapper.vm.showMicronRendererInMobileMenu).toBe(true); }); }); describe("partials", () => { it("clearPartials resets partial state and timers", () => { const wrapper = mountNomadNetworkPage(); wrapper.vm.pagePartials = { "partial-0": "x" }; wrapper.vm.loadedPartialIds = { "partial-0": true }; wrapper.vm.partialIdsByKey = { "abc:path": [] }; wrapper.vm.partialRefreshByKey = { "abc:path": 10 }; wrapper.vm.partialRefreshTimers = { "abc:path": 12345 }; wrapper.vm.clearPartials(); expect(wrapper.vm.pagePartials).toEqual({}); expect(wrapper.vm.loadedPartialIds).toEqual({}); expect(wrapper.vm.partialIdsByKey).toEqual({}); expect(wrapper.vm.partialRefreshByKey).toEqual({}); expect(wrapper.vm.partialRefreshTimers).toEqual({}); }); it("processPartials does not call downloadNomadNetPage again after partials are marked loaded", async () => { const dest = "a".repeat(32); const wrapper = mountNomadNetworkPage(); wrapper.vm.selectedNode = { destination_hash: dest, display_name: "Test" }; wrapper.vm.nodePagePath = `${dest}:/page/index.mu`; wrapper.vm.nodePageContent = "`{" + dest + ":/page/nested.mu}"; wrapper.vm.isShowingNodePageSource = false; const downloadSpy = vi .spyOn(wrapper.vm, "downloadNomadNetPage") .mockImplementation((_d, _p, _f, onSuccess) => { onSuccess("# ok"); }); await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick(); wrapper.vm.processPartials(); await wrapper.vm.$nextTick(); const afterFirst = downloadSpy.mock.calls.length; expect(afterFirst).toBeGreaterThanOrEqual(1); wrapper.vm.processPartials(); await wrapper.vm.$nextTick(); expect(downloadSpy.mock.calls.length).toBe(afterFirst); downloadSpy.mockRestore(); }); it("does not re-run Micron conversion when only favourites list updates", async () => { const dest = "b".repeat(32); const wrapper = mountNomadNetworkPage(); wrapper.vm.selectedNode = { destination_hash: dest, display_name: "Test" }; wrapper.vm.nodePagePath = `${dest}:/page/index.mu`; wrapper.vm.nodePageContent = "# line one\n# line two"; wrapper.vm.isShowingNodePageSource = false; await wrapper.vm.$nextTick(); const parseSpy = vi.spyOn(MicronParser.prototype, "convertMicronToHtml"); wrapper.vm.favourites = [{ destination_hash: "x", display_name: "Fav" }]; await wrapper.vm.$nextTick(); expect(parseSpy).not.toHaveBeenCalled(); parseSpy.mockRestore(); }); it("renderPageContent with .mu and pagePartials injects partial content", () => { const dest = "a".repeat(32); const wrapper = mountNomadNetworkPage(); wrapper.vm.pagePartials = { "partial-0": "Loaded partial" }; const content = "Hello\n`{" + dest + ":/page/partial.mu}\nWorld"; const path = dest + ":/page/index.mu"; const html = wrapper.vm.renderPageContent(path, content); expect(html).toContain("Loaded partial"); expect(html).not.toContain("Loading..."); expect(html).toContain("H"); expect(html).toContain("W"); }); it("renderPageContent without pagePartials shows placeholder for partial", () => { const dest = "b".repeat(32); const wrapper = mountNomadNetworkPage(); const content = "`{" + dest + ":/page/partial.mu}"; const path = dest + ":/page/index.mu"; const html = wrapper.vm.renderPageContent(path, content); expect(html).toContain("mu-partial"); expect(html).toContain("Loading..."); expect(html).toContain('data-dest="' + dest + '"'); }); }); describe("page load stats", () => { it("formatShortDuration formats ms and seconds", () => { const wrapper = mountNomadNetworkPage(); expect(wrapper.vm.formatShortDuration(0)).toBe("0 ms"); expect(wrapper.vm.formatShortDuration(500)).toBe("500 ms"); expect(wrapper.vm.formatShortDuration(1500)).toMatch(/1\.5 s/); expect(wrapper.vm.formatShortDuration(120000)).toMatch(/2m/); }); }); describe("isFailedPageContent", () => { const failedCases = [ ["request_failed"], ["Failed loading page:"], ["Failed loading page: Could not establish link to destination."], ["Failed loading page: empty_response"], ["Failed loading page: request_failed"], ]; it.each(failedCases)("treats %s as failed load sentinel", (content) => { const wrapper = mountNomadNetworkPage(); expect(wrapper.vm.isFailedPageContent(content)).toBe(true); }); const notFailedCases = [ [null], [undefined], ["# README\nTalk about failure modes here."], ["FAILURE is not how we detect errors"], ["partial_failure in prose"], [""], ["Download cancelled"], ["

success

"], ]; it.each(notFailedCases)("does not treat %s as failed load", (content) => { const wrapper = mountNomadNetworkPage(); expect(wrapper.vm.isFailedPageContent(content)).toBe(false); }); const boundaryCases = [ ["phrase only mid-document", "Something happened. Failed loading page: not a real prefix.", false], ["leading newline before meshchat prefix", "\nFailed loading page: timeout", false], ["leading spaces before meshchat prefix", " Failed loading page: timeout", false], ["wrong casing on meshchat prefix", "failed loading page: timeout", false], ["wrong casing on sentinel", "REQUEST_FAILED", false], ["sentinel with trailing space", "request_failed ", false], ["sentinel with leading space", " request_failed", false], ["meshchat prefix substring only", "PrefixedFailed loading page: no", false], ]; it.each(boundaryCases)("boundary: %s", (_label, content, expectedFailed) => { const wrapper = mountNomadNetworkPage(); expect(wrapper.vm.isFailedPageContent(content)).toBe(expectedFailed); }); it("non-string and boxed values are not matched as failed", () => { const wrapper = mountNomadNetworkPage(); expect(wrapper.vm.isFailedPageContent(123)).toBe(false); expect(wrapper.vm.isFailedPageContent(NaN)).toBe(false); expect(wrapper.vm.isFailedPageContent(true)).toBe(false); expect(wrapper.vm.isFailedPageContent(false)).toBe(false); expect(wrapper.vm.isFailedPageContent([])).toBe(false); expect(wrapper.vm.isFailedPageContent({ status: "failure" })).toBe(false); expect(wrapper.vm.isFailedPageContent(new String("request_failed"))).toBe(false); expect(wrapper.vm.isFailedPageContent(new String("Failed loading page: boxed"))).toBe(false); }); }); describe("hasPageLoadFailed", () => { const destHash = "c".repeat(32); it("is false while loading even if content looks like an error string", async () => { const wrapper = mountNomadNetworkPage(); await wrapper.setData({ selectedNode: { destination_hash: destHash, display_name: "N" }, isLoadingNodePage: true, nodePageContent: "Failed loading page: race", }); expect(wrapper.vm.hasPageLoadFailed).toBe(false); }); it("is false without selected node even if nodePageContent is an error string", async () => { const wrapper = mountNomadNetworkPage(); await wrapper.setData({ selectedNode: null, isLoadingNodePage: false, nodePageContent: "Failed loading page: orphan", }); expect(wrapper.vm.hasPageLoadFailed).toBe(false); }); it("is true when idle, node selected, and content matches failure detection", async () => { const wrapper = mountNomadNetworkPage(); await wrapper.setData({ selectedNode: { destination_hash: destHash, display_name: "N" }, isLoadingNodePage: false, nodePageContent: "Failed loading page: done", }); expect(wrapper.vm.hasPageLoadFailed).toBe(true); }); it("is false when idle with valid page prose mentioning failure", async () => { const wrapper = mountNomadNetworkPage(); await wrapper.setData({ selectedNode: { destination_hash: destHash, display_name: "N" }, isLoadingNodePage: false, nodePageContent: "# Doc\nAvoid failure during deploy.", }); expect(wrapper.vm.hasPageLoadFailed).toBe(false); }); }); describe("parseNomadnetworkUrl", () => { it("parses absolute URL with query string", () => { const wrapper = mountNomadNetworkPage(); const dest = "a".repeat(32); const result = wrapper.vm.parseNomadnetworkUrl(`${dest}:/file/report.pdf?version=2&format=raw`); expect(result).toEqual({ destination_hash: dest, path: "/file/report.pdf", query: "version=2&format=raw", }); }); it("parses relative URL with query string", () => { const wrapper = mountNomadNetworkPage(); wrapper.vm.defaultNodePagePath = "/page/index.mu"; const result = wrapper.vm.parseNomadnetworkUrl(":/file/data.bin?key=val"); expect(result).toEqual({ destination_hash: null, path: "/file/data.bin", query: "key=val", }); }); it("parses node-only URL without query", () => { const wrapper = mountNomadNetworkPage(); const dest = "b".repeat(32); const result = wrapper.vm.parseNomadnetworkUrl(dest); expect(result).toEqual({ destination_hash: dest, path: wrapper.vm.defaultNodePagePath, query: null, }); }); it("parses absolute URL without query", () => { const wrapper = mountNomadNetworkPage(); const dest = "c".repeat(32); const result = wrapper.vm.parseNomadnetworkUrl(`${dest}:/page/index.mu`); expect(result).toEqual({ destination_hash: dest, path: "/page/index.mu", query: null, }); }); it("returns null for unsupported URL", () => { const wrapper = mountNomadNetworkPage(); expect(wrapper.vm.parseNomadnetworkUrl("not-a-url")).toBeNull(); }); it("handles empty query string after ?", () => { const wrapper = mountNomadNetworkPage(); const dest = "d".repeat(32); const result = wrapper.vm.parseNomadnetworkUrl(`${dest}:/file/x.txt?`); expect(result.path).toBe("/file/x.txt"); expect(result.query).toBe(""); }); }); describe("downloadNomadNetFile", () => { let WebSocketConnection; beforeEach(async () => { // Re-import to get the mocked module WebSocketConnection = (await import("@/js/WebSocketConnection")).default; WebSocketConnection.send.mockClear(); }); it("includes data in websocket payload when provided", () => { const wrapper = mountNomadNetworkPage(); wrapper.vm.downloadNomadNetFile( "a".repeat(32), "/file/data.bin", "version=2&format=raw", vi.fn(), vi.fn(), vi.fn() ); expect(WebSocketConnection.send).toHaveBeenCalledOnce(); const payload = JSON.parse(WebSocketConnection.send.mock.calls[0][0]); expect(payload.type).toBe("nomadnet.file.download"); expect(payload.nomadnet_file_download.data).toBe("version=2&format=raw"); }); it("omits data field when data is null", () => { const wrapper = mountNomadNetworkPage(); wrapper.vm.downloadNomadNetFile("b".repeat(32), "/file/data.bin", null, vi.fn(), vi.fn(), vi.fn()); const payload = JSON.parse(WebSocketConnection.send.mock.calls[0][0]); expect(payload.nomadnet_file_download).not.toHaveProperty("data"); }); it("omits data field when data is undefined", () => { const wrapper = mountNomadNetworkPage(); wrapper.vm.downloadNomadNetFile("c".repeat(32), "/file/data.bin", undefined, vi.fn(), vi.fn(), vi.fn()); const payload = JSON.parse(WebSocketConnection.send.mock.calls[0][0]); expect(payload.nomadnet_file_download).not.toHaveProperty("data"); }); }); });