From c39e678d06a67fc935a86a3050c30f869031d5ef Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Wed, 4 Feb 2026 08:08:17 -0800
Subject: [PATCH] test: add core component coverage (tabs, toggles, bookmarks)
---
src/components/bookmarks/item.test.jsx | 32 ++++++++++++
src/components/bookmarks/list.test.jsx | 38 ++++++++++++++
src/components/errorboundry.test.jsx | 38 ++++++++++++++
src/components/favicon.test.jsx | 39 ++++++++++++++
src/components/resolvedicon.test.jsx | 50 ++++++++++++++++++
src/components/tab.test.jsx | 32 ++++++++++++
src/components/toggles/color.test.jsx | 59 ++++++++++++++++++++++
src/components/toggles/revalidate.test.jsx | 27 ++++++++++
src/components/toggles/theme.test.jsx | 33 ++++++++++++
9 files changed, 348 insertions(+)
create mode 100644 src/components/bookmarks/item.test.jsx
create mode 100644 src/components/bookmarks/list.test.jsx
create mode 100644 src/components/errorboundry.test.jsx
create mode 100644 src/components/favicon.test.jsx
create mode 100644 src/components/resolvedicon.test.jsx
create mode 100644 src/components/tab.test.jsx
create mode 100644 src/components/toggles/color.test.jsx
create mode 100644 src/components/toggles/revalidate.test.jsx
create mode 100644 src/components/toggles/theme.test.jsx
diff --git a/src/components/bookmarks/item.test.jsx b/src/components/bookmarks/item.test.jsx
new file mode 100644
index 000000000..38a5be3f9
--- /dev/null
+++ b/src/components/bookmarks/item.test.jsx
@@ -0,0 +1,32 @@
+// @vitest-environment jsdom
+
+import { screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders } from "test-utils/render-with-providers";
+
+vi.mock("components/resolvedicon", () => ({
+ default: ({ icon }) =>
,
+}));
+
+import Item from "./item";
+
+describe("components/bookmarks/item", () => {
+ it("falls back description to href hostname and uses settings.target", () => {
+ renderWithProviders( , {
+ settings: { target: "_self", cardBlur: "" },
+ });
+
+ expect(screen.getByText("example.com")).toBeInTheDocument();
+ expect(screen.getByRole("link").getAttribute("target")).toBe("_self");
+ });
+
+ it("renders icon-only layout with icon when provided", () => {
+ renderWithProviders(
+ ,
+ { settings: { target: "_self" } },
+ );
+
+ expect(screen.getByTestId("resolved-icon").getAttribute("data-icon")).toBe("mdi-home");
+ });
+});
diff --git a/src/components/bookmarks/list.test.jsx b/src/components/bookmarks/list.test.jsx
new file mode 100644
index 000000000..60dd26025
--- /dev/null
+++ b/src/components/bookmarks/list.test.jsx
@@ -0,0 +1,38 @@
+// @vitest-environment jsdom
+
+import { render } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { Item } = vi.hoisted(() => ({
+ Item: vi.fn(({ bookmark, iconOnly }) => (
+
+ )),
+}));
+
+vi.mock("components/bookmarks/item", () => ({
+ default: Item,
+}));
+
+import List from "./list";
+
+describe("components/bookmarks/list", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders items with iconOnly when iconsOnly is set", () => {
+ render(
);
+
+ expect(Item).toHaveBeenCalled();
+ expect(Item.mock.calls[0][0].iconOnly).toBe(true);
+ });
+
+ it("applies gridTemplateColumns in icons style", () => {
+ const { container } = render(
+
,
+ );
+
+ const ul = container.querySelector("ul");
+ expect(ul.style.gridTemplateColumns).toContain("minmax(60px");
+ });
+});
diff --git a/src/components/errorboundry.test.jsx b/src/components/errorboundry.test.jsx
new file mode 100644
index 000000000..8f2c47368
--- /dev/null
+++ b/src/components/errorboundry.test.jsx
@@ -0,0 +1,38 @@
+// @vitest-environment jsdom
+
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import ErrorBoundary from "./errorboundry";
+
+describe("components/errorboundry", () => {
+ it("renders children when no error is thrown", () => {
+ render(
+
+ ok
+ ,
+ );
+
+ expect(screen.getByText("ok")).toBeInTheDocument();
+ });
+
+ it("renders a fallback UI when a child throws", () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ const Boom = () => {
+ throw new Error("boom");
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Something went wrong.")).toBeInTheDocument();
+ expect(screen.getByText("Error: boom")).toBeInTheDocument();
+ } finally {
+ consoleSpy.mockRestore();
+ }
+ });
+});
diff --git a/src/components/favicon.test.jsx b/src/components/favicon.test.jsx
new file mode 100644
index 000000000..c7a1d8c4c
--- /dev/null
+++ b/src/components/favicon.test.jsx
@@ -0,0 +1,39 @@
+// @vitest-environment jsdom
+
+import { render, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { ColorContext } from "utils/contexts/color";
+
+import Favicon from "./favicon";
+
+describe("components/favicon", () => {
+ it("appends a shortcut icon link after rendering the SVG to canvas", async () => {
+ const drawImage = vi.fn();
+ const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ drawImage });
+ const toDataURLSpy = vi
+ .spyOn(HTMLCanvasElement.prototype, "toDataURL")
+ .mockReturnValue("data:image/x-icon;base64,AAA");
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ const img = container.querySelector("img");
+ await waitFor(() => {
+ expect(typeof img.onload).toBe("function");
+ });
+
+ img.onload();
+
+ const link = document.head.querySelector('link[rel="shortcut icon"]');
+ expect(link).not.toBeNull();
+ expect(link.getAttribute("href")).toBe("data:image/x-icon;base64,AAA");
+ expect(drawImage).toHaveBeenCalled();
+
+ getContextSpy.mockRestore();
+ toDataURLSpy.mockRestore();
+ });
+});
diff --git a/src/components/resolvedicon.test.jsx b/src/components/resolvedicon.test.jsx
new file mode 100644
index 000000000..79f853017
--- /dev/null
+++ b/src/components/resolvedicon.test.jsx
@@ -0,0 +1,50 @@
+// @vitest-environment jsdom
+
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { SettingsContext } from "utils/contexts/settings";
+import { ThemeContext } from "utils/contexts/theme";
+
+vi.mock("next/image", () => ({
+ default: ({ src, alt }) => ,
+}));
+
+import ResolvedIcon from "./resolvedicon";
+
+function renderWithContexts(ui, { settings = {}, theme = "dark" } = {}) {
+ return render(
+ {} }}>
+ {ui}
+ ,
+ );
+}
+
+describe("components/resolvedicon", () => {
+ it("renders direct URL icons via next/image", () => {
+ renderWithContexts();
+ expect(screen.getByTestId("next-image").getAttribute("data-src")).toBe("http://example.com/x.png");
+ });
+
+ it("renders selfh.st icons for sh- prefix with extension", () => {
+ renderWithContexts();
+ expect(screen.getByTestId("next-image").getAttribute("data-src")).toContain("/webp/test.webp");
+ });
+
+ it("renders mdi icons as a masked div and supports custom hex colors", () => {
+ const { container } = renderWithContexts(, {
+ settings: { iconStyle: "theme" },
+ theme: "dark",
+ });
+
+ const div = container.querySelector("div");
+ // Browser normalizes hex colors to rgb() strings on assignment.
+ expect(div.style.background).toMatch(/(#ff00ff|rgb\(255, 0, 255\))/);
+ expect(div.getAttribute("style")).toContain("home.svg");
+ });
+
+ it("falls back to dashboard-icons for .svg", () => {
+ renderWithContexts();
+ expect(screen.getByTestId("next-image").getAttribute("data-src")).toContain("/dashboard-icons/svg/foo.svg");
+ });
+});
diff --git a/src/components/tab.test.jsx b/src/components/tab.test.jsx
new file mode 100644
index 000000000..87fa9e6ca
--- /dev/null
+++ b/src/components/tab.test.jsx
@@ -0,0 +1,32 @@
+// @vitest-environment jsdom
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { TabContext } from "utils/contexts/tab";
+
+import Tab, { slugifyAndEncode } from "./tab";
+
+describe("components/tab", () => {
+ it("slugifyAndEncode lowercases and encodes spaces", () => {
+ expect(slugifyAndEncode("My Tab")).toBe("my-tab");
+ expect(slugifyAndEncode(undefined)).toBe("");
+ });
+
+ it("marks the matching tab as selected and updates hash on click", () => {
+ const setActiveTab = vi.fn();
+
+ render(
+
+
+ ,
+ );
+
+ const btn = screen.getByRole("tab");
+ expect(btn.getAttribute("aria-selected")).toBe("true");
+
+ fireEvent.click(btn);
+ expect(setActiveTab).toHaveBeenCalledWith("my-tab");
+ expect(window.location.hash).toBe("#my-tab");
+ });
+});
diff --git a/src/components/toggles/color.test.jsx b/src/components/toggles/color.test.jsx
new file mode 100644
index 000000000..bb96322ba
--- /dev/null
+++ b/src/components/toggles/color.test.jsx
@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { ColorContext } from "utils/contexts/color";
+
+// Stub Popover/Transition to always render children.
+vi.mock("@headlessui/react", async () => {
+ const React = await import("react");
+ const { Fragment } = React;
+
+ function passthrough({ as: As = "div", children, ...props }) {
+ if (As === Fragment) return <>{typeof children === "function" ? children({ open: true }) : children}>;
+ const content = typeof children === "function" ? children({ open: true }) : children;
+ return {content};
+ }
+
+ function Popover({ children }) {
+ return {typeof children === "function" ? children({ open: true }) : children}
;
+ }
+ function PopoverButton(props) {
+ return ;
+ }
+ function PopoverPanel(props) {
+ return ;
+ }
+ Popover.Button = PopoverButton;
+ Popover.Panel = PopoverPanel;
+
+ return { Popover, Transition: passthrough };
+});
+
+import ColorToggle from "./color";
+
+describe("components/toggles/color", () => {
+ it("renders nothing when no active color is set", () => {
+ const { container } = render(
+
+
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("invokes setColor when a color button is clicked", () => {
+ const setColor = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ // Buttons contain a sr-only span with the color name.
+ const blue = screen.getByText("blue").closest("button");
+ fireEvent.click(blue);
+ expect(setColor).toHaveBeenCalledWith("blue");
+ });
+});
diff --git a/src/components/toggles/revalidate.test.jsx b/src/components/toggles/revalidate.test.jsx
new file mode 100644
index 000000000..57bd65284
--- /dev/null
+++ b/src/components/toggles/revalidate.test.jsx
@@ -0,0 +1,27 @@
+// @vitest-environment jsdom
+
+import { fireEvent, render } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import Revalidate from "./revalidate";
+
+describe("components/toggles/revalidate", () => {
+ it("calls /api/revalidate and reloads when ok", async () => {
+ const reload = vi.fn();
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true });
+ vi.stubGlobal("location", { reload });
+
+ render();
+ const icon = document.querySelector("svg");
+ fireEvent.click(icon);
+
+ // allow promise chain to flush
+ await Promise.resolve();
+
+ expect(fetchSpy).toHaveBeenCalledWith("/api/revalidate");
+ expect(reload).toHaveBeenCalledTimes(1);
+
+ fetchSpy.mockRestore();
+ vi.unstubAllGlobals();
+ });
+});
diff --git a/src/components/toggles/theme.test.jsx b/src/components/toggles/theme.test.jsx
new file mode 100644
index 000000000..eecb2a4dc
--- /dev/null
+++ b/src/components/toggles/theme.test.jsx
@@ -0,0 +1,33 @@
+// @vitest-environment jsdom
+
+import { fireEvent, render } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { ThemeContext } from "utils/contexts/theme";
+
+import ThemeToggle from "./theme";
+
+describe("components/toggles/theme", () => {
+ it("renders nothing when theme is missing", () => {
+ const { container } = render(
+
+
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("toggles from dark to light when clicked", () => {
+ const setTheme = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ // The toggle is a clickable icon rendered as an svg (react-icons).
+ const toggles = document.querySelectorAll("svg");
+ fireEvent.click(toggles[1]);
+ expect(setTheme).toHaveBeenCalledWith("light");
+ });
+});