test: add coverage for core pages + page assets

This commit is contained in:
shamoon
2026-02-04 08:53:38 -08:00
parent c576fbeb54
commit 0516b5f816
8 changed files with 300 additions and 0 deletions

37
src/pages/_app.test.jsx Normal file
View File

@@ -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 <div>msg:{message}</div>;
}
render(<App Component={Page} pageProps={{ message: "hello" }} />);
expect(screen.getByText("msg:hello")).toBeInTheDocument();
expect(document.querySelector('meta[name="viewport"]')).toBeTruthy();
});
});

View File

@@ -0,0 +1,24 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
vi.mock("next/document", () => ({
Html: ({ children }) => <div data-testid="html">{children}</div>,
Head: ({ children }) => <div data-testid="head">{children}</div>,
Main: () => <main data-testid="main" />,
NextScript: () => <script data-testid="nextscript" />,
}));
import Document from "./_document.jsx";
describe("pages/_document", () => {
it("renders the PWA meta + custom css links", () => {
const html = renderToStaticMarkup(<Document />);
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"');
});
});

View File

@@ -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('<?xml version="1.0" encoding="utf-8"?>');
expect(xml).toContain('<square150x150logo src="/mstile-150x150.png?v=2"/>');
expect(xml).toContain(`<TileColor>${themes.slate.dark}</TileColor>`);
});
});

102
src/pages/index.test.js Normal file
View File

@@ -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();
});
});

View File

@@ -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: /");
});
});

View File

@@ -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);
});
});

View File

@@ -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)),

View File

@@ -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) => {