diff --git a/src/__tests__/pages/api/services/proxy.test.js b/src/__tests__/pages/api/services/proxy.test.js index ec60c0a4e..372962b73 100644 --- a/src/__tests__/pages/api/services/proxy.test.js +++ b/src/__tests__/pages/api/services/proxy.test.js @@ -1,6 +1,12 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const { getServiceWidget } = vi.hoisted(() => ({ getServiceWidget: vi.fn() })); +const { state, getServiceWidget, calendarProxy } = vi.hoisted(() => ({ + state: { + genericResult: { ok: true }, + }, + getServiceWidget: vi.fn(), + calendarProxy: vi.fn(), +})); vi.mock("utils/logger", () => ({ default: () => ({ debug: vi.fn(), error: vi.fn() }), @@ -12,7 +18,7 @@ const handlerFn = vi.hoisted(() => ({ handler: vi.fn() })); vi.mock("utils/proxy/handlers/generic", () => ({ default: handlerFn.handler })); // Calendar proxy is only used for an exception; keep it stubbed. -vi.mock("widgets/calendar/proxy", () => ({ default: vi.fn() })); +vi.mock("widgets/calendar/proxy", () => ({ default: calendarProxy })); // Provide a minimal widget registry for mapping tests. vi.mock("widgets/widgets", () => ({ @@ -23,6 +29,36 @@ vi.mock("widgets/widgets", () => ({ collections: { endpoint: "collections" }, }, }, + segments: { + api: "{url}/{endpoint}", + mappings: { + item: { endpoint: "items/{id}", segments: ["id"] }, + }, + }, + queryparams: { + api: "{url}/{endpoint}", + mappings: { + list: { endpoint: "list", params: ["limit"], optionalParams: ["q"] }, + }, + }, + endpointproxy: { + api: "{url}/{endpoint}", + mappings: { + list: { endpoint: "list", proxyHandler: handlerFn.handler, headers: { "X-Test": "1" } }, + }, + }, + regex: { + api: "{url}/{endpoint}", + allowedEndpoints: /^ok\//, + }, + ical: { + api: "{url}/{endpoint}", + proxyHandler: calendarProxy, + }, + unifi_console: { + api: "{url}/{endpoint}", + proxyHandler: handlerFn.handler, + }, }, })); @@ -51,6 +87,10 @@ function createMockRes() { } describe("pages/api/services/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("maps opaque endpoints using widget.mappings and calls the handler", async () => { getServiceWidget.mockResolvedValue({ type: "linkwarden" }); handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint })); @@ -76,4 +116,192 @@ describe("pages/api/services/proxy", () => { expect(res.statusCode).toBe(403); expect(res.body).toEqual({ error: "Unsupported service endpoint" }); }); + + it("returns 403 for unknown widget types", async () => { + getServiceWidget.mockResolvedValue({ type: "does_not_exist" }); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ error: "Unknown proxy service type" }); + }); + + it("quick-returns the proxy handler when no endpoint is provided", async () => { + getServiceWidget.mockResolvedValue({ type: "linkwarden" }); + handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json(state.genericResult)); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(handlerFn.handler).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); + + it("applies the calendar exception and always delegates to calendarProxyHandler", async () => { + getServiceWidget.mockResolvedValue({ type: "calendar" }); + calendarProxy.mockImplementation(async (_req, res) => res.status(200).json({ ok: "calendar" })); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "events" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(calendarProxy).toHaveBeenCalledTimes(1); + expect(handlerFn.handler).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ ok: "calendar" }); + }); + + it("applies the unifi_console exception when service and group are unifi_console", async () => { + getServiceWidget.mockResolvedValue({ type: "something_else" }); + handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json({ ok: "unifi" })); + + const req = { + method: "GET", + query: { group: "unifi_console", service: "unifi_console", index: "0" }, + }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ ok: "unifi" }); + }); + + it("rejects unsupported mapping methods", async () => { + getServiceWidget.mockResolvedValue({ type: "linkwarden" }); + + // Inject a mapping with a method requirement through the mocked registry. + const widgets = (await import("widgets/widgets")).default; + const originalMethod = widgets.linkwarden.mappings.collections.method; + widgets.linkwarden.mappings.collections.method = "POST"; + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ error: "Unsupported method" }); + + widgets.linkwarden.mappings.collections.method = originalMethod; + }); + + it("replaces endpoint segments and rejects unsupported segment keys/values", async () => { + getServiceWidget.mockResolvedValue({ type: "segments" }); + handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint })); + + const res1 = createMockRes(); + await servicesProxy( + { + method: "GET", + query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ id: "123" }) }, + }, + res1, + ); + expect(res1.statusCode).toBe(200); + expect(res1.body).toEqual({ endpoint: "items/123" }); + + const res2 = createMockRes(); + await servicesProxy( + { + method: "GET", + query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ nope: "123" }) }, + }, + res2, + ); + expect(res2.statusCode).toBe(403); + expect(res2.body).toEqual({ error: "Unsupported segment" }); + + const res3 = createMockRes(); + await servicesProxy( + { + method: "GET", + query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ id: "../123" }) }, + }, + res3, + ); + expect(res3.statusCode).toBe(403); + expect(res3.body).toEqual({ error: "Unsupported segment" }); + }); + + it("adds query params based on mapping params + optionalParams", async () => { + getServiceWidget.mockResolvedValue({ type: "queryparams" }); + handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint })); + + const req = { + method: "GET", + query: { + group: "g", + service: "s", + index: "0", + endpoint: "list", + query: JSON.stringify({ limit: 10, q: "test" }), + }, + }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.endpoint).toBe("list?limit=10&q=test"); + }); + + it("passes mapping headers via req.extraHeaders and uses mapping.proxyHandler when provided", async () => { + getServiceWidget.mockResolvedValue({ type: "endpointproxy" }); + handlerFn.handler.mockImplementation(async (req, res) => + res.status(200).json({ headers: req.extraHeaders ?? null }), + ); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "list" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(handlerFn.handler).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBe(200); + expect(res.body.headers).toEqual({ "X-Test": "1" }); + }); + + it("allows regex endpoints when widget.allowedEndpoints matches", async () => { + getServiceWidget.mockResolvedValue({ type: "regex" }); + handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json({ ok: true })); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "ok/test" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(200); + }); + + it("rejects unmapped proxy requests when no mapping and regex does not match", async () => { + getServiceWidget.mockResolvedValue({ type: "regex" }); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "nope" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ error: "Unmapped proxy request." }); + }); + + it("returns 500 on unexpected errors", async () => { + getServiceWidget.mockRejectedValueOnce(new Error("boom")); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: "Unexpected error" }); + }); }); diff --git a/src/__tests__/pages/index.test.js b/src/__tests__/pages/index.test.js deleted file mode 100644 index 5b675242c..000000000 --- a/src/__tests__/pages/index.test.js +++ /dev/null @@ -1,102 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { state, getSettings, servicesResponse, bookmarksResponse, widgetsResponse, serverSideTranslations, logger } = - vi.hoisted(() => { - const state = { - throwIn: null, - }; - - 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() }; - - return { state, getSettings, servicesResponse, bookmarksResponse, widgetsResponse, serverSideTranslations, logger }; - }); - -vi.mock("next/dynamic", () => ({ - default: () => () => null, -})); -vi.mock("next/head", () => ({ default: ({ children }) => children })); -vi.mock("next/script", () => ({ default: () => null })); -vi.mock("next/router", () => ({ useRouter: () => ({ asPath: "/" }) })); - -vi.mock("next-i18next/serverSideTranslations", () => ({ - serverSideTranslations, -})); - -vi.mock("utils/logger", () => ({ - default: () => logger, -})); - -vi.mock("utils/config/config", () => ({ - getSettings, -})); - -vi.mock("utils/config/api-response", () => ({ - servicesResponse, - bookmarksResponse, - widgetsResponse, -})); - -describe("pages/index getStaticProps", () => { - beforeEach(() => { - vi.clearAllMocks(); - state.throwIn = null; - }); - - 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(); - }); -}); diff --git a/src/__tests__/pages/index.test.jsx b/src/__tests__/pages/index.test.jsx new file mode 100644 index 000000000..b0a96156c --- /dev/null +++ b/src/__tests__/pages/index.test.jsx @@ -0,0 +1,400 @@ +// @vitest-environment jsdom + +import { 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, + 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) => { + if (key === "/api/validate") return { data: state.validateData }; + if (key === "/api/hash") 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", () => ({ + useTranslation: () => ({ + i18n, + t: (k) => k, + }), +})); + +vi.mock("next-i18next/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.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(); + + return render( + + + + + + + + + , + ); +} + +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"); + }); +}); + +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", reason: "broken", mark: { snippet: "x: y" } }]; + + await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } }); + + expect(screen.getByText("services.yaml")).toBeInTheDocument(); + expect(screen.getByText("broken")).toBeInTheDocument(); + expect(screen.getByText("x: y")).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 })); + // eslint-disable-next-line no-global-assign + 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: {} } }); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith("/api/revalidate"); + }); + await waitFor(() => { + expect(reloadSpy).toHaveBeenCalled(); + }); + expect(document.querySelector(".animate-spin")).toBeTruthy(); + }); +}); + +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"); + + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(screen.getByTestId("quicklaunch")).toHaveTextContent("closed:3"); + }); + + 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"]); + }); +}); diff --git a/src/__tests__/pages/site.webmanifest.test.js b/src/__tests__/pages/site.webmanifest.test.js index b9b5b9145..831f176ca 100644 --- a/src/__tests__/pages/site.webmanifest.test.js +++ b/src/__tests__/pages/site.webmanifest.test.js @@ -52,4 +52,41 @@ describe("pages/site.webmanifest", () => { expect(manifest.theme_color).toBe(themes.slate.dark); expect(manifest.background_color).toBe(themes.slate.dark); }); + + it("uses sensible defaults when no settings are provided", async () => { + getSettings.mockReturnValueOnce({}); + + const res = createMockRes(); + + await getServerSideProps({ res }); + + const manifest = JSON.parse(res.write.mock.calls[0][0]); + expect(manifest.name).toBe("Homepage"); + expect(manifest.short_name).toBe("Homepage"); + expect(manifest.start_url).toBe("/"); + expect(manifest.display).toBe("standalone"); + expect(manifest.theme_color).toBe(themes.slate.dark); + expect(manifest.background_color).toBe(themes.slate.dark); + + // Default icon set is used when pwa.icons is not set. + expect(manifest.icons).toEqual( + expect.arrayContaining([ + expect.objectContaining({ src: expect.stringContaining("android-chrome-192x192") }), + expect.objectContaining({ src: expect.stringContaining("android-chrome-512x512") }), + ]), + ); + }); + + it("respects provided pwa.icons even when it is an empty array", async () => { + getSettings.mockReturnValueOnce({ + pwa: { icons: [] }, + }); + + const res = createMockRes(); + + await getServerSideProps({ res }); + + const manifest = JSON.parse(res.write.mock.calls[0][0]); + expect(manifest.icons).toEqual([]); + }); });