diff --git a/src/pages/_app.test.jsx b/src/pages/_app.test.jsx
new file mode 100644
index 000000000..795c569a2
--- /dev/null
+++ b/src/pages/_app.test.jsx
@@ -0,0 +1,37 @@
+// @vitest-environment jsdom
+
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+// Next's Head implementation relies on internal Next contexts; stub it for unit tests.
+vi.mock("next/head", () => ({
+ default: ({ children }) => <>{children}>,
+}));
+
+vi.mock("utils/contexts/color", () => ({
+ ColorProvider: ({ children }) => <>{children}>,
+}));
+vi.mock("utils/contexts/theme", () => ({
+ ThemeProvider: ({ children }) => <>{children}>,
+}));
+vi.mock("utils/contexts/settings", () => ({
+ SettingsProvider: ({ children }) => <>{children}>,
+}));
+vi.mock("utils/contexts/tab", () => ({
+ TabProvider: ({ children }) => <>{children}>,
+}));
+
+import App from "./_app.jsx";
+
+describe("pages/_app", () => {
+ it("renders the active page component with pageProps", () => {
+ function Page({ message }) {
+ return
msg:{message}
;
+ }
+
+ render();
+
+ expect(screen.getByText("msg:hello")).toBeInTheDocument();
+ expect(document.querySelector('meta[name="viewport"]')).toBeTruthy();
+ });
+});
diff --git a/src/pages/_document.test.jsx b/src/pages/_document.test.jsx
new file mode 100644
index 000000000..e617dd59d
--- /dev/null
+++ b/src/pages/_document.test.jsx
@@ -0,0 +1,24 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("next/document", () => ({
+ Html: ({ children }) => {children}
,
+ Head: ({ children }) => {children}
,
+ Main: () => ,
+ NextScript: () => ,
+}));
+
+import Document from "./_document.jsx";
+
+describe("pages/_document", () => {
+ it("renders the PWA meta + custom css links", () => {
+ const html = renderToStaticMarkup();
+
+ expect(html).toContain('meta name="mobile-web-app-capable" content="yes"');
+ expect(html).toContain('link rel="manifest" href="/site.webmanifest?v=4"');
+ expect(html).toContain('link rel="preload" href="/api/config/custom.css" as="style"');
+ expect(html).toContain('link rel="stylesheet" href="/api/config/custom.css"');
+ expect(html).toContain('data-testid="main"');
+ expect(html).toContain('data-testid="nextscript"');
+ });
+});
diff --git a/src/pages/browserconfig.xml.test.js b/src/pages/browserconfig.xml.test.js
new file mode 100644
index 000000000..5bd77b61d
--- /dev/null
+++ b/src/pages/browserconfig.xml.test.js
@@ -0,0 +1,38 @@
+import { describe, expect, it, vi } from "vitest";
+
+import themes from "utils/styles/themes";
+
+const { getSettings } = vi.hoisted(() => ({
+ getSettings: vi.fn(),
+}));
+
+vi.mock("utils/config/config", () => ({
+ getSettings,
+}));
+
+import { getServerSideProps } from "./browserconfig.xml.jsx";
+
+function createMockRes() {
+ return {
+ setHeader: vi.fn(),
+ write: vi.fn(),
+ end: vi.fn(),
+ };
+}
+
+describe("pages/browserconfig.xml", () => {
+ it("writes a browserconfig xml response using the selected theme color", async () => {
+ getSettings.mockReturnValueOnce({ color: "slate", theme: "dark" });
+ const res = createMockRes();
+
+ await getServerSideProps({ res });
+
+ expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/xml");
+ expect(res.end).toHaveBeenCalled();
+
+ const xml = res.write.mock.calls[0][0];
+ expect(xml).toContain('');
+ expect(xml).toContain('');
+ expect(xml).toContain(`${themes.slate.dark}`);
+ });
+});
diff --git a/src/pages/index.test.js b/src/pages/index.test.js
new file mode 100644
index 000000000..2e3b2aae0
--- /dev/null
+++ b/src/pages/index.test.js
@@ -0,0 +1,102 @@
+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("./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("./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("./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/pages/robots.txt.test.js b/src/pages/robots.txt.test.js
new file mode 100644
index 000000000..126fd0aa7
--- /dev/null
+++ b/src/pages/robots.txt.test.js
@@ -0,0 +1,41 @@
+import { describe, expect, it, vi } from "vitest";
+
+const { getSettings } = vi.hoisted(() => ({
+ getSettings: vi.fn(),
+}));
+
+vi.mock("utils/config/config", () => ({
+ getSettings,
+}));
+
+import { getServerSideProps } from "./robots.txt.js";
+
+function createMockRes() {
+ return {
+ setHeader: vi.fn(),
+ write: vi.fn(),
+ end: vi.fn(),
+ };
+}
+
+describe("pages/robots.txt", () => {
+ it("allows indexing when disableIndexing is falsey", async () => {
+ getSettings.mockReturnValueOnce({ disableIndexing: false });
+ const res = createMockRes();
+
+ await getServerSideProps({ res });
+
+ expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/plain");
+ expect(res.write).toHaveBeenCalledWith("User-agent: *\nAllow: /");
+ expect(res.end).toHaveBeenCalled();
+ });
+
+ it("disallows indexing when disableIndexing is truthy", async () => {
+ getSettings.mockReturnValueOnce({ disableIndexing: true });
+ const res = createMockRes();
+
+ await getServerSideProps({ res });
+
+ expect(res.write).toHaveBeenCalledWith("User-agent: *\nDisallow: /");
+ });
+});
diff --git a/src/pages/site.webmanifest.test.js b/src/pages/site.webmanifest.test.js
new file mode 100644
index 000000000..2eb0274b6
--- /dev/null
+++ b/src/pages/site.webmanifest.test.js
@@ -0,0 +1,55 @@
+import { describe, expect, it, vi } from "vitest";
+
+import themes from "utils/styles/themes";
+
+const { checkAndCopyConfig, getSettings } = vi.hoisted(() => ({
+ checkAndCopyConfig: vi.fn(),
+ getSettings: vi.fn(),
+}));
+
+vi.mock("utils/config/config", () => ({
+ default: checkAndCopyConfig,
+ getSettings,
+}));
+
+import { getServerSideProps } from "./site.webmanifest.jsx";
+
+function createMockRes() {
+ return {
+ setHeader: vi.fn(),
+ write: vi.fn(),
+ end: vi.fn(),
+ };
+}
+
+describe("pages/site.webmanifest", () => {
+ it("writes a manifest json response and triggers a settings config check", async () => {
+ getSettings.mockReturnValueOnce({
+ title: "My Homepage",
+ startUrl: "/start",
+ color: "slate",
+ theme: "dark",
+ pwa: {
+ icons: [{ src: "/i.png", sizes: "1x1", type: "image/png" }],
+ shortcuts: [{ name: "One", url: "/one" }],
+ },
+ });
+
+ const res = createMockRes();
+
+ await getServerSideProps({ res });
+
+ expect(checkAndCopyConfig).toHaveBeenCalledWith("settings.yaml");
+ expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "application/manifest+json");
+ expect(res.end).toHaveBeenCalled();
+
+ const manifest = JSON.parse(res.write.mock.calls[0][0]);
+ expect(manifest.name).toBe("My Homepage");
+ expect(manifest.short_name).toBe("My Homepage");
+ expect(manifest.start_url).toBe("/start");
+ expect(manifest.icons).toEqual([{ src: "/i.png", sizes: "1x1", type: "image/png" }]);
+ expect(manifest.shortcuts).toEqual([{ name: "One", url: "/one" }]);
+ expect(manifest.theme_color).toBe(themes.slate.dark);
+ expect(manifest.background_color).toBe(themes.slate.dark);
+ });
+});
diff --git a/vitest.config.mjs b/vitest.config.mjs
index 8c1b58b2e..3a0337757 100644
--- a/vitest.config.mjs
+++ b/vitest.config.mjs
@@ -11,6 +11,7 @@ export default defineConfig({
resolve: {
alias: {
components: fileURLToPath(new URL("./src/components", import.meta.url)),
+ styles: fileURLToPath(new URL("./src/styles", import.meta.url)),
"test-utils": fileURLToPath(new URL("./src/test-utils", import.meta.url)),
utils: fileURLToPath(new URL("./src/utils", import.meta.url)),
widgets: fileURLToPath(new URL("./src/widgets", import.meta.url)),
diff --git a/vitest.setup.js b/vitest.setup.js
index 50ac0e565..87fe7a4a8 100644
--- a/vitest.setup.js
+++ b/vitest.setup.js
@@ -10,6 +10,8 @@ afterEach(() => {
// implement a couple of common formatters mocked in next-i18next
vi.mock("next-i18next", () => ({
+ // Keep app/page components importable in unit tests.
+ appWithTranslation: (Component) => Component,
useTranslation: () => ({
i18n: { language: "en" },
t: (key, opts) => {