import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; // We need to mock the DOM and store before importing the router describe("Router", () => { let store: any; let router: typeof import("./router"); beforeEach(async () => { // Reset DOM document.body.innerHTML = `
Overview
Pipelines
Metrics
Team
`; // Reset hash location.hash = ""; // Fresh imports vi.resetModules(); store = (await import("./state")).store; router = await import("./router"); }); afterEach(() => { vi.restoreAllMocks(); }); describe("registerView", () => { it("registers a view for a tab", () => { const mockView = { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }; router.registerView("overview", mockView); const views = router.getRegisteredViews(); expect(views.get("overview")).toBe(mockView); }); }); describe("switchTab", () => { it("updates the active tab in the store", () => { store.set("activeTab", "overview"); router.switchTab("pipelines"); expect(store.get("activeTab")).toBe("pipelines"); }); it("updates the location hash", () => { store.set("activeTab", "overview"); router.switchTab("pipelines"); expect(location.hash).toBe("#pipelines"); }); it("adds active class to the correct tab button", () => { store.set("activeTab", "overview"); router.switchTab("pipelines"); const btns = document.querySelectorAll(".tab-btn"); const pipelinesBtn = Array.from(btns).find( (b) => b.getAttribute("data-tab") === "pipelines", ); const overviewBtn = Array.from(btns).find( (b) => b.getAttribute("data-tab") === "overview", ); expect(pipelinesBtn?.classList.contains("active")).toBe(true); expect(overviewBtn?.classList.contains("active")).toBe(false); }); it("adds active class to the correct panel", () => { store.set("activeTab", "overview"); router.switchTab("pipelines"); const pipelinesPanel = document.getElementById("panel-pipelines"); const overviewPanel = document.getElementById("panel-overview"); expect(pipelinesPanel?.classList.contains("active")).toBe(true); expect(overviewPanel?.classList.contains("active")).toBe(false); }); it("does nothing when switching to the current tab", () => { store.set("activeTab", "overview"); const prevHash = location.hash; router.switchTab("overview"); // Should not change anything expect(store.get("activeTab")).toBe("overview"); }); it("initializes the view on first switch", () => { const mockView = { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }; router.registerView("metrics", mockView); store.set("activeTab", "overview"); router.switchTab("metrics"); expect(mockView.init).toHaveBeenCalledTimes(1); }); it("destroys the previous view on tab switch", () => { const overviewView = { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }; const metricsView = { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }; router.registerView("overview", overviewView); router.registerView("metrics", metricsView); // First, switch to overview so it gets initialized store.set("activeTab", "pipelines"); router.switchTab("overview"); expect(overviewView.init).toHaveBeenCalledTimes(1); // Now switch to metrics router.switchTab("metrics"); expect(overviewView.destroy).toHaveBeenCalledTimes(1); expect(metricsView.init).toHaveBeenCalledTimes(1); }); it("renders with fleet state if available", () => { const mockView = { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }; router.registerView("metrics", mockView); const fakeState = { pipelines: [], machines: [] }; store.set("fleetState", fakeState); store.set("activeTab", "overview"); router.switchTab("metrics"); expect(mockView.render).toHaveBeenCalledWith(fakeState); }); it("clears team refresh timer when leaving team tab", () => { const timer = setInterval(() => {}, 999999); router.__setTeamRefreshTimerForTest(timer); const clearSpy = vi.spyOn(global, "clearInterval"); store.set("activeTab", "team"); router.switchTab("overview"); expect(clearSpy).toHaveBeenCalledWith(timer); router.__setTeamRefreshTimerForTest(null); clearSpy.mockRestore(); }); }); describe("error boundaries", () => { it("shows string errors when non-Error is thrown", () => { const errorView = { init: vi.fn(() => { throw "string error"; }), render: vi.fn(), destroy: vi.fn(), }; router.registerView("metrics", errorView); const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); store.set("activeTab", "overview"); router.switchTab("metrics"); const panel = document.getElementById("panel-metrics"); const errorBoundary = panel?.querySelector(".tab-error-boundary"); expect(errorBoundary).toBeTruthy(); expect(errorBoundary?.textContent).toContain("string error"); consoleSpy.mockRestore(); }); it("catches init errors and shows error boundary", () => { const errorView = { init: vi.fn(() => { throw new Error("Init failed!"); }), render: vi.fn(), destroy: vi.fn(), }; router.registerView("metrics", errorView); // Suppress console.error for this test const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); store.set("activeTab", "overview"); router.switchTab("metrics"); const panel = document.getElementById("panel-metrics"); const errorBoundary = panel?.querySelector(".tab-error-boundary"); expect(errorBoundary).toBeTruthy(); expect(errorBoundary?.textContent).toContain("Init failed!"); consoleSpy.mockRestore(); }); it("catches render errors and shows error boundary", () => { const errorView = { init: vi.fn(), render: vi.fn(() => { throw new Error("Render exploded!"); }), destroy: vi.fn(), }; router.registerView("metrics", errorView); const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); store.set("fleetState", { pipelines: [] }); store.set("activeTab", "overview"); router.switchTab("metrics"); const panel = document.getElementById("panel-metrics"); const errorBoundary = panel?.querySelector(".tab-error-boundary"); expect(errorBoundary).toBeTruthy(); expect(errorBoundary?.textContent).toContain("Render exploded!"); consoleSpy.mockRestore(); }); it("does not stack multiple error boundaries", () => { const errorView = { init: vi.fn(() => { throw new Error("Fail"); }), render: vi.fn(), destroy: vi.fn(), }; router.registerView("metrics", errorView); const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); store.set("activeTab", "overview"); router.switchTab("metrics"); // Try to trigger again (should not add second boundary) store.set("activeTab", "pipelines"); router.switchTab("metrics"); const panel = document.getElementById("panel-metrics"); const boundaries = panel?.querySelectorAll(".tab-error-boundary"); expect(boundaries?.length).toBeLessThanOrEqual(1); consoleSpy.mockRestore(); }); }); describe("renderActiveView", () => { it("renders the current active view with fleet state", () => { const mockView = { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }; router.registerView("overview", mockView); const fakeState = { pipelines: [] }; store.set("fleetState", fakeState); store.set("activeTab", "overview"); router.renderActiveView(); expect(mockView.render).toHaveBeenCalledWith(fakeState); }); it("does nothing when no fleet state is available", () => { const mockView = { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }; router.registerView("overview", mockView); store.set("activeTab", "overview"); router.renderActiveView(); expect(mockView.render).not.toHaveBeenCalled(); }); it("does nothing when no view is registered for active tab", () => { store.set("activeTab", "agents"); store.set("fleetState", { pipelines: [] }); expect(() => router.renderActiveView()).not.toThrow(); }); it("catches render errors and shows error boundary", () => { const errorView = { init: vi.fn(), render: vi.fn(() => { throw new Error("renderActiveView render failed!"); }), destroy: vi.fn(), }; router.registerView("overview", errorView); const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); store.set("fleetState", { pipelines: [] }); store.set("activeTab", "overview"); router.renderActiveView(); const panel = document.getElementById("panel-overview"); const errorBoundary = panel?.querySelector(".tab-error-boundary"); expect(errorBoundary).toBeTruthy(); expect(errorBoundary?.textContent).toContain( "renderActiveView render failed!", ); consoleSpy.mockRestore(); }); }); describe("error boundary retry", () => { it("retry button reinitalizes view and re-renders on success", () => { let initCalled = 0; const errorView = { init: vi.fn(() => { initCalled++; if (initCalled === 1) throw new Error("First init failed"); }), render: vi.fn(), destroy: vi.fn(), }; router.registerView("metrics", errorView); const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); store.set("fleetState", { pipelines: [] }); store.set("activeTab", "overview"); router.switchTab("metrics"); const panel = document.getElementById("panel-metrics"); const retryBtn = panel?.querySelector(".error-boundary-retry"); expect(retryBtn).toBeTruthy(); retryBtn?.dispatchEvent(new Event("click", { bubbles: true })); expect(errorView.init).toHaveBeenCalledTimes(2); expect(errorView.render).toHaveBeenCalledWith({ pipelines: [] }); expect(panel?.querySelector(".tab-error-boundary")).toBeFalsy(); consoleSpy.mockRestore(); }); it("retry button does not call render when fleetState is null", () => { const errorView = { init: vi.fn(), render: vi.fn(() => { throw new Error("Render failed"); }), destroy: vi.fn(), }; router.registerView("metrics", errorView); const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); store.set("fleetState", { pipelines: [] }); store.set("activeTab", "overview"); router.switchTab("metrics"); expect(errorView.render).toHaveBeenCalledTimes(1); store.set("fleetState", null); const panel = document.getElementById("panel-metrics"); const retryBtn = panel?.querySelector(".error-boundary-retry"); retryBtn?.dispatchEvent(new Event("click", { bubbles: true })); expect(errorView.init).toHaveBeenCalledTimes(2); expect(errorView.render).toHaveBeenCalledTimes(1); consoleSpy.mockRestore(); }); it("retry button shows error boundary again when retry fails", () => { const errorView = { init: vi.fn(() => { throw new Error("Retry failed!"); }), render: vi.fn(), destroy: vi.fn(), }; router.registerView("metrics", errorView); const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); store.set("fleetState", { pipelines: [] }); store.set("activeTab", "overview"); router.switchTab("metrics"); const panel = document.getElementById("panel-metrics"); const retryBtn = panel?.querySelector(".error-boundary-retry"); retryBtn?.dispatchEvent(new Event("click", { bubbles: true })); const errorBoundary = panel?.querySelector(".tab-error-boundary"); expect(errorBoundary).toBeTruthy(); expect(errorBoundary?.textContent).toContain("Retry failed!"); consoleSpy.mockRestore(); }); }); describe("setupRouter", () => { beforeEach(async () => { vi.resetModules(); store = (await import("./state")).store; router = await import("./router"); }); it("handles tab button clicks and switches tab", () => { const mockView = { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }; router.registerView("pipelines", mockView); router.setupRouter(); const pipelinesBtn = document.querySelector( '[data-tab="pipelines"]', ) as HTMLElement; pipelinesBtn?.click(); expect(store.get("activeTab")).toBe("pipelines"); }); it("does not switch when tab button has no data-tab", () => { document.body.innerHTML = `
No Tab
Overview
`; store.set("activeTab", "overview"); router.registerView("overview", { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }); router.setupRouter(); const noTabBtn = document.querySelector(".tab-btn"); noTabBtn?.dispatchEvent(new Event("click", { bubbles: true })); expect(store.get("activeTab")).toBe("overview"); }); it("switches to tab from valid initial hash", () => { location.hash = "#team"; router.registerView("team", { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }); router.setupRouter(); expect(store.get("activeTab")).toBe("team"); expect(location.hash).toBe("#team"); }); it("initializes current view when hash is invalid", () => { location.hash = "#invalid"; store.set("activeTab", "overview"); const mockView = { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }; router.registerView("overview", mockView); router.setupRouter(); expect(mockView.init).toHaveBeenCalledTimes(1); }); it("responds to hashchange events", () => { router.registerView("overview", { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }); router.registerView("metrics", { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }); router.setupRouter(); store.set("activeTab", "overview"); location.hash = "#metrics"; window.dispatchEvent(new HashChangeEvent("hashchange")); expect(store.get("activeTab")).toBe("metrics"); }); it("does not switch on hashchange when hash matches current tab", () => { router.registerView("metrics", { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }); router.setupRouter(); store.set("activeTab", "metrics"); location.hash = "#metrics"; const switchSpy = vi.spyOn(router, "switchTab"); window.dispatchEvent(new HashChangeEvent("hashchange")); expect(switchSpy).not.toHaveBeenCalled(); }); it("re-renders active view when fleetState changes", () => { const mockView = { init: vi.fn(), render: vi.fn(), destroy: vi.fn(), }; router.registerView("overview", mockView); store.set("activeTab", "overview"); router.setupRouter(); store.set("fleetState", { pipelines: [], machines: [] }); expect(mockView.render).toHaveBeenCalledWith({ pipelines: [], machines: [], }); }); }); });