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