// @vitest-environment jsdom import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ColorContext } from "utils/contexts/color"; import { SettingsContext } from "utils/contexts/settings"; import { TabContext } from "utils/contexts/tab"; import { ThemeContext } from "utils/contexts/theme"; const { state, router, i18n, getSettings, servicesResponse, bookmarksResponse, widgetsResponse, serverSideTranslations, logger, useSWR, useWindowFocus, } = vi.hoisted(() => { const state = { throwIn: null, validateData: [], hashData: null, hashConfig: null, mutateHash: vi.fn(), servicesData: [], bookmarksData: [], widgetsData: [], quickLaunchProps: null, widgetCalls: [], windowFocused: false, }; const router = { asPath: "/" }; const i18n = { language: "en", changeLanguage: vi.fn() }; const getSettings = vi.fn(() => ({ providers: {}, language: "en", title: "Homepage", })); const servicesResponse = vi.fn(async () => { if (state.throwIn === "services") throw new Error("services failed"); return [{ name: "svc" }]; }); const bookmarksResponse = vi.fn(async () => { if (state.throwIn === "bookmarks") throw new Error("bookmarks failed"); return [{ name: "bm" }]; }); const widgetsResponse = vi.fn(async () => { if (state.throwIn === "widgets") throw new Error("widgets failed"); return [{ type: "search" }]; }); const serverSideTranslations = vi.fn(async (language) => ({ _translations: language })); const logger = { error: vi.fn() }; const useSWR = vi.fn((key, config) => { if (key === "/api/validate") return { data: state.validateData }; if (key === "/api/hash") { state.hashConfig = config; return { data: state.hashData, mutate: state.mutateHash }; } if (key === "/api/services") return { data: state.servicesData }; if (key === "/api/bookmarks") return { data: state.bookmarksData }; if (key === "/api/widgets") return { data: state.widgetsData }; return { data: undefined }; }); const useWindowFocus = vi.fn(() => state.windowFocused); return { state, router, i18n, getSettings, servicesResponse, bookmarksResponse, widgetsResponse, serverSideTranslations, logger, useSWR, useWindowFocus, }; }); vi.mock("next/dynamic", () => ({ default: () => () => null, })); vi.mock("next/head", () => ({ default: ({ children }) => children })); vi.mock("next/script", () => ({ default: () => null })); vi.mock("next/router", () => ({ useRouter: () => router })); vi.mock("next-i18next/pages", () => ({ useTranslation: () => ({ i18n, t: (k) => k, }), })); vi.mock("next-i18next/pages/serverSideTranslations", () => ({ serverSideTranslations, })); vi.mock("swr", () => ({ default: useSWR, SWRConfig: ({ children }) => children, })); vi.mock("utils/logger", () => ({ default: () => logger, })); vi.mock("utils/config/config", () => ({ getSettings, })); vi.mock("utils/config/api-response", () => ({ servicesResponse, bookmarksResponse, widgetsResponse, })); vi.mock("utils/hooks/window-focus", () => ({ default: useWindowFocus, })); vi.mock("components/bookmarks/group", () => ({ default: ({ bookmarks }) =>
{bookmarks?.name}
, })); vi.mock("components/services/group", () => ({ default: ({ group }) =>
{group?.name}
, })); vi.mock("components/errorboundry", () => ({ default: ({ children }) => <>{children}, })); vi.mock("components/tab", () => ({ default: ({ tab }) =>
  • {tab}
  • , slugifyAndEncode: (tabName) => tabName !== undefined ? encodeURIComponent(tabName.toString().replace(/\\s+/g, "-").toLowerCase()) : "", })); vi.mock("components/quicklaunch", () => ({ default: (props) => { state.quickLaunchProps = props; return (
    {props.isOpen ? "open" : "closed"}:{props.servicesAndBookmarks?.length ?? 0}
    ); }, })); vi.mock("components/widgets/widget", () => ({ default: ({ widget, style }) => { state.widgetCalls.push({ widget, style }); return
    {widget?.type}
    ; }, })); vi.mock("components/toggles/revalidate", () => ({ default: () => null, })); describe("pages/index getStaticProps", () => { beforeEach(() => { vi.clearAllMocks(); state.throwIn = null; state.validateData = []; state.hashData = null; state.hashConfig = null; state.servicesData = []; state.bookmarksData = []; state.widgetsData = []; state.quickLaunchProps = null; state.widgetCalls = []; state.windowFocused = false; router.asPath = "/"; i18n.changeLanguage.mockClear(); }); it("returns initial settings and api fallbacks for swr", async () => { getSettings.mockReturnValueOnce({ providers: { x: 1 }, language: "en", title: "Homepage" }); const { getStaticProps } = await import("pages/index.jsx"); const result = await getStaticProps(); expect(result.props.initialSettings).toEqual({ language: "en", title: "Homepage" }); expect(result.props.fallback["/api/services"]).toEqual([{ name: "svc" }]); expect(result.props.fallback["/api/bookmarks"]).toEqual([{ name: "bm" }]); expect(result.props.fallback["/api/widgets"]).toEqual([{ type: "search" }]); expect(result.props.fallback["/api/hash"]).toBe(false); expect(serverSideTranslations).toHaveBeenCalledWith("en"); }); it("normalizes legacy language codes before requesting translations", async () => { getSettings.mockReturnValueOnce({ providers: {}, language: "zh-CN" }); const { getStaticProps } = await import("pages/index.jsx"); await getStaticProps(); expect(serverSideTranslations).toHaveBeenCalledWith("zh-Hans"); }); it("falls back to empty settings and en translations on errors", async () => { getSettings.mockReturnValueOnce({ providers: {}, language: "de" }); state.throwIn = "services"; const { getStaticProps } = await import("pages/index.jsx"); const result = await getStaticProps(); expect(result.props.initialSettings).toEqual({}); expect(result.props.fallback["/api/services"]).toEqual([]); expect(result.props.fallback["/api/bookmarks"]).toEqual([]); expect(result.props.fallback["/api/widgets"]).toEqual([]); expect(serverSideTranslations).toHaveBeenCalledWith("en"); expect(logger.error).toHaveBeenCalled(); }); }); async function renderIndex({ initialSettings = { title: "Homepage", layout: {} }, fallback = {}, theme = "dark", color = "slate", activeTab = "", settings = initialSettings, } = {}) { const { default: Wrapper } = await import("pages/index.jsx"); const setTheme = vi.fn(); const setColor = vi.fn(); const setSettings = vi.fn(); const setActiveTab = vi.fn(); const renderResult = render( , ); return { ...renderResult, setTheme, setColor, setSettings, setActiveTab }; } describe("pages/index Wrapper", () => { beforeEach(() => { vi.clearAllMocks(); state.validateData = []; state.hashData = null; state.servicesData = []; state.bookmarksData = []; state.widgetsData = []; state.widgetCalls = []; document.documentElement.className = "dark theme-slate"; }); it("applies theme/color classes and renders a background overlay when configured", async () => { await renderIndex({ initialSettings: { title: "Homepage", color: "slate", background: { image: "https://example.com/bg.jpg", opacity: 10, blur: true, saturate: 150, brightness: 125 }, layout: {}, }, theme: "dark", color: "emerald", }); await waitFor(() => { expect(document.documentElement.classList.contains("scheme-dark")).toBe(true); }); expect(document.documentElement.classList.contains("dark")).toBe(true); expect(document.documentElement.classList.contains("theme-emerald")).toBe(true); expect(document.documentElement.classList.contains("theme-slate")).toBe(false); expect(document.querySelector("#background")).toBeTruthy(); expect(document.querySelector("#inner_wrapper")?.className).toContain("backdrop-blur"); expect(document.querySelector("#inner_wrapper")?.className).toContain("backdrop-saturate-150"); expect(document.querySelector("#inner_wrapper")?.className).toContain("backdrop-brightness-125"); }); it("supports legacy string backgrounds in settings", async () => { await renderIndex({ initialSettings: { title: "Homepage", color: "slate", background: "https://example.com/bg.jpg", layout: {}, }, theme: "dark", color: "emerald", }); expect(document.querySelector("#background")).toBeTruthy(); }); }); describe("pages/index Index routing + SWR branches", () => { beforeEach(() => { vi.clearAllMocks(); state.hashData = null; state.mutateHash.mockClear(); state.servicesData = []; state.bookmarksData = []; state.widgetsData = []; }); it("renders the validation error screen when /api/validate returns an error", async () => { state.validateData = { error: "bad config" }; await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } }); expect(screen.getByText("Error")).toBeInTheDocument(); expect(screen.getByText("bad config")).toBeInTheDocument(); }); it("renders config errors when /api/validate returns a list of errors", async () => { state.validateData = [{ config: "services.yaml", name: "Service 1", reason: "broken", mark: { line: 4 } }]; await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } }); expect(screen.getByText(/services.yaml/)).toBeInTheDocument(); expect(screen.getByText(/line 4/)).toBeInTheDocument(); }); it("marks the UI stale when the hash changes and triggers a revalidate reload", async () => { state.validateData = []; state.hashData = { hash: "new-hash" }; localStorage.setItem("hash", "old-hash"); const fetchSpy = vi.fn(async () => ({ ok: true })); fetch = fetchSpy; let reloadSpy; try { reloadSpy = vi.spyOn(window.location, "reload").mockImplementation(() => {}); } catch { // jsdom can make window.location non-configurable in some contexts. Object.defineProperty(window, "location", { value: { reload: vi.fn() }, writable: true }); reloadSpy = vi.spyOn(window.location, "reload").mockImplementation(() => {}); } await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } }); act(() => state.hashConfig.onSuccess(state.hashData)); await waitFor(() => { expect(fetchSpy).toHaveBeenCalledWith("/api/revalidate"); }); await waitFor(() => { expect(reloadSpy).toHaveBeenCalled(); }); expect(document.querySelector(".animate-spin")).toBeTruthy(); }); it("mutates the hash when the window regains focus", async () => { state.validateData = []; state.hashData = { hash: "h" }; state.windowFocused = true; await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } }); await waitFor(() => { expect(state.mutateHash).toHaveBeenCalled(); }); }); it("stores the initial hash in localStorage when none exists", async () => { state.validateData = []; state.hashData = { hash: "first-hash" }; localStorage.removeItem("hash"); await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } }); act(() => state.hashConfig.onSuccess(state.hashData)); await waitFor(() => { expect(localStorage.getItem("hash")).toBe("first-hash"); }); }); it("ignores a hash response without a hash", async () => { state.validateData = []; localStorage.setItem("hash", "old-hash"); await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } }); expect(() => act(() => state.hashConfig.onSuccess(null))).not.toThrow(); expect(() => act(() => state.hashConfig.onSuccess({}))).not.toThrow(); expect(localStorage.getItem("hash")).toBe("old-hash"); expect(document.querySelector(".animate-spin")).toBeFalsy(); }); }); describe("pages/index Home behavior", () => { beforeEach(() => { vi.clearAllMocks(); state.validateData = []; state.hashData = null; state.servicesData = [ { name: "Services", services: [{ name: "s1", href: "http://svc/1" }, { name: "s2" }], groups: [{ name: "Nested", services: [{ name: "s3", href: "http://svc/3" }], groups: [] }], }, ]; state.bookmarksData = [{ name: "Bookmarks", bookmarks: [{ name: "b1", href: "http://bm/1" }, { name: "b2" }] }]; state.widgetsData = [{ type: "glances" }, { type: "search" }]; state.quickLaunchProps = null; state.widgetCalls = []; }); it("passes href-bearing services and bookmarks to QuickLaunch and toggles search on keydown", async () => { await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { title: "Homepage", layout: {}, language: "en" }, }); await waitFor(() => { expect(state.quickLaunchProps).toBeTruthy(); }); expect(state.quickLaunchProps.servicesAndBookmarks.map((i) => i.name)).toEqual(["b1", "s1", "s3"]); expect(screen.getByTestId("quicklaunch")).toHaveTextContent("closed:3"); fireEvent.keyDown(document.body, { key: "a" }); expect(screen.getByTestId("quicklaunch")).toHaveTextContent("open:3"); expect(state.quickLaunchProps.searchString).toBe("a"); fireEvent.keyDown(document.body, { key: "Escape" }); expect(screen.getByTestId("quicklaunch")).toHaveTextContent("closed:3"); }); it("opens search on space without seeding the query with whitespace", async () => { await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { title: "Homepage", layout: {}, language: "en" }, }); await waitFor(() => { expect(state.quickLaunchProps).toBeTruthy(); }); fireEvent.keyDown(document.body, { key: " " }); expect(screen.getByTestId("quicklaunch")).toHaveTextContent("open:3"); expect(state.quickLaunchProps.searchString).toBe(""); }); it("renders services and bookmark groups when present", async () => { await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { title: "Homepage", layout: {}, language: "en" }, }); expect(await screen.findByTestId("services-group")).toHaveTextContent("Services"); expect(screen.getByTestId("bookmarks-group")).toHaveTextContent("Bookmarks"); }); it("renders tab navigation and filters groups by active tab", async () => { state.servicesData = [{ name: "Services", services: [], groups: [] }]; state.bookmarksData = [{ name: "Bookmarks", bookmarks: [] }]; await renderIndex({ initialSettings: { title: "Homepage", layout: { Services: { tab: "Main" }, Bookmarks: { tab: "Main" } } }, settings: { title: "Homepage", layout: { Services: { tab: "Main" }, Bookmarks: { tab: "Main" } } }, activeTab: "main", }); expect(await screen.findAllByTestId("tab")).toHaveLength(1); expect(screen.getAllByTestId("services-group")[0]).toHaveTextContent("Services"); expect(screen.getAllByTestId("bookmarks-group")[0]).toHaveTextContent("Bookmarks"); }); it("waits for settings.layout to populate when it differs from initial settings", async () => { state.servicesData = [{ name: "Services", services: [], groups: [] }]; state.bookmarksData = [{ name: "Bookmarks", bookmarks: [] }]; await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, // Missing layout triggers the temporary `
    ` return to avoid eager widget fetches. settings: { title: "Homepage" }, }); expect(screen.queryByTestId("services-group")).toBeNull(); expect(screen.queryByTestId("bookmarks-group")).toBeNull(); }); it("applies cardBlur classes for tabs and boxed headers when configured", async () => { state.servicesData = [{ name: "Services", services: [], groups: [] }]; state.bookmarksData = [{ name: "Bookmarks", bookmarks: [] }]; state.widgetsData = [{ type: "search" }]; await renderIndex({ initialSettings: { title: "Homepage", layout: { Services: { tab: "Main" }, Bookmarks: { tab: "Main" } } }, settings: { title: "Homepage", layout: { Services: { tab: "Main" }, Bookmarks: { tab: "Main" } }, headerStyle: "boxed", cardBlur: "sm", }, activeTab: "main", }); expect(document.querySelector("#myTab")?.className).toContain("backdrop-blur-sm"); expect(document.querySelector("#information-widgets")?.className).toContain("backdrop-blur-sm"); }); it("applies settings-driven language/theme/color updates and renders head tags", async () => { state.servicesData = []; state.bookmarksData = []; state.widgetsData = []; const { setTheme, setColor, setSettings } = await renderIndex({ initialSettings: { title: "Homepage", layout: {}, favicon: "/x.ico" }, settings: { title: "Homepage", layout: {}, language: "en", theme: "light", color: "emerald", disableIndexing: true, base: "/base/", }, theme: "dark", color: "slate", }); await waitFor(() => { expect(setSettings).toHaveBeenCalled(); }); expect(i18n.changeLanguage).toHaveBeenCalledWith("en"); expect(setTheme).toHaveBeenCalledWith("light"); expect(setColor).toHaveBeenCalledWith("emerald"); expect(document.querySelector('meta[name="robots"][content="noindex, nofollow"]')).toBeTruthy(); expect(document.querySelector("base")?.getAttribute("href")).toBe("/base/"); expect(document.querySelector('link[rel="icon"]')?.getAttribute("href")).toBe("/x.ico"); }); // Safari reads the head without running JS, so the favicon has to be there before the // settings context is populated, and mask-icon must not be it("renders a custom favicon before the settings context is populated", async () => { await renderIndex({ initialSettings: { title: "Homepage", layout: {}, favicon: "/x.ico" }, settings: {}, }); expect(document.querySelector('link[rel="icon"]')?.getAttribute("href")).toBe("/x.ico"); expect(document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute("href")).toBe("/x.ico"); expect(document.querySelector('link[rel="mask-icon"]')).toBeNull(); expect(document.querySelector('link[rel="shortcut icon"]')).toBeNull(); }); it("marks information widgets as right-aligned for known widget types", async () => { await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { title: "Homepage", layout: {}, language: "en" }, }); await waitFor(() => { expect(state.widgetCalls.length).toBeGreaterThan(0); }); const rightAligned = state.widgetCalls.filter((c) => c.style?.isRightAligned).map((c) => c.widget.type); expect(rightAligned).toEqual(["search"]); }); });