test: add unit and component tests across the monorepo

Adds ~1000 tests to bring the previously-untested packages and apps
under coverage:

- packages/utils — string, color, date, file, level, locale, sanitize,
  field, html, network-icons, rate-limit, url, url-security, monorepo,
  resume/patch, resume/docx/link-utils, style helpers (~97% on the
  testable utility files)
- packages/ui — 28 component test files plus the use-controlled-state,
  use-mobile, use-confirm, use-prompt hooks (95% statements)
- packages/pdf — shared template helpers (filtering, columns, picture,
  metrics, section-links, page-size, rich-text-html, section-title)
- packages/schema — resumeDataSchema, page, templates, default
- packages/fonts — expanded coverage on font helpers
- packages/ai — resume sanitize and extraction template
- packages/api — resume-access-policy
- apps/web — error-message, locale, theme, pwa, dialogs/store, and
  the resume/move-item / section-actions / make-section-item helpers

Adds jsdom polyfills (ResizeObserver, IntersectionObserver,
scrollIntoView, matchMedia) and an explicit React Testing Library
cleanup hook to vitest.setup.ts so portal- and overlay-based components
work without per-test setup.
This commit is contained in:
Amruth Pillai
2026-05-10 20:00:07 +02:00
parent 33103536ae
commit 6a01207b6b
83 changed files with 8097 additions and 4 deletions
+124
View File
@@ -0,0 +1,124 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDialogStore } from "./store";
describe("useDialogStore", () => {
beforeEach(() => {
vi.useFakeTimers();
// Reset store state between tests
useDialogStore.setState({ open: false, activeDialog: null, onBeforeClose: null });
});
afterEach(() => {
vi.useRealTimers();
});
describe("openDialog", () => {
it("opens a dialog and sets activeDialog", () => {
useDialogStore.getState().openDialog("api-key.create", undefined);
const state = useDialogStore.getState();
expect(state.open).toBe(true);
expect(state.activeDialog?.type).toBe("api-key.create");
});
it("clears any existing onBeforeClose handler", () => {
useDialogStore.setState({ onBeforeClose: () => true });
useDialogStore.getState().openDialog("resume.create", undefined);
expect(useDialogStore.getState().onBeforeClose).toBeNull();
});
it("preserves dialog data for typed payloads", () => {
const data = { id: "r1", name: "My Resume", slug: "my-resume", tags: [] };
useDialogStore.getState().openDialog("resume.update", data);
const active = useDialogStore.getState().activeDialog;
expect(active?.type).toBe("resume.update");
if (active?.type === "resume.update") {
expect(active.data).toEqual(data);
}
});
});
describe("closeDialog", () => {
it("immediately sets open to false", () => {
useDialogStore.setState({
open: true,
activeDialog: { type: "api-key.create", data: undefined },
});
useDialogStore.getState().closeDialog();
expect(useDialogStore.getState().open).toBe(false);
});
it("clears activeDialog after 300ms (animation finished)", () => {
useDialogStore.setState({
open: true,
activeDialog: { type: "api-key.create", data: undefined },
});
useDialogStore.getState().closeDialog();
expect(useDialogStore.getState().activeDialog).not.toBeNull();
vi.advanceTimersByTime(300);
expect(useDialogStore.getState().activeDialog).toBeNull();
});
});
describe("onOpenChange", () => {
it("opens via set without invoking onBeforeClose", () => {
const onBefore = vi.fn();
useDialogStore.setState({ onBeforeClose: onBefore });
useDialogStore.getState().onOpenChange(true);
expect(useDialogStore.getState().open).toBe(true);
expect(onBefore).not.toHaveBeenCalled();
});
it("closes immediately when no onBeforeClose handler", () => {
useDialogStore.setState({ open: true, activeDialog: { type: "api-key.create", data: undefined } });
useDialogStore.getState().onOpenChange(false);
expect(useDialogStore.getState().open).toBe(false);
});
it("invokes onBeforeClose when closing", async () => {
const handler = vi.fn().mockResolvedValue(true);
useDialogStore.setState({
open: true,
activeDialog: { type: "api-key.create", data: undefined },
onBeforeClose: handler,
});
useDialogStore.getState().onOpenChange(false);
await vi.waitFor(() => expect(handler).toHaveBeenCalled());
});
it("calls cancel when onBeforeClose returns false", async () => {
const handler = vi.fn().mockResolvedValue(false);
const cancel = vi.fn();
useDialogStore.setState({
open: true,
activeDialog: { type: "api-key.create", data: undefined },
onBeforeClose: handler,
});
useDialogStore.getState().onOpenChange(false, { cancel });
await vi.waitFor(() => expect(cancel).toHaveBeenCalled());
});
});
describe("setOnBeforeClose", () => {
it("sets and clears the handler", () => {
const handler = () => true;
useDialogStore.getState().setOnBeforeClose(handler);
expect(useDialogStore.getState().onBeforeClose).toBe(handler);
useDialogStore.getState().setOnBeforeClose(null);
expect(useDialogStore.getState().onBeforeClose).toBeNull();
});
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { ORPCError } from "@orpc/client";
import { getOrpcErrorMessage, getReadableErrorMessage, getResumeErrorMessage } from "./error-message";
describe("getReadableErrorMessage", () => {
it("returns the string error directly", () => {
expect(getReadableErrorMessage("explicit error", "fallback")).toBe("explicit error");
});
it("returns Error.message", () => {
expect(getReadableErrorMessage(new Error("boom"), "fallback")).toBe("boom");
});
it("returns fallback for unknown shapes", () => {
expect(getReadableErrorMessage({ random: "object" }, "fallback")).toBe("fallback");
expect(getReadableErrorMessage(null, "fallback")).toBe("fallback");
expect(getReadableErrorMessage(undefined, "fallback")).toBe("fallback");
expect(getReadableErrorMessage(42, "fallback")).toBe("fallback");
});
it("returns fallback for empty string error (falsy)", () => {
expect(getReadableErrorMessage("", "fallback")).toBe("fallback");
});
it("returns fallback for Error with empty message", () => {
expect(getReadableErrorMessage(new Error(""), "fallback")).toBe("fallback");
});
});
describe("getOrpcErrorMessage", () => {
it("delegates to getReadableErrorMessage for non-ORPCErrors", () => {
expect(getOrpcErrorMessage(new Error("boom"), { fallback: "fallback" })).toBe("boom");
expect(getOrpcErrorMessage("string error", { fallback: "fallback" })).toBe("string error");
});
it("uses byCode mapping when present", () => {
const error = new ORPCError("RESUME_LOCKED");
expect(
getOrpcErrorMessage(error, {
fallback: "fallback",
byCode: { RESUME_LOCKED: "It is locked." },
}),
).toBe("It is locked.");
});
it("returns server message when allowServerMessage and message is set", () => {
const error = new ORPCError("OTHER", { message: "Server-provided message" });
expect(
getOrpcErrorMessage(error, {
fallback: "fallback",
allowServerMessage: true,
}),
).toBe("Server-provided message");
});
it("falls back when allowServerMessage is false even if message set", () => {
const error = new ORPCError("OTHER", { message: "Server-provided message" });
expect(getOrpcErrorMessage(error, { fallback: "fallback" })).toBe("fallback");
});
it("byCode takes precedence over allowServerMessage", () => {
const error = new ORPCError("RESUME_LOCKED", { message: "Server msg" });
expect(
getOrpcErrorMessage(error, {
fallback: "fallback",
byCode: { RESUME_LOCKED: "It is locked." },
allowServerMessage: true,
}),
).toBe("It is locked.");
});
it("returns fallback when no mapping or server message", () => {
const error = new ORPCError("UNKNOWN_CODE");
expect(getOrpcErrorMessage(error, { fallback: "fallback" })).toBe("fallback");
});
});
describe("getResumeErrorMessage", () => {
it("returns mapped message for RESUME_SLUG_ALREADY_EXISTS", () => {
const error = new ORPCError("RESUME_SLUG_ALREADY_EXISTS");
expect(getResumeErrorMessage(error)).toBe("A resume with this slug already exists.");
});
it("returns mapped message for RESUME_LOCKED", () => {
const error = new ORPCError("RESUME_LOCKED");
expect(getResumeErrorMessage(error)).toBe("This resume is locked. Unlock it first to make changes.");
});
it("returns generic fallback for unknown codes", () => {
const error = new ORPCError("UNKNOWN");
expect(getResumeErrorMessage(error)).toBe("Something went wrong. Please try again.");
});
it("returns fallback for plain Error (delegates to getOrpcErrorMessage)", () => {
// Plain Error gets readable message
expect(getResumeErrorMessage(new Error("boom"))).toBe("boom");
});
it("returns fallback for unknown shape", () => {
expect(getResumeErrorMessage(null)).toBe("Something went wrong. Please try again.");
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { isLocale, isRTL, resolveLocale } from "./locale";
describe("isLocale", () => {
it("returns true for known locale en-US", () => {
expect(isLocale("en-US")).toBe(true);
});
it("returns true for de-DE", () => {
expect(isLocale("de-DE")).toBe(true);
});
it("returns true for zh-CN", () => {
expect(isLocale("zh-CN")).toBe(true);
});
it("returns false for unknown locale", () => {
expect(isLocale("xx-YY")).toBe(false);
});
it("returns false for empty string", () => {
expect(isLocale("")).toBe(false);
});
it("returns false for malformed locale", () => {
expect(isLocale("not a locale")).toBe(false);
});
it("is case-sensitive", () => {
expect(isLocale("en-us")).toBe(false);
});
});
describe("resolveLocale", () => {
it("returns the locale unchanged when valid", () => {
expect(resolveLocale("fr-FR")).toBe("fr-FR");
});
it("returns en-US default for invalid locale", () => {
expect(resolveLocale("xx-YY")).toBe("en-US");
});
it("returns en-US default for empty string", () => {
expect(resolveLocale("")).toBe("en-US");
});
});
describe("isRTL", () => {
it("returns true for Arabic", () => {
expect(isRTL("ar-SA")).toBe(true);
});
it("returns true for Hebrew", () => {
expect(isRTL("he-IL")).toBe(true);
});
it("returns true for Persian/Farsi", () => {
expect(isRTL("fa-IR")).toBe(true);
});
it("returns true for Urdu", () => {
expect(isRTL("ur-PK")).toBe(true);
});
it("returns false for English", () => {
expect(isRTL("en-US")).toBe(false);
});
it("returns false for German", () => {
expect(isRTL("de-DE")).toBe(false);
});
it("returns false for Chinese", () => {
expect(isRTL("zh-CN")).toBe(false);
});
it("returns false for unknown locale", () => {
expect(isRTL("xyz-XX")).toBe(false);
});
it("matches prefix case-insensitively (lowercase prefix)", () => {
expect(isRTL("AR-SA")).toBe(true);
});
it("works with locale-only string (no region)", () => {
expect(isRTL("ar")).toBe(true);
expect(isRTL("en")).toBe(false);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { pwaHeadMetaTags, pwaManifest, pwaServiceWorkerRegistrationScript } from "./pwa";
describe("pwaManifest", () => {
it("uses the same theme color and background color (dark zinc)", () => {
expect(pwaManifest.theme_color).toBe(pwaManifest.background_color);
});
it("declares standalone display and portrait orientation", () => {
expect(pwaManifest.display).toBe("standalone");
expect(pwaManifest.orientation).toBe("portrait");
});
it("scopes to root '/'", () => {
expect(pwaManifest.scope).toBe("/");
expect(pwaManifest.start_url).toContain("/");
});
it("declares all icon sizes (64, 192, 512, maskable, favicon)", () => {
const sizes = pwaManifest.icons?.map((i) => i.sizes) ?? [];
expect(sizes).toContain("64x64");
expect(sizes).toContain("192x192");
expect(sizes).toContain("512x512");
});
it("declares at least one maskable icon", () => {
const maskable = pwaManifest.icons?.find((i) => i.purpose === "maskable");
expect(maskable).toBeDefined();
});
it("includes both wide and narrow screenshots for proper installation UI", () => {
const wide = pwaManifest.screenshots?.filter((s) => s.form_factor === "wide");
const narrow = pwaManifest.screenshots?.filter((s) => s.form_factor === "narrow");
expect(wide?.length).toBeGreaterThan(0);
expect(narrow?.length).toBeGreaterThan(0);
});
it("includes 'resume' in categories for app stores", () => {
expect(pwaManifest.categories).toContain("resume");
});
});
describe("pwaHeadMetaTags", () => {
it("includes theme-color meta tag", () => {
expect(pwaHeadMetaTags).toContainEqual(expect.objectContaining({ name: "theme-color" }));
});
it("declares apple-mobile-web-app capable", () => {
const tag = pwaHeadMetaTags.find((t) => t.name === "apple-mobile-web-app-capable");
expect(tag?.content).toBe("yes");
});
it("uses 'black-translucent' status bar style for iOS", () => {
const tag = pwaHeadMetaTags.find((t) => t.name === "apple-mobile-web-app-status-bar-style");
expect(tag?.content).toBe("black-translucent");
});
});
describe("pwaServiceWorkerRegistrationScript", () => {
it("registers the service worker only when supported", () => {
expect(pwaServiceWorkerRegistrationScript).toContain('"serviceWorker" in navigator');
});
it("registers /sw.js with root scope", () => {
expect(pwaServiceWorkerRegistrationScript).toContain("/sw.js");
expect(pwaServiceWorkerRegistrationScript).toContain('scope: "/"');
});
it("registers on window load to avoid blocking initial paint", () => {
expect(pwaServiceWorkerRegistrationScript).toContain('addEventListener("load"');
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { makeSectionItem } from "./make-section-item";
describe("makeSectionItem", () => {
it("clones the provided item but generates a fresh id", () => {
const original = { id: "original-id", name: "Skill" };
const result = makeSectionItem({ id: "default", name: "" }, original);
expect(result.name).toBe("Skill");
expect(result.id).not.toBe("original-id");
expect(result.id.length).toBeGreaterThan(0);
});
it("uses defaultItem when no item is provided", () => {
const defaultItem = { id: "default", name: "", level: 0 };
const result = makeSectionItem(defaultItem);
expect(result.name).toBe("");
expect(result.level).toBe(0);
expect(result.id).not.toBe("default");
});
it("does not mutate the input item when duplicating", () => {
const original = { id: "original-id", value: "test" };
const before = JSON.stringify(original);
makeSectionItem({ id: "default", value: "" }, original);
expect(JSON.stringify(original)).toBe(before);
});
it("does not mutate the defaultItem", () => {
const defaultItem = { id: "default", value: "x" };
const before = JSON.stringify(defaultItem);
makeSectionItem(defaultItem);
expect(JSON.stringify(defaultItem)).toBe(before);
});
it("generates a unique id per call", () => {
const a = makeSectionItem({ id: "default", name: "" });
const b = makeSectionItem({ id: "default", name: "" });
expect(a.id).not.toBe(b.id);
});
it("preserves all other fields when duplicating", () => {
const item = { id: "x", a: 1, b: { c: 2 }, d: [3] };
const result = makeSectionItem({ id: "default", a: 0, b: { c: 0 }, d: [] }, item);
expect(result.a).toBe(1);
expect(result.b).toEqual({ c: 2 });
expect(result.d).toEqual([3]);
});
});
+246
View File
@@ -0,0 +1,246 @@
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { produce } from "immer";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import {
addItemToSection,
createCustomSectionWithItem,
createPageWithSection,
getCompatibleMoveTargets,
getSourceSectionTitle,
removeItemFromSource,
} from "./move-item";
beforeAll(() => {
// Lingui requires an active locale before t`...` macros can render.
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("getSourceSectionTitle", () => {
it("returns custom section title for custom sections", () => {
const data = produce(defaultResumeData, (draft) => {
draft.customSections.push({
id: "ext-1",
type: "cover-letter",
title: "My Custom Title",
columns: 1,
hidden: false,
items: [],
});
});
expect(getSourceSectionTitle(data, "cover-letter", "ext-1")).toBe("My Custom Title");
});
it("returns localized default title when customSectionId is undefined", () => {
// Default section title comes from t`...` macro — we just check it's a non-empty string
expect(getSourceSectionTitle(defaultResumeData, "experience").length).toBeGreaterThan(0);
});
it("falls back to default title when custom section is not found", () => {
expect(getSourceSectionTitle(defaultResumeData, "experience", "non-existent").length).toBeGreaterThan(0);
});
});
describe("getCompatibleMoveTargets", () => {
it("excludes the source section itself", () => {
// Default has experience in main; trying to move from experience to other targets
const targets = getCompatibleMoveTargets(defaultResumeData, "experience", undefined);
expect(targets[0]?.sections.find((s) => s.sectionId === "experience")).toBeUndefined();
});
it("returns only custom sections of the same type as source", () => {
const data = produce(defaultResumeData, (draft) => {
draft.customSections.push({
id: "ext-1",
type: "cover-letter",
title: "Cover Letter",
columns: 1,
hidden: false,
items: [],
});
draft.metadata.layout.pages[0].main.push("ext-1");
});
// Source is also a cover-letter custom section, but with different id
const targets = getCompatibleMoveTargets(data, "cover-letter", "different-id");
expect(targets[0]?.sections.find((s) => s.sectionId === "ext-1")).toBeDefined();
});
it("returns empty per page when no compatible targets", () => {
const targets = getCompatibleMoveTargets(defaultResumeData, "cover-letter", undefined);
// No custom sections of cover-letter type in default
for (const page of targets) {
expect(page.sections).toHaveLength(0);
}
});
it("returns one entry per page", () => {
const targets = getCompatibleMoveTargets(defaultResumeData, "experience", undefined);
expect(targets).toHaveLength(defaultResumeData.metadata.layout.pages.length);
});
});
describe("removeItemFromSource", () => {
it("removes an item from a built-in section and returns its id", () => {
const initial = produce(defaultResumeData, (draft) => {
draft.sections.skills.items.push({
id: "skill-1",
hidden: false,
icon: "",
iconColor: "",
name: "Go",
proficiency: "",
level: 4,
keywords: [],
});
});
// Capture the id before produce revokes the draft proxy.
let removedId: string | undefined;
const result = produce(initial, (draft) => {
const removed = removeItemFromSource(draft, "skill-1", "skills");
removedId = (removed as { id: string } | null)?.id;
});
expect(result.sections.skills.items).toHaveLength(0);
expect(removedId).toBe("skill-1");
});
it("returns null when item id is not found in built-in section", () => {
let removed: unknown;
produce(defaultResumeData, (draft) => {
removed = removeItemFromSource(draft, "non-existent", "skills");
});
expect(removed).toBeNull();
});
it("removes an item from a custom section and returns its id", () => {
const initial = produce(defaultResumeData, (draft) => {
draft.customSections.push({
id: "ext-1",
type: "cover-letter",
title: "",
columns: 1,
hidden: false,
items: [{ id: "i1" } as never],
});
});
let removedId: string | undefined;
const result = produce(initial, (draft) => {
const removed = removeItemFromSource(draft, "i1", "cover-letter", "ext-1");
removedId = (removed as { id?: string } | null)?.id;
});
expect(result.customSections[0]?.items).toHaveLength(0);
expect(removedId).toBe("i1");
});
it("returns null when custom section does not exist", () => {
let removed: unknown;
produce(defaultResumeData, (draft) => {
removed = removeItemFromSource(draft, "i1", "cover-letter", "non-existent");
});
expect(removed).toBeNull();
});
});
describe("addItemToSection", () => {
it("adds to a matching built-in section", () => {
const item = {
id: "skill-x",
hidden: false,
icon: "",
iconColor: "",
name: "Rust",
proficiency: "",
level: 4,
keywords: [],
};
const result = produce(defaultResumeData, (draft) => {
addItemToSection(draft, item, "skills", "skills");
});
expect(result.sections.skills.items).toHaveLength(1);
});
it("adds to a custom section by id", () => {
const initial = produce(defaultResumeData, (draft) => {
draft.customSections.push({
id: "ext-1",
type: "cover-letter",
title: "",
columns: 1,
hidden: false,
items: [],
});
});
const result = produce(initial, (draft) => {
addItemToSection(draft, { id: "i1" } as never, "ext-1", "cover-letter");
});
expect(result.customSections[0]?.items).toHaveLength(1);
});
it("does not add when target id does not match anything", () => {
const result = produce(defaultResumeData, (draft) => {
addItemToSection(draft, { id: "x" } as never, "non-existent", "skills");
});
expect(result.sections.skills.items).toHaveLength(0);
});
});
describe("createCustomSectionWithItem", () => {
it("creates a new custom section and adds it to the target page main", () => {
let newSectionId = "";
const result = produce(defaultResumeData, (draft) => {
newSectionId = createCustomSectionWithItem(draft, { id: "i1" } as never, "cover-letter", "My Section", 0);
});
expect(result.customSections).toHaveLength(1);
expect(result.customSections[0]?.title).toBe("My Section");
expect(result.customSections[0]?.items).toHaveLength(1);
expect(result.metadata.layout.pages[0]?.main).toContain(newSectionId);
});
it("returns the generated section id", () => {
let newSectionId = "";
produce(defaultResumeData, (draft) => {
newSectionId = createCustomSectionWithItem(draft, { id: "i1" } as never, "cover-letter", "X", 0);
});
expect(newSectionId.length).toBeGreaterThan(0);
});
it("does not crash on out-of-range page index (no main column to push to)", () => {
// targetPageIndex=99 — page does not exist; section should still be created
const result = produce(defaultResumeData, (draft) => {
createCustomSectionWithItem(draft, { id: "i1" } as never, "cover-letter", "X", 99);
});
expect(result.customSections).toHaveLength(1);
});
});
describe("createPageWithSection", () => {
it("creates a new page with the new custom section in main", () => {
const initialPageCount = defaultResumeData.metadata.layout.pages.length;
const result = produce(defaultResumeData, (draft) => {
createPageWithSection(draft, { id: "i1" } as never, "cover-letter", "My New Page");
});
expect(result.metadata.layout.pages).toHaveLength(initialPageCount + 1);
const newPage = result.metadata.layout.pages.at(-1);
expect(newPage?.main).toHaveLength(1);
expect(newPage?.sidebar).toEqual([]);
expect(newPage?.fullWidth).toBe(false);
});
it("adds the custom section to customSections array", () => {
const result = produce(defaultResumeData, (draft) => {
createPageWithSection(draft, { id: "i1" } as never, "cover-letter", "My Section");
});
expect(result.customSections).toHaveLength(1);
expect(result.customSections[0]?.title).toBe("My Section");
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { produce } from "immer";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createSectionItem, updateSectionItem } from "./section-actions";
describe("createSectionItem", () => {
it("appends to a built-in section's items array", () => {
const result = produce(defaultResumeData, (draft) => {
createSectionItem(draft, "skills", { id: "1", name: "Go" });
});
expect(result.sections.skills.items).toHaveLength(1);
expect((result.sections.skills.items[0] as { name?: string })?.name).toBe("Go");
});
it("appends to a custom section by id", () => {
const initial = produce(defaultResumeData, (draft) => {
draft.customSections.push({
id: "custom-1",
type: "cover-letter",
title: "Cover Letter",
columns: 1,
hidden: false,
items: [],
});
});
const result = produce(initial, (draft) => {
createSectionItem(draft, "skills", { id: "1", text: "Custom item" }, "custom-1");
});
const customSection = result.customSections.find((s) => s.id === "custom-1");
expect(customSection?.items).toHaveLength(1);
// Built-in section is untouched
expect(result.sections.skills.items).toHaveLength(0);
});
it("does nothing when customSectionId does not match", () => {
const result = produce(defaultResumeData, (draft) => {
createSectionItem(draft, "skills", { id: "1", name: "x" }, "non-existent");
});
// Skills not touched, no custom section to modify
expect(result.sections.skills.items).toHaveLength(0);
});
});
describe("updateSectionItem", () => {
it("replaces the matching item in a built-in section", () => {
const initial = produce(defaultResumeData, (draft) => {
createSectionItem(draft, "skills", { id: "abc", name: "Go", level: 4 });
});
const result = produce(initial, (draft) => {
updateSectionItem(draft, "skills", { id: "abc", name: "Go", level: 5 });
});
const item = result.sections.skills.items[0] as { id?: string; name?: string; level?: number };
expect(item?.level).toBe(5);
});
it("does nothing when item id does not exist", () => {
const initial = produce(defaultResumeData, (draft) => {
createSectionItem(draft, "skills", { id: "abc", name: "Go" });
});
const before = JSON.stringify(initial.sections.skills);
const result = produce(initial, (draft) => {
updateSectionItem(draft, "skills", { id: "non-existent", name: "X" });
});
expect(JSON.stringify(result.sections.skills)).toBe(before);
});
it("replaces the matching item in a custom section", () => {
const initial = produce(defaultResumeData, (draft) => {
draft.customSections.push({
id: "custom-1",
type: "cover-letter",
title: "",
columns: 1,
hidden: false,
items: [{ id: "x", value: "old" } as never],
});
});
const result = produce(initial, (draft) => {
updateSectionItem(draft, "skills", { id: "x", value: "new" }, "custom-1");
});
const customSection = result.customSections.find((s) => s.id === "custom-1");
expect((customSection?.items[0] as { value?: string }).value).toBe("new");
});
it("does nothing when custom section is not found", () => {
const before = JSON.stringify(defaultResumeData);
const result = produce(defaultResumeData, (draft) => {
updateSectionItem(draft, "skills", { id: "x", value: "new" }, "non-existent");
});
expect(JSON.stringify(result)).toBe(before);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { isTheme, themeMap } from "./theme";
describe("isTheme", () => {
it("returns true for 'light'", () => {
expect(isTheme("light")).toBe(true);
});
it("returns true for 'dark'", () => {
expect(isTheme("dark")).toBe(true);
});
it("returns false for unknown theme", () => {
expect(isTheme("auto")).toBe(false);
expect(isTheme("system")).toBe(false);
});
it("returns false for empty string", () => {
expect(isTheme("")).toBe(false);
});
it("is case-sensitive", () => {
expect(isTheme("Light")).toBe(false);
expect(isTheme("DARK")).toBe(false);
});
});
describe("themeMap", () => {
it("includes a descriptor for each theme", () => {
expect(themeMap.light).toBeDefined();
expect(themeMap.dark).toBeDefined();
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { buildAiExtractionTemplate } from "./extraction-template";
describe("buildAiExtractionTemplate", () => {
it("returns a copy of defaultResumeData with one item per section", () => {
const template = buildAiExtractionTemplate();
for (const [, section] of Object.entries(template.sections)) {
expect(Array.isArray(section.items)).toBe(true);
expect(section.items).toHaveLength(1);
}
});
it("populates the customFields with a single empty entry", () => {
const template = buildAiExtractionTemplate();
expect(template.basics.customFields).toEqual([{ id: "", icon: "", text: "", link: "" }]);
});
it("does not mutate defaultResumeData", () => {
const before = JSON.stringify(defaultResumeData);
buildAiExtractionTemplate();
expect(JSON.stringify(defaultResumeData)).toBe(before);
});
it("creates skill items with empty fields and zero level", () => {
const template = buildAiExtractionTemplate();
const item = template.sections.skills.items[0] as Record<string, unknown>;
expect(item.id).toBe("");
expect(item.hidden).toBe(false);
expect(item.name).toBe("");
expect(item.level).toBe(0);
expect(item.keywords).toEqual([]);
});
it("creates experience items with nested website object", () => {
const template = buildAiExtractionTemplate();
const item = template.sections.experience.items[0] as Record<string, unknown>;
expect(item.website).toEqual({ url: "", label: "", inlineLink: false });
expect(item.company).toBe("");
expect(item.period).toBe("");
});
it("preserves unmodified sections from default (e.g., title)", () => {
const template = buildAiExtractionTemplate();
expect(template.sections.skills.title).toBe(defaultResumeData.sections.skills.title);
expect(template.sections.skills.hidden).toBe(defaultResumeData.sections.skills.hidden);
expect(template.sections.skills.columns).toBe(defaultResumeData.sections.skills.columns);
});
it("creates languages items with empty language and zero level", () => {
const template = buildAiExtractionTemplate();
const item = template.sections.languages.items[0] as Record<string, unknown>;
expect(item.language).toBe("");
expect(item.level).toBe(0);
});
it("creates references items with phone field", () => {
const template = buildAiExtractionTemplate();
const item = template.sections.references.items[0] as Record<string, unknown>;
expect(item.phone).toBe("");
expect(item.name).toBe("");
});
});
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { sanitizeAndParseResumeJson } from "./sanitize";
describe("sanitizeAndParseResumeJson", () => {
it("parses a minimal but well-formed resume JSON", () => {
const json = JSON.stringify({
basics: { name: "Alice" },
});
const result = sanitizeAndParseResumeJson(json);
expect(result.data.basics.name).toBe("Alice");
expect(result.diagnostics.coercions).toEqual([]);
});
it("repairs unquoted keys and trailing commas via jsonrepair", () => {
const broken = `{ basics: { name: "Bob", }, }`;
const result = sanitizeAndParseResumeJson(broken);
expect(result.data.basics.name).toBe("Bob");
});
it("strips text before and after the JSON block", () => {
const messy = `Some preamble text\n\n{ "basics": { "name": "Carol" } }\n\nSome trailing text`;
const result = sanitizeAndParseResumeJson(messy);
expect(result.data.basics.name).toBe("Carol");
});
it("coerces 'true'/'false' strings to booleans", () => {
const json = JSON.stringify({
basics: { name: "Test" },
summary: { hidden: "true", title: "", columns: 1, content: "" },
});
const result = sanitizeAndParseResumeJson(json);
expect(result.data.summary.hidden).toBe(true);
expect(result.diagnostics.coercions.length).toBeGreaterThan(0);
});
it("coerces numeric strings to numbers", () => {
const json = JSON.stringify({
basics: { name: "Test" },
summary: { hidden: false, title: "", columns: "2", content: "" },
});
const result = sanitizeAndParseResumeJson(json);
expect(result.data.summary.columns).toBe(2);
});
it("drops items missing required fields with diagnostics", () => {
const json = JSON.stringify({
basics: { name: "Test" },
sections: {
skills: {
items: [
{ name: "TypeScript", level: 4 },
{ name: "" }, // dropped: missing name
{}, // dropped: missing name
],
},
},
});
const result = sanitizeAndParseResumeJson(json);
expect(result.data.sections.skills.items).toHaveLength(1);
expect(result.diagnostics.droppedSectionItems.length).toBeGreaterThan(0);
expect(result.diagnostics.salvageApplied).toBe(true);
});
it("auto-generates ids for items lacking them", () => {
const json = JSON.stringify({
basics: { name: "Test" },
sections: {
skills: { items: [{ name: "Go" }] },
},
});
const result = sanitizeAndParseResumeJson(json);
const item = result.data.sections.skills.items[0];
expect(item).toBeDefined();
expect(item?.id?.length).toBeGreaterThan(0);
expect(result.diagnostics.salvageApplied).toBe(true);
});
it("defaults hidden=false on items missing the field", () => {
const json = JSON.stringify({
basics: { name: "Test" },
sections: {
skills: { items: [{ name: "Go" }] },
},
});
const result = sanitizeAndParseResumeJson(json);
expect(result.data.sections.skills.items[0]?.hidden).toBe(false);
});
it("uses defaultResumeData picture and metadata regardless of input", () => {
const json = JSON.stringify({
basics: { name: "Test" },
picture: { url: "https://attacker.com/pic.png", size: 1024 }, // overridden
metadata: { template: "unknown" }, // overridden
});
const result = sanitizeAndParseResumeJson(json);
expect(result.data.metadata.template).toBe("onyx");
expect(result.data.picture.url).toBe("");
});
it("preserves valid items alongside invalid ones in the same section", () => {
const json = JSON.stringify({
basics: { name: "Test" },
sections: {
experience: {
items: [
{ company: "Acme", position: "Engineer" },
{ position: "Designer" }, // dropped: missing company
],
},
},
});
const result = sanitizeAndParseResumeJson(json);
expect(result.data.sections.experience.items).toHaveLength(1);
expect(result.data.sections.experience.items[0]?.company).toBe("Acme");
});
it("clears customSections to empty array regardless of input", () => {
const json = JSON.stringify({
basics: { name: "Test" },
customSections: [{ key: "hack" }],
});
const result = sanitizeAndParseResumeJson(json);
expect(result.data.customSections).toEqual([]);
});
it("throws when input is non-JSON garbage", () => {
expect(() => sanitizeAndParseResumeJson("not even close to json")).toThrow();
});
it("preserves coercion path entries in diagnostics", () => {
const json = JSON.stringify({
basics: { name: "Test" },
summary: { hidden: "true", title: "", columns: 1, content: "" },
});
const result = sanitizeAndParseResumeJson(json);
const hiddenCoercion = result.diagnostics.coercions.find((c) => c.path === "summary.hidden");
expect(hiddenCoercion).toEqual({ path: "summary.hidden", fromType: "string", toType: "boolean" });
});
});
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./resume-access-policy";
describe("isOwner", () => {
it("returns true when viewer.id matches resume.userId", () => {
expect(isOwner({ userId: "u1", isPublic: false }, { id: "u1" })).toBe(true);
});
it("returns false when viewer.id differs", () => {
expect(isOwner({ userId: "u1", isPublic: false }, { id: "u2" })).toBe(false);
});
it("returns false when viewer is null (anonymous)", () => {
expect(isOwner({ userId: "u1", isPublic: false }, null)).toBe(false);
});
});
describe("assertCanView", () => {
it("does not throw when viewer is owner of private resume", () => {
expect(() => assertCanView({ userId: "u1", isPublic: false }, { id: "u1" })).not.toThrow();
});
it("does not throw when resume is public regardless of viewer", () => {
expect(() => assertCanView({ userId: "u1", isPublic: true }, { id: "u2" })).not.toThrow();
expect(() => assertCanView({ userId: "u1", isPublic: true }, null)).not.toThrow();
});
it("throws NOT_FOUND for private resume viewed by non-owner", () => {
expect(() => assertCanView({ userId: "u1", isPublic: false }, { id: "u2" })).toThrow();
});
it("throws NOT_FOUND for private resume viewed anonymously", () => {
expect(() => assertCanView({ userId: "u1", isPublic: false }, null)).toThrow();
});
it("error code is NOT_FOUND (not FORBIDDEN) to prevent existence disclosure", () => {
try {
assertCanView({ userId: "u1", isPublic: false }, null);
expect.unreachable();
} catch (error: unknown) {
expect((error as { code?: string }).code).toBe("NOT_FOUND");
}
});
});
describe("redactResumeForViewer", () => {
it("returns the resume unchanged for owner", () => {
const resume = {
name: "My Dashboard Title",
data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Private" } },
};
expect(redactResumeForViewer(resume, true)).toBe(resume);
});
it("strips name to empty for non-owner", () => {
const resume = {
name: "Senior Eng @ Foo — final draft",
data: defaultResumeData,
};
const result = redactResumeForViewer(resume, false);
expect(result.name).toBe("");
});
it("strips metadata.notes for non-owner", () => {
const resume = {
name: "Title",
data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Private notes" } },
};
const result = redactResumeForViewer(resume, false);
expect(result.data.metadata.notes).toBe("");
});
it("preserves resume.data.basics.name (the person's name) for non-owner", () => {
const resume = {
name: "Dashboard title",
data: {
...defaultResumeData,
basics: { ...defaultResumeData.basics, name: "Alice Smith" },
},
};
const result = redactResumeForViewer(resume, false);
expect(result.data.basics.name).toBe("Alice Smith");
});
it("does not mutate the input", () => {
const resume = {
name: "Title",
data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Notes" } },
};
const before = JSON.stringify(resume);
redactResumeForViewer(resume, false);
expect(JSON.stringify(resume)).toBe(before);
});
it("preserves additional resume fields", () => {
const resume = {
name: "Title",
data: defaultResumeData,
extraField: "extra",
};
const result = redactResumeForViewer(resume, false);
expect((result as typeof resume).extraField).toBe("extra");
});
});
describe("shouldCountForStatistics", () => {
it("returns false when viewer is the owner", () => {
expect(shouldCountForStatistics({ userId: "u1", isPublic: true }, { id: "u1" })).toBe(false);
});
it("returns true for anonymous viewers", () => {
expect(shouldCountForStatistics({ userId: "u1", isPublic: true }, null)).toBe(true);
});
it("returns true for non-owner viewers", () => {
expect(shouldCountForStatistics({ userId: "u1", isPublic: true }, { id: "u2" })).toBe(true);
});
});
+238 -4
View File
@@ -1,5 +1,20 @@
import { describe, expect, it } from "vitest";
import { fontList, getPdfCjkFallbackFontFamily, getWebFontSource } from "./index";
import {
buildResumeFontFamily,
fontList,
getFallbackWebFontFamilies,
getFont,
getFontDisplayName,
getFontSearchKeywords,
getLoadableWebFontWeights,
getPdfCjkFallbackFontFamily,
getWebFont,
getWebFontSource,
isStandardPdfFontFamily,
standardFontList,
webFontList,
webFontMap,
} from "./index";
const sortFontFamilies = (families: string[]) => {
return [...families].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
@@ -8,18 +23,237 @@ const sortFontFamilies = (families: string[]) => {
describe("fontList", () => {
it("is ordered by font family name instead of localized display name", () => {
const families = fontList.map((font) => font.family);
expect(families).toEqual(sortFontFamilies(families));
});
it("contains standard PDF fonts", () => {
const families = fontList.map((font) => font.family);
expect(families).toContain("Helvetica");
expect(families).toContain("Courier");
expect(families).toContain("Times-Roman");
});
it("merges standardFontList and webFontList without duplicates", () => {
const expectedSize = standardFontList.length + webFontList.length;
expect(fontList).toHaveLength(expectedSize);
});
});
describe("font source helpers", () => {
describe("standardFontList", () => {
it("contains only fonts not present in webFontMap", () => {
for (const font of standardFontList) {
expect(webFontMap.has(font.family)).toBe(false);
}
});
it("marks all standard fonts with type='standard'", () => {
for (const font of standardFontList) {
expect(font.type).toBe("standard");
}
});
});
describe("getFont", () => {
it("returns a font record by family", () => {
expect(getFont("Helvetica")).toBeDefined();
expect(getFont("Helvetica")?.family).toBe("Helvetica");
});
it("returns undefined for unknown families", () => {
expect(getFont("DefinitelyNotAFont")).toBeUndefined();
});
});
describe("getFontDisplayName", () => {
it("returns localized name when available", () => {
expect(getFontDisplayName("Noto Sans SC")).toBe("思源黑体");
expect(getFontDisplayName("PingFang SC")).toBe("苹方");
});
it("returns the family name when no localized name exists", () => {
expect(getFontDisplayName("Helvetica")).toBe("Helvetica");
expect(getFontDisplayName("Roboto")).toBe("Roboto");
});
});
describe("getFontSearchKeywords", () => {
it("returns family for non-CJK fonts", () => {
const keywords = getFontSearchKeywords("Helvetica");
expect(keywords).toContain("Helvetica");
expect(keywords).not.toContain("中文");
});
it("includes localized display name and 中文 marker for CJK fonts", () => {
const keywords = getFontSearchKeywords("Noto Sans SC");
expect(keywords).toContain("Noto Sans SC");
expect(keywords).toContain("思源黑体");
expect(keywords).toContain("中文");
});
it("deduplicates entries", () => {
const keywords = getFontSearchKeywords("Helvetica");
expect(new Set(keywords).size).toBe(keywords.length);
});
});
describe("isStandardPdfFontFamily", () => {
it("returns true for Helvetica, Courier, Times-Roman", () => {
expect(isStandardPdfFontFamily("Helvetica")).toBe(true);
expect(isStandardPdfFontFamily("Courier")).toBe(true);
expect(isStandardPdfFontFamily("Times-Roman")).toBe(true);
});
it("returns false for unknown families", () => {
expect(isStandardPdfFontFamily("Not-A-Font")).toBe(false);
});
it("returns false for non-standard web fonts", () => {
// Roboto is a web font, not a standard PDF font
const roboto = getWebFont("Roboto");
if (roboto) expect(isStandardPdfFontFamily("Roboto")).toBe(false);
});
});
describe("getWebFont", () => {
it("returns webFont record for known web font family", () => {
// Spot-check a font that should be in the list
const font = getWebFont("Roboto");
if (font) {
expect(font.type).toBe("web");
expect(font.family).toBe("Roboto");
}
});
it("returns undefined for non-web families", () => {
expect(getWebFont("Helvetica")).toBeUndefined();
expect(getWebFont("definitely-not-a-font")).toBeUndefined();
});
});
describe("getWebFontSource", () => {
it("uses the full normal font source when an italic variant is unavailable", () => {
expect(getWebFontSource("Noto Serif SC", "400", true)).toBe(getWebFontSource("Noto Serif SC", "400", false));
});
it("returns CJK PDF fallbacks for standard PDF fonts", () => {
it("returns null for unknown fonts", () => {
expect(getWebFontSource("definitely-not-a-font", "400")).toBeNull();
});
it("defaults to weight 400 when not specified", () => {
const roboto = getWebFont("Roboto");
if (roboto) {
const source = getWebFontSource("Roboto");
expect(source).toBeTruthy();
}
});
});
describe("getPdfCjkFallbackFontFamily", () => {
it("returns Noto Sans SC for sans-serif/standard PDF fonts", () => {
expect(getPdfCjkFallbackFontFamily("Helvetica")).toBe("Noto Sans SC");
});
it("returns Noto Serif SC for serif fonts", () => {
expect(getPdfCjkFallbackFontFamily("Times-Roman")).toBe("Noto Serif SC");
});
it("returns null when family already is the CJK fallback", () => {
expect(getPdfCjkFallbackFontFamily("Noto Sans SC")).toBeNull();
});
});
describe("getFallbackWebFontFamilies", () => {
it("returns empty array for standard PDF fonts", () => {
expect(getFallbackWebFontFamilies("Helvetica")).toEqual([]);
expect(getFallbackWebFontFamilies("Courier")).toEqual([]);
expect(getFallbackWebFontFamilies("Times-Roman")).toEqual([]);
});
it("returns the primary CJK web font for non-standard, non-CJK families", () => {
// e.g. Roboto (sans-serif) → Noto Sans SC fallback
const roboto = getWebFont("Roboto");
if (roboto) {
expect(getFallbackWebFontFamilies("Roboto")).toEqual(["Noto Sans SC"]);
}
});
it("returns empty when family is already its primary CJK fallback", () => {
expect(getFallbackWebFontFamilies("Noto Sans SC")).toEqual([]);
});
});
describe("getLoadableWebFontWeights", () => {
it("returns empty array for unknown fonts", () => {
expect(getLoadableWebFontWeights("definitely-not-a-font", ["400"])).toEqual([]);
});
it("returns matching weights when preferred weights are available", () => {
const roboto = getWebFont("Roboto");
if (roboto) {
const result = getLoadableWebFontWeights("Roboto", ["400", "700"]);
for (const weight of result) {
expect(roboto.weights).toContain(weight);
}
}
});
it("falls back to default weights when no preferences match", () => {
const roboto = getWebFont("Roboto");
if (roboto) {
const result = getLoadableWebFontWeights("Roboto", ["999"]);
expect(result.length).toBeGreaterThan(0);
}
});
it("deduplicates preferred weights", () => {
const roboto = getWebFont("Roboto");
if (roboto?.weights.includes("400")) {
const result = getLoadableWebFontWeights("Roboto", ["400", "400"]);
expect(result).toEqual(["400"]);
}
});
});
describe("buildResumeFontFamily", () => {
it("wraps the primary family in single quotes", () => {
const result = buildResumeFontFamily("Roboto");
expect(result.startsWith("'Roboto',")).toBe(true);
});
it("includes generic sans-serif fallback by default", () => {
const result = buildResumeFontFamily("Roboto");
expect(result.endsWith("sans-serif")).toBe(true);
});
it("uses serif fallback for serif-category fonts", () => {
expect(buildResumeFontFamily("Times-Roman").endsWith("serif")).toBe(true);
});
it("includes system-ui and Segoe UI fallbacks", () => {
const result = buildResumeFontFamily("Roboto");
expect(result).toContain("system-ui");
expect(result).toContain("Segoe UI");
});
it("includes CJK fallbacks for sans-serif fonts", () => {
const result = buildResumeFontFamily("Roboto");
expect(result).toContain("Noto Sans SC");
});
it("includes CJK serif fallbacks for serif fonts", () => {
const result = buildResumeFontFamily("Times-Roman");
expect(result).toContain("Noto Serif SC");
});
it("does not duplicate primary family in fallback list", () => {
const result = buildResumeFontFamily("Noto Sans SC");
// Family should appear once
const occurrences = result.split("Noto Sans SC").length - 1;
expect(occurrences).toBe(1);
});
it("escapes single quotes in family names", () => {
const result = buildResumeFontFamily("Bob's Font");
expect(result).toContain("Bob\\'s Font");
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { getResumeSectionTitle, resolveSectionTitle } from "./section-title";
describe("resolveSectionTitle", () => {
it("returns the trimmed user-provided title when non-empty", () => {
const result = resolveSectionTitle("My Title", {
sectionId: "experience",
locale: "en-US",
sectionKind: "builtin",
});
expect(result).toBe("My Title");
});
it("falls through to resolver when title is empty", () => {
const resolver = vi.fn().mockReturnValue("Resolved");
const result = resolveSectionTitle(
"",
{
sectionId: "experience",
locale: "en-US",
sectionKind: "builtin",
},
resolver,
);
expect(resolver).toHaveBeenCalled();
expect(result).toBe("Resolved");
});
it("falls through to legacyFallback when title and resolver return blank", () => {
const resolver = vi.fn().mockReturnValue("");
const result = resolveSectionTitle(
"",
{ sectionId: "experience", locale: "en-US", sectionKind: "builtin" },
resolver,
"Legacy Title",
);
expect(result).toBe("Legacy Title");
});
it("falls through to defaultEnglishTitle when no other source available", () => {
const result = resolveSectionTitle("", {
sectionId: "experience",
locale: "en-US",
sectionKind: "builtin",
defaultEnglishTitle: "Experience",
});
expect(result).toBe("Experience");
});
it("falls through to sectionId as last resort", () => {
const result = resolveSectionTitle("", {
sectionId: "experience",
locale: "en-US",
sectionKind: "builtin",
});
expect(result).toBe("experience");
});
it("treats whitespace-only title as empty", () => {
const result = resolveSectionTitle(" ", {
sectionId: "x",
locale: "en-US",
sectionKind: "builtin",
defaultEnglishTitle: "Default",
});
expect(result).toBe("Default");
});
});
describe("getResumeSectionTitle", () => {
it("uses summary's user-provided title when set", () => {
const data = {
...defaultResumeData,
summary: { ...defaultResumeData.summary, title: "Profile Summary" },
};
expect(getResumeSectionTitle(data, "summary")).toBe("Profile Summary");
});
it("uses default English title for summary when title is empty", () => {
expect(getResumeSectionTitle(defaultResumeData, "summary")).toBe("Summary");
});
it("uses default English title for built-in sections", () => {
expect(getResumeSectionTitle(defaultResumeData, "experience")).toBe("Experience");
expect(getResumeSectionTitle(defaultResumeData, "skills")).toBe("Skills");
});
it("falls back to legacyFallback for unknown section ids", () => {
expect(getResumeSectionTitle(defaultResumeData, "unknown-id", "Custom Legacy")).toBe("Custom Legacy");
});
it("falls back to sectionId for unknown sections without legacyFallback", () => {
expect(getResumeSectionTitle(defaultResumeData, "unknown-section")).toBe("unknown-section");
});
it("calls user's resolveSectionTitle when present", () => {
const resolver = vi.fn().mockReturnValue("Translated");
const data = { ...defaultResumeData, resolveSectionTitle: resolver };
expect(getResumeSectionTitle(data, "experience")).toBe("Translated");
expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sectionId: "experience", sectionKind: "builtin" }));
});
it("uses custom section's title when defined", () => {
const data = {
...defaultResumeData,
customSections: [
{
id: "ext-1",
type: "cover-letter",
title: "My Cover Letter",
columns: 1,
hidden: false,
items: [],
},
],
};
expect(getResumeSectionTitle(data, "ext-1")).toBe("My Cover Letter");
});
it("uses default English title 'Cover Letter' for cover-letter custom sections", () => {
const data = {
...defaultResumeData,
customSections: [
{
id: "ext-1",
type: "cover-letter",
title: "",
columns: 1,
hidden: false,
items: [],
},
],
};
expect(getResumeSectionTitle(data, "ext-1")).toBe("Cover Letter");
});
});
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { getSectionItemRows, getSectionItemsLayout, shouldUseSectionTimeline } from "./columns";
describe("getSectionItemsLayout", () => {
it("returns single-column layout for columns=1", () => {
const layout = getSectionItemsLayout({ columns: 1, rowGap: 4, columnGap: 6 });
expect(layout.columns).toBe(1);
expect(layout.isGrid).toBe(false);
expect(layout.rowStyle).toBeUndefined();
expect(layout.itemStyle).toBeUndefined();
});
it("returns grid layout for columns=2", () => {
const layout = getSectionItemsLayout({ columns: 2, rowGap: 4, columnGap: 6 });
expect(layout.columns).toBe(2);
expect(layout.isGrid).toBe(true);
expect(layout.rowStyle).toEqual({ flexDirection: "row", columnGap: 6 });
expect(layout.itemStyle).toMatchObject({
flexBasis: 0,
flexGrow: 1,
flexShrink: 1,
minWidth: 0,
maxWidth: "100%",
overflow: "hidden",
});
});
it("clamps columns above 6 to 6", () => {
const layout = getSectionItemsLayout({ columns: 99, rowGap: 4, columnGap: 6 });
expect(layout.columns).toBe(6);
});
it("clamps columns below 1 to 1", () => {
const layout = getSectionItemsLayout({ columns: 0, rowGap: 4, columnGap: 6 });
expect(layout.columns).toBe(1);
});
it("treats non-integer columns as 1", () => {
const layout = getSectionItemsLayout({ columns: 2.5, rowGap: 4, columnGap: 6 });
expect(layout.columns).toBe(1);
});
it("treats non-numeric columns as 1", () => {
const layout = getSectionItemsLayout({ columns: "2", rowGap: 4, columnGap: 6 });
expect(layout.columns).toBe(1);
});
it("treats Infinity as 1", () => {
const layout = getSectionItemsLayout({ columns: Number.POSITIVE_INFINITY, rowGap: 4, columnGap: 6 });
expect(layout.columns).toBe(1);
});
it("includes containerStyle.rowGap", () => {
const layout = getSectionItemsLayout({ columns: 1, rowGap: 8, columnGap: 6 });
expect(layout.containerStyle).toEqual({ rowGap: 8 });
});
});
describe("getSectionItemRows", () => {
it("groups items into rows of N columns", () => {
expect(getSectionItemRows([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
});
it("returns single-item rows for columns=1", () => {
expect(getSectionItemRows([1, 2, 3], 1)).toEqual([[1], [2], [3]]);
});
it("returns one row when items fit in one row", () => {
expect(getSectionItemRows([1, 2], 3)).toEqual([[1, 2]]);
});
it("returns empty array for empty input", () => {
expect(getSectionItemRows([], 2)).toEqual([]);
});
it("clamps invalid columns to 1", () => {
expect(getSectionItemRows([1, 2], 0)).toEqual([[1], [2]]);
expect(getSectionItemRows([1, 2], "not-a-number")).toEqual([[1], [2]]);
});
it("clamps columns above 6 to 6", () => {
expect(getSectionItemRows([1, 2, 3, 4, 5, 6, 7, 8], 99)).toEqual([
[1, 2, 3, 4, 5, 6],
[7, 8],
]);
});
});
describe("shouldUseSectionTimeline", () => {
it("returns true only for sectionTimeline=true, placement='main', columns=1", () => {
expect(shouldUseSectionTimeline({ sectionTimeline: true, placement: "main", columns: 1 })).toBe(true);
});
it("returns false when placement is not 'main'", () => {
expect(shouldUseSectionTimeline({ sectionTimeline: true, placement: "sidebar", columns: 1 })).toBe(false);
});
it("returns false when columns > 1 (after normalization)", () => {
expect(shouldUseSectionTimeline({ sectionTimeline: true, placement: "main", columns: 2 })).toBe(false);
});
it("returns false when sectionTimeline=false", () => {
expect(shouldUseSectionTimeline({ sectionTimeline: false, placement: "main", columns: 1 })).toBe(false);
});
it("returns true when columns is invalid (normalized to 1)", () => {
expect(shouldUseSectionTimeline({ sectionTimeline: true, placement: "main", columns: 0 })).toBe(true);
});
});
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { filterItems, filterSections, hasVisibleItems, isSectionVisible, isVisibleSummary } from "./filtering";
describe("filterItems", () => {
it("returns only items where hidden is false", () => {
const items = [
{ id: 1, hidden: false },
{ id: 2, hidden: true },
{ id: 3, hidden: false },
];
expect(filterItems(items)).toEqual([
{ id: 1, hidden: false },
{ id: 3, hidden: false },
]);
});
it("returns empty array when all items are hidden", () => {
const items = [{ hidden: true }, { hidden: true }];
expect(filterItems(items)).toEqual([]);
});
it("returns empty array for empty input", () => {
expect(filterItems([])).toEqual([]);
});
it("preserves additional properties on items", () => {
const items = [{ hidden: false, name: "Alice", level: 4 }];
expect(filterItems(items)).toEqual([{ hidden: false, name: "Alice", level: 4 }]);
});
});
describe("hasVisibleItems", () => {
it("returns false when section.hidden is true", () => {
expect(hasVisibleItems({ hidden: true, items: [{ hidden: false }] })).toBe(false);
});
it("returns false when no items are visible", () => {
expect(hasVisibleItems({ hidden: false, items: [{ hidden: true }] })).toBe(false);
});
it("returns true when at least one item is visible and section not hidden", () => {
expect(hasVisibleItems({ hidden: false, items: [{ hidden: false }] })).toBe(true);
});
it("returns false for empty items", () => {
expect(hasVisibleItems({ hidden: false, items: [] })).toBe(false);
});
});
describe("isVisibleSummary", () => {
it("returns true when not hidden and content is non-empty", () => {
expect(isVisibleSummary({ hidden: false, content: "<p>Some text</p>" })).toBe(true);
});
it("returns false when summary is hidden", () => {
expect(isVisibleSummary({ hidden: true, content: "<p>Text</p>" })).toBe(false);
});
it("returns false when content is empty after trimming", () => {
expect(isVisibleSummary({ hidden: false, content: " \n " })).toBe(false);
});
it("returns false when content is empty", () => {
expect(isVisibleSummary({ hidden: false, content: "" })).toBe(false);
});
});
describe("isSectionVisible", () => {
const data = {
summary: { hidden: false, content: "<p>Hi</p>" },
sections: {
experience: { hidden: false, items: [{ hidden: false }] },
skills: { hidden: false, items: [] },
education: { hidden: true, items: [{ hidden: false }] },
},
customSections: [{ id: "ext-1", hidden: false, items: [{ hidden: false }] }],
};
it("returns true for visible summary", () => {
expect(isSectionVisible("summary", data)).toBe(true);
});
it("returns false for hidden summary", () => {
expect(isSectionVisible("summary", { ...data, summary: { hidden: true, content: "<p>x</p>" } })).toBe(false);
});
it("returns true for built-in section with visible items", () => {
expect(isSectionVisible("experience", data)).toBe(true);
});
it("returns false for built-in section with no items", () => {
expect(isSectionVisible("skills", data)).toBe(false);
});
it("returns false for built-in section with hidden flag", () => {
expect(isSectionVisible("education", data)).toBe(false);
});
it("returns true for matching custom section by id", () => {
expect(isSectionVisible("ext-1", data)).toBe(true);
});
it("returns false for unknown section id", () => {
expect(isSectionVisible("does-not-exist", data)).toBe(false);
});
});
describe("filterSections", () => {
const data = {
summary: { hidden: false, content: "<p>Hi</p>" },
sections: {
experience: { hidden: false, items: [{ hidden: false }] },
skills: { hidden: false, items: [] },
},
customSections: [],
};
it("returns only visible section ids in input order", () => {
expect(filterSections(["summary", "experience", "skills"], data)).toEqual(["summary", "experience"]);
});
it("returns empty array when no sections are visible", () => {
const empty = {
summary: { hidden: false, content: "" },
sections: { skills: { hidden: false, items: [] } },
customSections: [],
};
expect(filterSections(["summary", "skills"], empty)).toEqual([]);
});
it("preserves order of input section ids", () => {
expect(filterSections(["experience", "summary"], data)).toEqual(["experience", "summary"]);
});
});
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { getTemplateMetrics } from "./metrics";
describe("getTemplateMetrics", () => {
it("maps margins to page paddings", () => {
const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 14, marginY: 12 });
expect(metrics.page.paddingHorizontal).toBe(14);
expect(metrics.page.paddingVertical).toBe(12);
});
it("uses marginY as headerGap and sectionGap", () => {
const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 14, marginY: 20 });
expect(metrics.headerGap).toBe(20);
expect(metrics.sectionGap).toBe(20);
});
it("uses marginX as columnGap", () => {
const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 30, marginY: 12 });
expect(metrics.columnGap).toBe(30);
});
it("preserves itemGapX and itemGapY directly from gapX/gapY", () => {
const metrics = getTemplateMetrics({ gapX: 7, gapY: 9, marginX: 14, marginY: 12 });
expect(metrics.itemGapX).toBe(7);
expect(metrics.itemGapY).toBe(9);
});
it("provides gapX/gapY scaling functions", () => {
const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 14, marginY: 12 });
expect(metrics.gapX(2)).toBe(8);
expect(metrics.gapY(3)).toBe(18);
});
it("scaling by 0.5 returns half-gap", () => {
const metrics = getTemplateMetrics({ gapX: 10, gapY: 20, marginX: 14, marginY: 12 });
expect(metrics.gapX(0.5)).toBe(5);
expect(metrics.gapY(0.5)).toBe(10);
});
it("scaling by 0 returns 0", () => {
const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 14, marginY: 12 });
expect(metrics.gapX(0)).toBe(0);
expect(metrics.gapY(0)).toBe(0);
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "./page-size";
describe("getTemplatePageSize", () => {
it("returns 'A4' for a4 format", () => {
expect(getTemplatePageSize("a4")).toBe("A4");
});
it("returns 'LETTER' for letter format", () => {
expect(getTemplatePageSize("letter")).toBe("LETTER");
});
it("returns custom width for free-form format", () => {
const result = getTemplatePageSize("free-form");
expect(typeof result).toBe("object");
if (typeof result === "object") {
expect(result.width).toBeCloseTo(595.28, 2);
}
});
});
describe("getTemplatePageMinHeightStyle", () => {
it("returns undefined for non-free-form formats", () => {
expect(getTemplatePageMinHeightStyle("a4")).toBeUndefined();
expect(getTemplatePageMinHeightStyle("letter")).toBeUndefined();
});
it("returns A4-style minHeight for free-form", () => {
expect(getTemplatePageMinHeightStyle("free-form")).toEqual({ minHeight: 841.89 });
});
});
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { hasTemplatePicture } from "./picture";
const basePicture = {
hidden: false,
url: "",
size: 80,
rotation: 0,
aspectRatio: 1,
borderRadius: 0,
borderColor: "rgba(0, 0, 0, 0.5)",
borderWidth: 0,
shadowColor: "rgba(0, 0, 0, 0.5)",
shadowWidth: 0,
} as const;
describe("hasTemplatePicture", () => {
it("returns true when not hidden and url is non-empty", () => {
expect(hasTemplatePicture({ ...basePicture, url: "/uploads/me.png" })).toBe(true);
});
it("returns false when hidden", () => {
expect(hasTemplatePicture({ ...basePicture, url: "/uploads/me.png", hidden: true })).toBe(false);
});
it("returns false when url is empty", () => {
expect(hasTemplatePicture(basePicture)).toBe(false);
});
it("returns false when url is whitespace only", () => {
expect(hasTemplatePicture({ ...basePicture, url: " " })).toBe(false);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { normalizeRichTextHtml } from "./rich-text-html";
describe("normalizeRichTextHtml", () => {
it("wraps loose inline content in a <p>", () => {
expect(normalizeRichTextHtml("hello world")).toBe("<p>hello world</p>");
});
it("wraps inline tags in a <p>", () => {
expect(normalizeRichTextHtml("<strong>bold</strong> text")).toBe("<p><strong>bold</strong> text</p>");
});
it("preserves block-level <p> as-is", () => {
expect(normalizeRichTextHtml("<p>Already wrapped</p>")).toBe("<p>Already wrapped</p>");
});
it("preserves block-level <ul>", () => {
const html = "<ul><li>a</li><li>b</li></ul>";
expect(normalizeRichTextHtml(html)).toBe(html);
});
it("flushes accumulated inlines before block-level tags", () => {
expect(normalizeRichTextHtml("loose<ul><li>a</li></ul>")).toBe("<p>loose</p><ul><li>a</li></ul>");
});
it("flushes accumulated inlines after block-level tags", () => {
expect(normalizeRichTextHtml("<ul><li>a</li></ul>after")).toBe("<ul><li>a</li></ul><p>after</p>");
});
it("treats <span> as inline", () => {
expect(normalizeRichTextHtml("<span>x</span>")).toBe("<p><span>x</span></p>");
});
it("trims input whitespace", () => {
expect(normalizeRichTextHtml(" text ")).toBe("<p>text</p>");
});
it("returns empty string for empty input", () => {
expect(normalizeRichTextHtml("")).toBe("");
});
it("treats <a> as inline (no need to wrap by itself)", () => {
expect(normalizeRichTextHtml('<a href="x">link</a>')).toBe('<p><a href="x">link</a></p>');
});
it("does not double-wrap inline tags inside block elements", () => {
expect(normalizeRichTextHtml("<p><strong>x</strong></p>")).toBe("<p><strong>x</strong></p>");
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { getInlineItemWebsiteUrl, shouldRenderSeparateItemWebsite } from "./section-links";
describe("getInlineItemWebsiteUrl", () => {
it("returns the url when url is set and inlineLink is true", () => {
expect(getInlineItemWebsiteUrl({ url: "https://example.com", label: "", inlineLink: true })).toBe(
"https://example.com",
);
});
it("returns undefined when inlineLink is false", () => {
expect(getInlineItemWebsiteUrl({ url: "https://example.com", label: "", inlineLink: false })).toBeUndefined();
});
it("returns undefined when url is empty", () => {
expect(getInlineItemWebsiteUrl({ url: "", label: "", inlineLink: true })).toBeUndefined();
});
it("returns undefined when inlineLink is undefined", () => {
expect(getInlineItemWebsiteUrl({ url: "https://example.com", label: "" })).toBeUndefined();
});
});
describe("shouldRenderSeparateItemWebsite", () => {
it("returns true when url is set and inlineLink is false", () => {
expect(shouldRenderSeparateItemWebsite({ url: "https://example.com", label: "", inlineLink: false })).toBe(true);
});
it("returns false when url is empty", () => {
expect(shouldRenderSeparateItemWebsite({ url: "", label: "", inlineLink: false })).toBe(false);
});
it("returns false when inlineLink is true", () => {
expect(shouldRenderSeparateItemWebsite({ url: "https://example.com", label: "", inlineLink: true })).toBe(false);
});
it("returns true when inlineLink is undefined", () => {
expect(shouldRenderSeparateItemWebsite({ url: "https://example.com", label: "" })).toBe(true);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { pageDimensionsAsMillimeters, pageDimensionsAsPixels } from "./page";
describe("pageDimensionsAsPixels", () => {
it("defines a4 dimensions matching ISO 216 at ~96 DPI", () => {
expect(pageDimensionsAsPixels.a4).toEqual({ width: 794, height: 1123 });
});
it("defines US letter dimensions", () => {
expect(pageDimensionsAsPixels.letter).toEqual({ width: 816, height: 1056 });
});
it("defines free-form dimensions same as a4", () => {
expect(pageDimensionsAsPixels["free-form"]).toEqual({ width: 794, height: 1123 });
});
it("uses portrait orientation (height > width) for all formats", () => {
expect(pageDimensionsAsPixels.a4.height).toBeGreaterThan(pageDimensionsAsPixels.a4.width);
expect(pageDimensionsAsPixels.letter.height).toBeGreaterThan(pageDimensionsAsPixels.letter.width);
});
});
describe("pageDimensionsAsMillimeters", () => {
it("defines a4 dimensions in millimeters per ISO 216", () => {
expect(pageDimensionsAsMillimeters.a4).toEqual({ width: "210mm", height: "297mm" });
});
it("defines letter dimensions in millimeters", () => {
expect(pageDimensionsAsMillimeters.letter).toEqual({ width: "216mm", height: "279mm" });
});
it("appends 'mm' unit suffix to all values", () => {
for (const [, dims] of Object.entries(pageDimensionsAsMillimeters)) {
expect(dims.width).toMatch(/mm$/);
expect(dims.height).toMatch(/mm$/);
}
});
it("provides all three formats (a4, letter, free-form)", () => {
expect(Object.keys(pageDimensionsAsMillimeters).sort()).toEqual(["a4", "free-form", "letter"]);
});
});
+272
View File
@@ -0,0 +1,272 @@
import { describe, expect, it } from "vitest";
import {
basicsSchema,
customFieldSchema,
experienceItemSchema,
layoutSchema,
pageSchema,
pictureSchema,
resumeDataSchema,
skillItemSchema,
websiteSchema,
} from "./data";
import { defaultResumeData } from "./default";
describe("resumeDataSchema", () => {
it("validates the default resume", () => {
expect(resumeDataSchema.safeParse(defaultResumeData).success).toBe(true);
});
it("rejects empty object", () => {
expect(resumeDataSchema.safeParse({}).success).toBe(false);
});
it("rejects missing top-level keys", () => {
const partial = { ...defaultResumeData, basics: undefined };
expect(resumeDataSchema.safeParse(partial).success).toBe(false);
});
});
describe("websiteSchema", () => {
it("requires url and label fields", () => {
expect(websiteSchema.safeParse({ url: "https://example.com", label: "Example" }).success).toBe(true);
});
it("rejects missing url", () => {
expect(websiteSchema.safeParse({ label: "Example" }).success).toBe(false);
});
it("rejects missing label", () => {
expect(websiteSchema.safeParse({ url: "https://example.com" }).success).toBe(false);
});
it("allows empty strings (caller decides display)", () => {
expect(websiteSchema.safeParse({ url: "", label: "" }).success).toBe(true);
});
});
describe("pictureSchema", () => {
it("accepts the default picture config", () => {
expect(pictureSchema.safeParse(defaultResumeData.picture).success).toBe(true);
});
it("rejects size below 32", () => {
const invalid = { ...defaultResumeData.picture, size: 16 };
expect(pictureSchema.safeParse(invalid).success).toBe(false);
});
it("rejects size above 512", () => {
const invalid = { ...defaultResumeData.picture, size: 1024 };
expect(pictureSchema.safeParse(invalid).success).toBe(false);
});
it("rejects rotation below 0", () => {
const invalid = { ...defaultResumeData.picture, rotation: -1 };
expect(pictureSchema.safeParse(invalid).success).toBe(false);
});
it("rejects rotation above 360", () => {
const invalid = { ...defaultResumeData.picture, rotation: 361 };
expect(pictureSchema.safeParse(invalid).success).toBe(false);
});
});
describe("basicsSchema", () => {
it("validates default basics", () => {
expect(basicsSchema.safeParse(defaultResumeData.basics).success).toBe(true);
});
it("requires email to be a string but does not enforce email shape (catches at app level)", () => {
const result = basicsSchema.safeParse({
...defaultResumeData.basics,
email: "not-an-email",
});
expect(result.success).toBe(true);
});
it("supports customFields array", () => {
const result = basicsSchema.safeParse({
...defaultResumeData.basics,
customFields: [{ id: "1", icon: "phone", text: "555-0000", link: "" }],
});
expect(result.success).toBe(true);
});
});
describe("customFieldSchema", () => {
it("requires id, icon, and text", () => {
const result = customFieldSchema.safeParse({
id: "1",
icon: "phone",
text: "555-0000",
link: "",
});
expect(result.success).toBe(true);
});
it("rejects missing id", () => {
expect(customFieldSchema.safeParse({ icon: "phone", text: "555-0000", link: "" }).success).toBe(false);
});
it("falls back to empty link via .catch when missing", () => {
const result = customFieldSchema.safeParse({ id: "1", icon: "phone", text: "x" });
expect(result.success).toBe(true);
if (result.success) expect(result.data.link).toBe("");
});
});
describe("experienceItemSchema", () => {
it("requires company name (min 1)", () => {
const result = experienceItemSchema.safeParse({
id: "abcdef0123456789",
hidden: false,
company: "",
position: "Engineer",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [],
});
expect(result.success).toBe(false);
});
it("validates a complete experience item", () => {
const result = experienceItemSchema.safeParse({
id: "abcdef0123456789",
hidden: false,
company: "Acme",
position: "Engineer",
location: "NYC",
period: "2020 - 2023",
website: { url: "https://acme.com", label: "Acme", inlineLink: false },
description: "<p>Did things</p>",
roles: [],
});
expect(result.success).toBe(true);
});
it("defaults roles to [] via .catch when missing", () => {
const result = experienceItemSchema.safeParse({
id: "x",
hidden: false,
company: "Acme",
position: "",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
});
expect(result.success).toBe(true);
if (result.success) expect(result.data.roles).toEqual([]);
});
});
describe("skillItemSchema", () => {
it("requires name (min 1)", () => {
const invalid = {
id: "x",
hidden: false,
icon: "",
iconColor: "",
name: "",
proficiency: "",
level: 4,
keywords: [],
};
expect(skillItemSchema.safeParse(invalid).success).toBe(false);
});
it("clamps invalid level via .catch(0)", () => {
const item = {
id: "x",
hidden: false,
icon: "",
iconColor: "",
name: "TS",
proficiency: "",
level: 99,
keywords: [],
};
const result = skillItemSchema.safeParse(item);
expect(result.success).toBe(true);
if (result.success) expect(result.data.level).toBe(0);
});
it("allows level between 0 and 5", () => {
for (const level of [0, 1, 2, 3, 4, 5]) {
const item = {
id: "x",
hidden: false,
icon: "",
iconColor: "",
name: "X",
proficiency: "",
level,
keywords: [],
};
expect(skillItemSchema.safeParse(item).success).toBe(true);
}
});
it("falls back to empty array when keywords is invalid via .catch", () => {
const item = {
id: "x",
hidden: false,
icon: "",
iconColor: "",
name: "X",
proficiency: "",
level: 3,
keywords: "not-an-array",
};
const result = skillItemSchema.safeParse(item);
expect(result.success).toBe(true);
if (result.success) expect(result.data.keywords).toEqual([]);
});
});
describe("layoutSchema", () => {
it("validates default layout", () => {
expect(layoutSchema.safeParse(defaultResumeData.metadata.layout).success).toBe(true);
});
it("clamps sidebarWidth out-of-range to 35", () => {
const result = layoutSchema.safeParse({
...defaultResumeData.metadata.layout,
sidebarWidth: 999,
});
expect(result.success).toBe(true);
if (result.success) expect(result.data.sidebarWidth).toBe(35);
});
it("clamps sidebarWidth below 10 to 35", () => {
const result = layoutSchema.safeParse({
...defaultResumeData.metadata.layout,
sidebarWidth: 5,
});
expect(result.success).toBe(true);
if (result.success) expect(result.data.sidebarWidth).toBe(35);
});
});
describe("pageSchema", () => {
it("rejects negative gap or margin values", () => {
const invalid = { ...defaultResumeData.metadata.page, gapX: -1 };
expect(pageSchema.safeParse(invalid).success).toBe(false);
});
it("falls back to 'a4' for unknown format via .catch", () => {
const invalid = { ...defaultResumeData.metadata.page, format: "huge" };
const result = pageSchema.safeParse(invalid);
expect(result.success).toBe(true);
if (result.success) expect(result.data.format).toBe("a4");
});
it("accepts each known format", () => {
for (const format of ["a4", "letter", "free-form"] as const) {
const valid = { ...defaultResumeData.metadata.page, format };
expect(pageSchema.safeParse(valid).success).toBe(true);
}
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { resumeDataSchema } from "./data";
import { defaultResumeData } from "./default";
describe("defaultResumeData", () => {
it("conforms to resumeDataSchema", () => {
const result = resumeDataSchema.safeParse(defaultResumeData);
expect(result.success).toBe(true);
});
it("uses the 'onyx' template", () => {
expect(defaultResumeData.metadata.template).toBe("onyx");
});
it("uses en-US locale", () => {
expect(defaultResumeData.metadata.page.locale).toBe("en-US");
});
it("uses A4 format by default", () => {
expect(defaultResumeData.metadata.page.format).toBe("a4");
});
it("starts with no resume content", () => {
expect(defaultResumeData.basics.name).toBe("");
expect(defaultResumeData.summary.content).toBe("");
for (const section of Object.values(defaultResumeData.sections)) {
expect(section.items).toEqual([]);
}
});
it("has exactly one default page layout", () => {
expect(defaultResumeData.metadata.layout.pages).toHaveLength(1);
});
it("default page has standard main and sidebar columns", () => {
const page = defaultResumeData.metadata.layout.pages[0];
expect(page).toBeDefined();
expect(page?.main).toContain("experience");
expect(page?.main).toContain("education");
expect(page?.sidebar).toContain("skills");
});
it("uses serif font family for body", () => {
expect(defaultResumeData.metadata.typography.body.fontFamily).toBe("IBM Plex Serif");
});
it("default sidebar width is 35%", () => {
expect(defaultResumeData.metadata.layout.sidebarWidth).toBe(35);
});
it("custom sections are empty", () => {
expect(defaultResumeData.customSections).toEqual([]);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { templateSchema } from "./templates";
describe("templateSchema", () => {
it("accepts known template names", () => {
const validTemplates = [
"azurill",
"bronzor",
"chikorita",
"ditgar",
"ditto",
"gengar",
"glalie",
"kakuna",
"lapras",
"leafish",
"meowth",
"onyx",
"pikachu",
"rhyhorn",
];
for (const t of validTemplates) {
expect(templateSchema.safeParse(t).success).toBe(true);
}
});
it("rejects unknown template names", () => {
expect(templateSchema.safeParse("unknown").success).toBe(false);
expect(templateSchema.safeParse("").success).toBe(false);
expect(templateSchema.safeParse("ONYX").success).toBe(false); // case-sensitive
});
it("rejects non-string values", () => {
expect(templateSchema.safeParse(null).success).toBe(false);
expect(templateSchema.safeParse(undefined).success).toBe(false);
expect(templateSchema.safeParse(42).success).toBe(false);
});
it("returns the exact value for a valid template", () => {
const result = templateSchema.safeParse("onyx");
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe("onyx");
}
});
it("includes 14 templates", () => {
const validTemplates = templateSchema.options;
expect(validTemplates).toHaveLength(14);
});
});
@@ -0,0 +1,107 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./accordion";
describe("Accordion", () => {
it("renders with data-slot='accordion'", () => {
render(
<Accordion data-testid="acc">
<AccordionItem value="1">
<AccordionTrigger>Q1</AccordionTrigger>
<AccordionContent>A1</AccordionContent>
</AccordionItem>
</Accordion>,
);
expect(screen.getByTestId("acc")).toHaveAttribute("data-slot", "accordion");
});
it("merges custom className", () => {
render(
<Accordion data-testid="acc" className="my-class">
<AccordionItem value="1">
<AccordionTrigger>Q</AccordionTrigger>
<AccordionContent>A</AccordionContent>
</AccordionItem>
</Accordion>,
);
expect(screen.getByTestId("acc")).toHaveClass("my-class");
});
it("expands and collapses on trigger click", async () => {
render(
<Accordion>
<AccordionItem value="1">
<AccordionTrigger>Question 1</AccordionTrigger>
<AccordionContent>Answer 1</AccordionContent>
</AccordionItem>
</Accordion>,
);
const trigger = screen.getByText("Question 1");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
await userEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
await userEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
it("renders multiple items", () => {
render(
<Accordion>
<AccordionItem value="1">
<AccordionTrigger>Q1</AccordionTrigger>
<AccordionContent>A1</AccordionContent>
</AccordionItem>
<AccordionItem value="2">
<AccordionTrigger>Q2</AccordionTrigger>
<AccordionContent>A2</AccordionContent>
</AccordionItem>
</Accordion>,
);
expect(screen.getByText("Q1")).toBeInTheDocument();
expect(screen.getByText("Q2")).toBeInTheDocument();
});
});
describe("AccordionItem", () => {
it("uses data-slot='accordion-item'", () => {
render(
<Accordion>
<AccordionItem value="1" data-testid="item">
<AccordionTrigger>Q</AccordionTrigger>
<AccordionContent>A</AccordionContent>
</AccordionItem>
</Accordion>,
);
expect(screen.getByTestId("item")).toHaveAttribute("data-slot", "accordion-item");
});
});
describe("AccordionTrigger", () => {
it("uses data-slot='accordion-trigger'", () => {
render(
<Accordion>
<AccordionItem value="1">
<AccordionTrigger data-testid="trigger">Q</AccordionTrigger>
<AccordionContent>A</AccordionContent>
</AccordionItem>
</Accordion>,
);
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "accordion-trigger");
});
it("renders down/up caret icons", () => {
const { container } = render(
<Accordion>
<AccordionItem value="1">
<AccordionTrigger>Q</AccordionTrigger>
<AccordionContent>A</AccordionContent>
</AccordionItem>
</Accordion>,
);
expect(container.querySelectorAll("[data-slot=accordion-trigger-icon]")).toHaveLength(2);
});
});
@@ -0,0 +1,126 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "./alert-dialog";
describe("AlertDialog", () => {
it("does not render content by default", () => {
render(
<AlertDialog>
<AlertDialogTrigger>Open</AlertDialogTrigger>
<AlertDialogContent>Hidden body</AlertDialogContent>
</AlertDialog>,
);
expect(screen.queryByText("Hidden body")).not.toBeInTheDocument();
});
it("opens via defaultOpen", () => {
render(
<AlertDialog defaultOpen>
<AlertDialogTrigger>Open</AlertDialogTrigger>
<AlertDialogContent>Visible body</AlertDialogContent>
</AlertDialog>,
);
expect(screen.getByText("Visible body")).toBeInTheDocument();
});
it("trigger has data-slot='alert-dialog-trigger'", () => {
render(
<AlertDialog>
<AlertDialogTrigger data-testid="trigger">Open</AlertDialogTrigger>
<AlertDialogContent>x</AlertDialogContent>
</AlertDialog>,
);
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "alert-dialog-trigger");
});
it("content uses size 'default' by default", () => {
render(
<AlertDialog open>
<AlertDialogTrigger>x</AlertDialogTrigger>
<AlertDialogContent>visible</AlertDialogContent>
</AlertDialog>,
);
expect(screen.getByText("visible")).toHaveAttribute("data-size", "default");
});
it("content supports size='sm'", () => {
render(
<AlertDialog open>
<AlertDialogTrigger>x</AlertDialogTrigger>
<AlertDialogContent size="sm">visible</AlertDialogContent>
</AlertDialog>,
);
expect(screen.getByText("visible")).toHaveAttribute("data-size", "sm");
});
});
describe("AlertDialog subcomponents", () => {
it("AlertDialogHeader uses data-slot='alert-dialog-header'", () => {
render(<AlertDialogHeader data-testid="h">x</AlertDialogHeader>);
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "alert-dialog-header");
});
it("AlertDialogFooter uses data-slot='alert-dialog-footer'", () => {
render(<AlertDialogFooter data-testid="f">x</AlertDialogFooter>);
expect(screen.getByTestId("f")).toHaveAttribute("data-slot", "alert-dialog-footer");
});
it("AlertDialogMedia uses data-slot='alert-dialog-media'", () => {
render(<AlertDialogMedia data-testid="m">x</AlertDialogMedia>);
expect(screen.getByTestId("m")).toHaveAttribute("data-slot", "alert-dialog-media");
});
it("AlertDialogTitle uses data-slot='alert-dialog-title'", () => {
render(
<AlertDialog open>
<AlertDialogTrigger>x</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
</AlertDialogContent>
</AlertDialog>,
);
expect(screen.getByText("Are you sure?")).toHaveAttribute("data-slot", "alert-dialog-title");
});
it("AlertDialogDescription uses data-slot='alert-dialog-description'", () => {
render(
<AlertDialog open>
<AlertDialogTrigger>x</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogDescription>This is permanent</AlertDialogDescription>
</AlertDialogContent>
</AlertDialog>,
);
expect(screen.getByText("This is permanent")).toHaveAttribute("data-slot", "alert-dialog-description");
});
it("AlertDialogAction renders a Button with data-slot='alert-dialog-action'", () => {
render(<AlertDialogAction>Confirm</AlertDialogAction>);
const button = screen.getByText("Confirm");
expect(button).toHaveAttribute("data-slot", "alert-dialog-action");
});
it("AlertDialogCancel uses data-slot='alert-dialog-cancel'", () => {
render(
<AlertDialog open>
<AlertDialogTrigger>x</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogCancel>Cancel</AlertDialogCancel>
</AlertDialogContent>
</AlertDialog>,
);
const button = screen.getByText("Cancel");
expect(button).toHaveAttribute("data-slot", "alert-dialog-cancel");
});
});
+75
View File
@@ -0,0 +1,75 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./alert";
describe("Alert", () => {
it("renders children with role='alert'", () => {
render(<Alert>Heads up!</Alert>);
expect(screen.getByRole("alert")).toHaveTextContent("Heads up!");
});
it("applies data-slot='alert' for slotting", () => {
render(<Alert>x</Alert>);
expect(screen.getByRole("alert")).toHaveAttribute("data-slot", "alert");
});
it("merges custom className", () => {
render(<Alert className="my-class">x</Alert>);
expect(screen.getByRole("alert")).toHaveClass("my-class");
});
it("supports destructive variant", () => {
render(<Alert variant="destructive">x</Alert>);
// We just verify the component renders without error
expect(screen.getByRole("alert")).toBeInTheDocument();
});
});
describe("AlertTitle", () => {
it("renders children", () => {
render(<AlertTitle>Title</AlertTitle>);
expect(screen.getByText("Title")).toBeInTheDocument();
});
it("applies data-slot='alert-title'", () => {
render(<AlertTitle>x</AlertTitle>);
expect(screen.getByText("x")).toHaveAttribute("data-slot", "alert-title");
});
});
describe("AlertDescription", () => {
it("renders children", () => {
render(<AlertDescription>Description text</AlertDescription>);
expect(screen.getByText("Description text")).toBeInTheDocument();
});
it("applies data-slot='alert-description'", () => {
render(<AlertDescription>x</AlertDescription>);
expect(screen.getByText("x")).toHaveAttribute("data-slot", "alert-description");
});
});
describe("AlertAction", () => {
it("renders children", () => {
render(<AlertAction>Action</AlertAction>);
expect(screen.getByText("Action")).toBeInTheDocument();
});
it("applies data-slot='alert-action'", () => {
render(<AlertAction>x</AlertAction>);
expect(screen.getByText("x")).toHaveAttribute("data-slot", "alert-action");
});
});
describe("Alert composition", () => {
it("composes all subcomponents", () => {
render(
<Alert>
<AlertTitle>Title</AlertTitle>
<AlertDescription>Body</AlertDescription>
<AlertAction>OK</AlertAction>
</Alert>,
);
expect(screen.getByRole("alert")).toHaveTextContent(/TitleBodyOK/);
});
});
@@ -0,0 +1,88 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from "./avatar";
describe("Avatar", () => {
it("renders with data-slot='avatar'", () => {
render(<Avatar data-testid="av" />);
expect(screen.getByTestId("av")).toHaveAttribute("data-slot", "avatar");
});
it("defaults size to 'default'", () => {
render(<Avatar data-testid="av" />);
expect(screen.getByTestId("av")).toHaveAttribute("data-size", "default");
});
it.each(["default", "sm", "lg"] as const)("supports size=%s", (size) => {
render(<Avatar data-testid="av" size={size} />);
expect(screen.getByTestId("av")).toHaveAttribute("data-size", size);
});
it("merges custom className", () => {
render(<Avatar data-testid="av" className="my-class" />);
expect(screen.getByTestId("av")).toHaveClass("my-class");
});
});
describe("AvatarFallback", () => {
it("renders fallback content", () => {
render(
<Avatar>
<AvatarFallback data-testid="fb">JD</AvatarFallback>
</Avatar>,
);
// Note: fallback only renders if image fails — but data-slot should be set.
// We just confirm no crash and that fallback is present in DOM.
const fb = screen.queryByTestId("fb");
// AvatarFallback may or may not be in the DOM depending on image state.
// Just ensure it doesn't throw on render.
expect(fb === null || fb.getAttribute("data-slot") === "avatar-fallback").toBe(true);
});
});
describe("AvatarBadge", () => {
it("renders as span with data-slot='avatar-badge'", () => {
render(<AvatarBadge data-testid="b" />);
const badge = screen.getByTestId("b");
expect(badge.tagName).toBe("SPAN");
expect(badge).toHaveAttribute("data-slot", "avatar-badge");
});
});
describe("AvatarGroup", () => {
it("renders as div with data-slot='avatar-group'", () => {
render(<AvatarGroup data-testid="g" />);
const group = screen.getByTestId("g");
expect(group.tagName).toBe("DIV");
expect(group).toHaveAttribute("data-slot", "avatar-group");
});
it("supports children", () => {
render(
<AvatarGroup data-testid="g">
<Avatar />
<Avatar />
</AvatarGroup>,
);
expect(screen.getByTestId("g").children).toHaveLength(2);
});
});
describe("AvatarGroupCount", () => {
it("renders count children", () => {
render(<AvatarGroupCount>+3</AvatarGroupCount>);
expect(screen.getByText("+3")).toHaveAttribute("data-slot", "avatar-group-count");
});
});
describe("AvatarImage", () => {
it("accepts src prop without throwing", () => {
render(
<Avatar>
<AvatarImage src="https://example.com/x.png" alt="user" />
</Avatar>,
);
// Image may not render until loaded; we just verify no crash
expect(true).toBe(true);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Badge } from "./badge";
describe("Badge", () => {
it("renders children inside a span by default", () => {
render(<Badge>New</Badge>);
const badge = screen.getByText("New");
expect(badge.tagName).toBe("SPAN");
});
it("merges custom className", () => {
render(<Badge className="my-extra">x</Badge>);
expect(screen.getByText("x")).toHaveClass("my-extra");
});
it.each([
["default"],
["secondary"],
["destructive"],
["outline"],
["ghost"],
["link"],
] as const)("renders variant=%s without throwing", (variant) => {
render(<Badge variant={variant}>{variant}</Badge>);
expect(screen.getByText(variant)).toBeInTheDocument();
});
it("forwards aria attributes", () => {
render(<Badge aria-label="status indicator">3</Badge>);
expect(screen.getByLabelText("status indicator")).toBeInTheDocument();
});
it("supports a custom render function", () => {
render(<Badge render={(props) => <a {...props} href="/x" />}>link</Badge>);
const anchor = screen.getByRole("link", { name: "link" });
expect(anchor).toBeInTheDocument();
});
});
@@ -0,0 +1,41 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BrandIcon } from "./brand-icon";
describe("BrandIcon", () => {
it("renders two img elements (light + dark variants)", () => {
render(<BrandIcon />);
const imgs = screen.getAllByAltText("Reactive Resume");
expect(imgs).toHaveLength(2);
});
it("uses 'logo' as default variant", () => {
render(<BrandIcon />);
const imgs = screen.getAllByAltText("Reactive Resume");
expect(imgs.some((img) => img.getAttribute("src") === "/logo/dark.svg")).toBe(true);
expect(imgs.some((img) => img.getAttribute("src") === "/logo/light.svg")).toBe(true);
});
it("uses 'icon' variant when specified", () => {
render(<BrandIcon variant="icon" />);
const imgs = screen.getAllByAltText("Reactive Resume");
expect(imgs.some((img) => img.getAttribute("src") === "/icon/dark.svg")).toBe(true);
expect(imgs.some((img) => img.getAttribute("src") === "/icon/light.svg")).toBe(true);
});
it("merges custom className on both imgs", () => {
render(<BrandIcon className="my-custom" />);
const imgs = screen.getAllByAltText("Reactive Resume");
for (const img of imgs) {
expect(img).toHaveClass("my-custom");
}
});
it("hides dark variant by default (light mode); dark mode reveals it via dark:block", () => {
render(<BrandIcon />);
const imgs = screen.getAllByAltText("Reactive Resume");
const darkImg = imgs.find((img) => img.getAttribute("src") === "/logo/dark.svg");
expect(darkImg).toHaveClass("hidden");
expect(darkImg).toHaveClass("dark:block");
});
});
@@ -0,0 +1,61 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "./button-group";
describe("ButtonGroup", () => {
it("renders a fieldset with data-slot='button-group'", () => {
render(<ButtonGroup data-testid="g" />);
const g = screen.getByTestId("g");
expect(g.tagName).toBe("FIELDSET");
expect(g).toHaveAttribute("data-slot", "button-group");
});
it("respects horizontal orientation (default)", () => {
render(<ButtonGroup data-testid="g" orientation="horizontal" />);
expect(screen.getByTestId("g")).toHaveAttribute("data-orientation", "horizontal");
});
it("respects vertical orientation", () => {
render(<ButtonGroup data-testid="g" orientation="vertical" />);
expect(screen.getByTestId("g")).toHaveAttribute("data-orientation", "vertical");
});
it("merges custom className", () => {
render(<ButtonGroup data-testid="g" className="my-custom" />);
expect(screen.getByTestId("g")).toHaveClass("my-custom");
});
});
describe("ButtonGroupText", () => {
it("renders content inside a div by default", () => {
render(<ButtonGroupText>Hello</ButtonGroupText>);
expect(screen.getByText("Hello").tagName).toBe("DIV");
});
it("merges custom className", () => {
render(<ButtonGroupText className="my-class">x</ButtonGroupText>);
expect(screen.getByText("x")).toHaveClass("my-class");
});
it("supports custom render function", () => {
render(<ButtonGroupText render={(props) => <span {...props} />}>label</ButtonGroupText>);
expect(screen.getByText("label").tagName).toBe("SPAN");
});
});
describe("ButtonGroupSeparator", () => {
it("defaults to vertical orientation", () => {
render(<ButtonGroupSeparator data-testid="sep" />);
expect(screen.getByTestId("sep")).toHaveAttribute("data-orientation", "vertical");
});
it("supports horizontal orientation", () => {
render(<ButtonGroupSeparator data-testid="sep" orientation="horizontal" />);
expect(screen.getByTestId("sep")).toHaveAttribute("data-orientation", "horizontal");
});
it("uses data-slot='button-group-separator'", () => {
render(<ButtonGroupSeparator data-testid="sep" />);
expect(screen.getByTestId("sep")).toHaveAttribute("data-slot", "button-group-separator");
});
});
@@ -0,0 +1,85 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
describe("Button", () => {
it("renders children", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("defaults to type='button' to prevent accidental form submits", () => {
render(<Button>Submit</Button>);
expect(screen.getByRole("button")).toHaveAttribute("type", "button");
});
it("respects an explicit type override", () => {
render(<Button type="submit">Submit</Button>);
expect(screen.getByRole("button")).toHaveAttribute("type", "submit");
});
it("applies the data-slot attribute for shadcn/base-ui slotting", () => {
render(<Button>x</Button>);
expect(screen.getByRole("button")).toHaveAttribute("data-slot", "button");
});
it("merges custom className with variant classes", () => {
render(<Button className="my-custom-class">x</Button>);
expect(screen.getByRole("button")).toHaveClass("my-custom-class");
});
it("calls onClick when clicked", async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
await userEvent.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledOnce();
});
it("does not fire onClick when disabled", async () => {
const onClick = vi.fn();
render(
<Button disabled onClick={onClick}>
Click
</Button>,
);
await userEvent.click(screen.getByRole("button"));
expect(onClick).not.toHaveBeenCalled();
});
it("renders disabled state", () => {
render(<Button disabled>x</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
it.each([
["default"],
["outline"],
["secondary"],
["ghost"],
["destructive"],
["link"],
] as const)("renders variant=%s without throwing", (variant) => {
render(<Button variant={variant}>x</Button>);
expect(screen.getByRole("button")).toBeInTheDocument();
});
it.each([
["default"],
["xs"],
["sm"],
["lg"],
["icon"],
["icon-xs"],
["icon-sm"],
["icon-lg"],
] as const)("renders size=%s without throwing", (size) => {
render(<Button size={size}>x</Button>);
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("forwards aria-label", () => {
render(<Button aria-label="Close">×</Button>);
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
});
});
+99
View File
@@ -0,0 +1,99 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./card";
describe("Card", () => {
it("renders with data-slot='card'", () => {
render(<Card data-testid="c">x</Card>);
expect(screen.getByTestId("c")).toHaveAttribute("data-slot", "card");
});
it("defaults size to 'default'", () => {
render(<Card data-testid="c">x</Card>);
expect(screen.getByTestId("c")).toHaveAttribute("data-size", "default");
});
it("supports size='sm'", () => {
render(
<Card data-testid="c" size="sm">
x
</Card>,
);
expect(screen.getByTestId("c")).toHaveAttribute("data-size", "sm");
});
it("merges custom className", () => {
render(
<Card data-testid="c" className="my-custom">
x
</Card>,
);
expect(screen.getByTestId("c")).toHaveClass("my-custom");
});
});
describe("Card subcomponents", () => {
it.each([
["CardHeader", CardHeader, "card-header"],
["CardTitle", CardTitle, "card-title"],
["CardDescription", CardDescription, "card-description"],
["CardAction", CardAction, "card-action"],
["CardContent", CardContent, "card-content"],
["CardFooter", CardFooter, "card-footer"],
] as const)("%s renders with data-slot='%s'", (_name, Component, slot) => {
render(
<Component data-testid="el">
<span>child</span>
</Component>,
);
expect(screen.getByTestId("el")).toHaveAttribute("data-slot", slot);
});
it("CardHeader merges custom className", () => {
render(
<CardHeader data-testid="h" className="my-class">
x
</CardHeader>,
);
expect(screen.getByTestId("h")).toHaveClass("my-class");
});
it("CardTitle merges custom className", () => {
render(
<CardTitle data-testid="t" className="my-class">
x
</CardTitle>,
);
expect(screen.getByTestId("t")).toHaveClass("my-class");
});
it("CardFooter merges custom className", () => {
render(
<CardFooter data-testid="f" className="my-class">
x
</CardFooter>,
);
expect(screen.getByTestId("f")).toHaveClass("my-class");
});
});
describe("Card composition", () => {
it("composes a full card", () => {
render(
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
<CardAction>Action</CardAction>
</CardHeader>
<CardContent>Content</CardContent>
<CardFooter>Footer</CardFooter>
</Card>,
);
expect(screen.getByText("Title")).toBeInTheDocument();
expect(screen.getByText("Description")).toBeInTheDocument();
expect(screen.getByText("Action")).toBeInTheDocument();
expect(screen.getByText("Content")).toBeInTheDocument();
expect(screen.getByText("Footer")).toBeInTheDocument();
});
});
@@ -0,0 +1,47 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Checkbox } from "./checkbox";
describe("Checkbox", () => {
it("renders with data-slot='checkbox'", () => {
render(<Checkbox data-testid="cb" />);
expect(screen.getByTestId("cb")).toHaveAttribute("data-slot", "checkbox");
});
it("defaults to unchecked", () => {
render(<Checkbox data-testid="cb" />);
const cb = screen.getByTestId("cb");
// base-ui sets aria-checked
expect(cb.getAttribute("aria-checked")).toBe("false");
});
it("renders as checked via defaultChecked", () => {
render(<Checkbox data-testid="cb" defaultChecked />);
expect(screen.getByTestId("cb").getAttribute("aria-checked")).toBe("true");
});
it("can be controlled via checked prop", () => {
render(<Checkbox data-testid="cb" checked onCheckedChange={() => {}} />);
expect(screen.getByTestId("cb").getAttribute("aria-checked")).toBe("true");
});
it("calls onCheckedChange when toggled", async () => {
const onChange = vi.fn();
render(<Checkbox data-testid="cb" onCheckedChange={onChange} />);
await userEvent.click(screen.getByTestId("cb"));
expect(onChange).toHaveBeenCalledOnce();
});
it("respects disabled state via aria-disabled (base-ui uses span+role)", () => {
render(<Checkbox data-testid="cb" disabled />);
const cb = screen.getByTestId("cb");
expect(cb.getAttribute("aria-disabled")).toBe("true");
expect(cb).toHaveAttribute("data-disabled");
});
it("merges custom className", () => {
render(<Checkbox data-testid="cb" className="my-cb" />);
expect(screen.getByTestId("cb")).toHaveClass("my-cb");
});
});
@@ -0,0 +1,223 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxRoot,
ComboboxSeparator,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
useFilter,
} from "./combobox";
const items = ["Apple", "Banana", "Cherry"];
const renderCombobox = (overrides: Partial<Parameters<typeof ComboboxRoot>[0]> = {}) =>
render(
<ComboboxRoot items={items} {...overrides}>
<ComboboxInput placeholder="Search" />
<ComboboxContent>
<ComboboxList>
{items.map((item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</ComboboxRoot>,
);
describe("ComboboxRoot/Input", () => {
it("renders ComboboxInput with data-slot='combobox-input' (input is from primitive)", () => {
const { container } = renderCombobox();
// The InputGroupInput wraps with data-slot 'input-group-control' which is from input-group
expect(container.querySelector("[data-slot=input-group-control]")).toBeInTheDocument();
});
it("does not render content when closed", () => {
renderCombobox();
expect(screen.queryByText("Apple")).not.toBeInTheDocument();
});
it("renders content when open=true", () => {
renderCombobox({ open: true });
expect(screen.getByText("Apple")).toBeInTheDocument();
expect(screen.getByText("Banana")).toBeInTheDocument();
});
});
describe("ComboboxValue", () => {
it("renders inside primitive without throwing", () => {
expect(() =>
render(
<ComboboxRoot items={items} value="Apple">
<ComboboxValue />
</ComboboxRoot>,
),
).not.toThrow();
});
});
describe("ComboboxItem", () => {
it("renders with data-slot='combobox-item'", () => {
renderCombobox({ open: true });
expect(document.querySelectorAll("[data-slot=combobox-item]").length).toBeGreaterThan(0);
});
});
describe("ComboboxList / ComboboxGroup / ComboboxLabel", () => {
it("ComboboxList renders with data-slot='combobox-list'", () => {
renderCombobox({ open: true });
expect(document.querySelector("[data-slot=combobox-list]")).toBeInTheDocument();
});
it("ComboboxGroup and ComboboxLabel render with their slots", () => {
render(
<ComboboxRoot items={items} open>
<ComboboxInput />
<ComboboxContent>
<ComboboxList>
<ComboboxGroup items={items}>
<ComboboxLabel>Fruits</ComboboxLabel>
<ComboboxItem value="Apple">Apple</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</ComboboxContent>
</ComboboxRoot>,
);
expect(document.querySelector("[data-slot=combobox-group]")).toBeInTheDocument();
expect(document.querySelector("[data-slot=combobox-label]")).toBeInTheDocument();
});
});
describe("ComboboxSeparator and ComboboxEmpty", () => {
it("ComboboxSeparator renders with data-slot='combobox-separator'", () => {
render(
<ComboboxRoot items={items} open>
<ComboboxInput />
<ComboboxContent>
<ComboboxList>
<ComboboxItem value="Apple">Apple</ComboboxItem>
<ComboboxSeparator />
<ComboboxItem value="Banana">Banana</ComboboxItem>
</ComboboxList>
</ComboboxContent>
</ComboboxRoot>,
);
expect(document.querySelector("[data-slot=combobox-separator]")).toBeInTheDocument();
});
it("ComboboxEmpty renders with data-slot='combobox-empty'", () => {
render(
<ComboboxRoot items={[]} open>
<ComboboxInput />
<ComboboxContent>
<ComboboxEmpty>No results</ComboboxEmpty>
</ComboboxContent>
</ComboboxRoot>,
);
expect(document.querySelector("[data-slot=combobox-empty]")).toBeInTheDocument();
});
});
describe("ComboboxCollection", () => {
it("renders without throwing", () => {
expect(() =>
render(
<ComboboxRoot items={items} open>
<ComboboxInput />
<ComboboxContent>
<ComboboxList>
<ComboboxCollection items={items}>
{(item) => <ComboboxItem value={item}>{item}</ComboboxItem>}
</ComboboxCollection>
</ComboboxList>
</ComboboxContent>
</ComboboxRoot>,
),
).not.toThrow();
});
});
describe("ComboboxClear", () => {
it("renders inside ComboboxInput when showClear=true", () => {
const { container } = render(
<ComboboxRoot items={items} value="Apple">
<ComboboxInput showClear />
</ComboboxRoot>,
);
expect(container.querySelector("[data-slot=combobox-clear]")).toBeInTheDocument();
});
});
describe("ComboboxTrigger", () => {
it("renders an input-group-button addon when showTrigger=true (default)", () => {
const { container } = renderCombobox();
// ComboboxTrigger is rendered inside InputGroupButton which sets its own data-slot.
expect(container.querySelector("[data-slot=input-group-button]")).toBeInTheDocument();
});
it("hides input-group-button addon when showTrigger=false and showClear=false", () => {
const { container } = render(
<ComboboxRoot items={items}>
<ComboboxInput showTrigger={false} />
</ComboboxRoot>,
);
expect(container.querySelector("[data-slot=input-group-button]")).not.toBeInTheDocument();
});
it("ComboboxTrigger renders standalone with data-slot='combobox-trigger'", () => {
const { container } = render(
<ComboboxRoot items={items}>
<ComboboxTrigger>Open</ComboboxTrigger>
</ComboboxRoot>,
);
expect(container.querySelector("[data-slot=combobox-trigger]")).toBeInTheDocument();
});
});
describe("ComboboxChips and friends", () => {
it("renders ComboboxChips with data-slot='combobox-chips'", () => {
const { container } = render(
<ComboboxRoot items={items} multiple value={["Apple"]}>
<ComboboxChips>
<ComboboxChipsInput />
</ComboboxChips>
</ComboboxRoot>,
);
expect(container.querySelector("[data-slot=combobox-chips]")).toBeInTheDocument();
});
it("renders ComboboxChipsInput with data-slot='combobox-chip-input'", () => {
const { container } = render(
<ComboboxRoot items={items} multiple value={["Apple"]}>
<ComboboxChips>
<ComboboxChipsInput />
</ComboboxChips>
</ComboboxRoot>,
);
expect(container.querySelector("[data-slot=combobox-chip-input]")).toBeInTheDocument();
});
});
describe("useFilter", () => {
it("is exported as a function", () => {
expect(typeof useFilter).toBe("function");
});
});
describe("useComboboxAnchor", () => {
it("is exported as a function", () => {
expect(typeof useComboboxAnchor).toBe("function");
});
});
+129
View File
@@ -0,0 +1,129 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "./command";
describe("Command", () => {
it("renders with data-slot='command'", () => {
const { container } = render(
<Command>
<CommandList>
<CommandItem>x</CommandItem>
</CommandList>
</Command>,
);
expect(container.querySelector("[data-slot=command]")).toBeInTheDocument();
});
it("merges custom className on root", () => {
const { container } = render(
<Command className="my-cmd">
<CommandList />
</Command>,
);
expect(container.querySelector("[data-slot=command]")).toHaveClass("my-cmd");
});
});
describe("CommandInput", () => {
it("renders with data-slot='command-input'", () => {
const { container } = render(
<Command>
<CommandInput placeholder="Search..." />
</Command>,
);
expect(container.querySelector("[data-slot=command-input]")).toBeInTheDocument();
});
it("wrapper uses data-slot='command-input-wrapper'", () => {
const { container } = render(
<Command>
<CommandInput />
</Command>,
);
expect(container.querySelector("[data-slot=command-input-wrapper]")).toBeInTheDocument();
});
});
describe("CommandList / CommandGroup", () => {
it("CommandList uses data-slot='command-list'", () => {
const { container } = render(
<Command>
<CommandList>x</CommandList>
</Command>,
);
expect(container.querySelector("[data-slot=command-list]")).toBeInTheDocument();
});
it("CommandEmpty renders without throwing in tree", () => {
// CommandEmpty only mounts under specific cmdk states — just verify no crash
expect(() =>
render(
<Command>
<CommandList>
<CommandEmpty>No results</CommandEmpty>
</CommandList>
</Command>,
),
).not.toThrow();
});
it("CommandGroup uses data-slot='command-group'", () => {
const { container } = render(
<Command>
<CommandList>
<CommandGroup heading="Group 1">
<CommandItem>Item</CommandItem>
</CommandGroup>
</CommandList>
</Command>,
);
expect(container.querySelector("[data-slot=command-group]")).toBeInTheDocument();
});
});
describe("CommandItem", () => {
it("uses data-slot='command-item'", () => {
const { container } = render(
<Command>
<CommandList>
<CommandItem>Hello</CommandItem>
</CommandList>
</Command>,
);
expect(container.querySelector("[data-slot=command-item]")).toBeInTheDocument();
});
});
describe("CommandSeparator", () => {
it("uses data-slot='command-separator'", () => {
const { container } = render(
<Command>
<CommandList>
<CommandSeparator />
</CommandList>
</Command>,
);
expect(container.querySelector("[data-slot=command-separator]")).toBeInTheDocument();
});
});
describe("CommandShortcut", () => {
it("uses data-slot='command-shortcut'", () => {
render(<CommandShortcut>⌘K</CommandShortcut>);
expect(screen.getByText("⌘K")).toHaveAttribute("data-slot", "command-shortcut");
});
it("merges custom className", () => {
render(<CommandShortcut className="my-class">x</CommandShortcut>);
expect(screen.getByText("x")).toHaveClass("my-class");
});
});
@@ -0,0 +1,133 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "./context-menu";
const renderWithMenu = (children: React.ReactNode) =>
render(
<ContextMenu open>
<ContextMenuTrigger>x</ContextMenuTrigger>
<ContextMenuContent>{children}</ContextMenuContent>
</ContextMenu>,
);
describe("ContextMenu", () => {
it("trigger uses data-slot='context-menu-trigger'", () => {
render(
<ContextMenu>
<ContextMenuTrigger data-testid="trigger">Right-click me</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>x</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
);
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "context-menu-trigger");
});
it("does not render content by default", () => {
render(
<ContextMenu>
<ContextMenuTrigger>x</ContextMenuTrigger>
<ContextMenuContent>Hidden</ContextMenuContent>
</ContextMenu>,
);
expect(screen.queryByText("Hidden")).not.toBeInTheDocument();
});
});
describe("ContextMenuItem", () => {
it("uses data-slot='context-menu-item'", () => {
renderWithMenu(<ContextMenuItem>Item</ContextMenuItem>);
expect(screen.getByText("Item")).toHaveAttribute("data-slot", "context-menu-item");
});
it("defaults variant='default'", () => {
renderWithMenu(<ContextMenuItem>Item</ContextMenuItem>);
expect(screen.getByText("Item")).toHaveAttribute("data-variant", "default");
});
it("supports variant='destructive'", () => {
renderWithMenu(<ContextMenuItem variant="destructive">Delete</ContextMenuItem>);
expect(screen.getByText("Delete")).toHaveAttribute("data-variant", "destructive");
});
it("supports inset prop", () => {
renderWithMenu(<ContextMenuItem inset>Inset</ContextMenuItem>);
expect(screen.getByText("Inset")).toHaveAttribute("data-inset", "true");
});
});
describe("ContextMenuLabel", () => {
it("uses data-slot='context-menu-label'", () => {
renderWithMenu(
<ContextMenuGroup>
<ContextMenuLabel>Group label</ContextMenuLabel>
</ContextMenuGroup>,
);
expect(screen.getByText("Group label")).toHaveAttribute("data-slot", "context-menu-label");
});
it("supports inset prop", () => {
renderWithMenu(
<ContextMenuGroup>
<ContextMenuLabel inset>Inset label</ContextMenuLabel>
</ContextMenuGroup>,
);
expect(screen.getByText("Inset label")).toHaveAttribute("data-inset", "true");
});
});
describe("ContextMenuShortcut", () => {
it("uses data-slot='context-menu-shortcut'", () => {
render(<ContextMenuShortcut>⌘K</ContextMenuShortcut>);
expect(screen.getByText("⌘K")).toHaveAttribute("data-slot", "context-menu-shortcut");
});
it("merges custom className", () => {
render(<ContextMenuShortcut className="my-class">x</ContextMenuShortcut>);
expect(screen.getByText("x")).toHaveClass("my-class");
});
});
describe("ContextMenuCheckboxItem and ContextMenuRadioItem", () => {
it("CheckboxItem renders within menu", () => {
renderWithMenu(<ContextMenuCheckboxItem checked>Toggle</ContextMenuCheckboxItem>);
expect(screen.getByText("Toggle")).toHaveAttribute("data-slot", "context-menu-checkbox-item");
});
it("RadioItem renders within menu", () => {
renderWithMenu(
<ContextMenuRadioGroup value="a">
<ContextMenuRadioItem value="a">A</ContextMenuRadioItem>
</ContextMenuRadioGroup>,
);
expect(screen.getByText("A")).toHaveAttribute("data-slot", "context-menu-radio-item");
});
});
describe("ContextMenuSub", () => {
it("SubTrigger uses data-slot='context-menu-sub-trigger'", () => {
renderWithMenu(
<ContextMenuSub>
<ContextMenuSubTrigger>More</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Inner</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>,
);
expect(screen.getByText("More")).toHaveAttribute("data-slot", "context-menu-sub-trigger");
});
});
+135
View File
@@ -0,0 +1,135 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./dialog";
describe("Dialog", () => {
it("does not render content by default", () => {
render(
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>Hidden body</DialogContent>
</Dialog>,
);
expect(screen.queryByText("Hidden body")).not.toBeInTheDocument();
});
it("opens content via defaultOpen", () => {
render(
<Dialog defaultOpen>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>Visible body</DialogContent>
</Dialog>,
);
expect(screen.getByText("Visible body")).toBeInTheDocument();
});
it("opens content via controlled open", () => {
render(
<Dialog open>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>Visible body</DialogContent>
</Dialog>,
);
expect(screen.getByText("Visible body")).toBeInTheDocument();
});
it("opens on trigger click", async () => {
render(
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>Body text</DialogContent>
</Dialog>,
);
await userEvent.click(screen.getByText("Open"));
expect(await screen.findByText("Body text")).toBeInTheDocument();
});
it("renders close button by default in DialogContent", () => {
render(
<Dialog open>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>body</DialogContent>
</Dialog>,
);
expect(screen.getByText("Close")).toBeInTheDocument();
});
it("hides close button when showCloseButton=false", () => {
render(
<Dialog open>
<DialogTrigger>Open</DialogTrigger>
<DialogContent showCloseButton={false}>body</DialogContent>
</Dialog>,
);
expect(screen.queryByText("Close")).not.toBeInTheDocument();
});
});
describe("DialogTitle", () => {
it("uses data-slot='dialog-title'", () => {
render(
<Dialog open>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogTitle>My Title</DialogTitle>
</DialogContent>
</Dialog>,
);
expect(screen.getByText("My Title")).toHaveAttribute("data-slot", "dialog-title");
});
});
describe("DialogDescription", () => {
it("uses data-slot='dialog-description'", () => {
render(
<Dialog open>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogDescription>My Description</DialogDescription>
</DialogContent>
</Dialog>,
);
expect(screen.getByText("My Description")).toHaveAttribute("data-slot", "dialog-description");
});
});
describe("DialogHeader", () => {
it("uses data-slot='dialog-header'", () => {
render(<DialogHeader data-testid="h">x</DialogHeader>);
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "dialog-header");
});
});
describe("DialogFooter", () => {
it("uses data-slot='dialog-footer'", () => {
render(<DialogFooter data-testid="f">x</DialogFooter>);
expect(screen.getByTestId("f")).toHaveAttribute("data-slot", "dialog-footer");
});
it("renders close button when showCloseButton=true", () => {
render(
<Dialog open>
<DialogTrigger>x</DialogTrigger>
<DialogContent showCloseButton={false}>
<DialogFooter showCloseButton>body</DialogFooter>
</DialogContent>
</Dialog>,
);
expect(screen.getByText("Close")).toBeInTheDocument();
});
it("does not render close button by default", () => {
render(<DialogFooter>just content</DialogFooter>);
expect(screen.queryByText("Close")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,197 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./dropdown-menu";
describe("DropdownMenu", () => {
it("trigger has data-slot='dropdown-menu-trigger'", () => {
render(
<DropdownMenu>
<DropdownMenuTrigger data-testid="trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>x</DropdownMenuContent>
</DropdownMenu>,
);
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "dropdown-menu-trigger");
});
it("does not render content by default", () => {
render(
<DropdownMenu>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>Hidden item</DropdownMenuContent>
</DropdownMenu>,
);
expect(screen.queryByText("Hidden item")).not.toBeInTheDocument();
});
it("renders content when controlled open", () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Item 1</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
);
expect(screen.getByText("Item 1")).toBeInTheDocument();
});
});
describe("DropdownMenuItem", () => {
it("uses data-slot='dropdown-menu-item'", () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>x</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Item</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
);
expect(screen.getByText("Item")).toHaveAttribute("data-slot", "dropdown-menu-item");
});
it("defaults variant='default'", () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>x</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Item</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
);
expect(screen.getByText("Item")).toHaveAttribute("data-variant", "default");
});
it("supports variant='destructive'", () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>x</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
);
expect(screen.getByText("Delete")).toHaveAttribute("data-variant", "destructive");
});
it("supports inset prop", () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>x</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem inset>Inset</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
);
expect(screen.getByText("Inset")).toHaveAttribute("data-inset", "true");
});
});
describe("DropdownMenuLabel", () => {
it("uses data-slot='dropdown-menu-label'", () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>x</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Group label</DropdownMenuLabel>
<DropdownMenuItem>Item</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>,
);
expect(screen.getByText("Group label")).toHaveAttribute("data-slot", "dropdown-menu-label");
});
});
describe("DropdownMenuSeparator", () => {
it("uses data-slot='dropdown-menu-separator'", () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>x</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>1</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>2</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
);
// Portal content renders at document.body
expect(document.querySelector("[data-slot=dropdown-menu-separator]")).toBeInTheDocument();
});
});
describe("DropdownMenuShortcut", () => {
it("uses data-slot='dropdown-menu-shortcut'", () => {
render(<DropdownMenuShortcut>⌘K</DropdownMenuShortcut>);
expect(screen.getByText("⌘K")).toHaveAttribute("data-slot", "dropdown-menu-shortcut");
});
it("merges custom className", () => {
render(<DropdownMenuShortcut className="my-class">x</DropdownMenuShortcut>);
expect(screen.getByText("x")).toHaveClass("my-class");
});
});
describe("DropdownMenuCheckboxItem", () => {
it("uses data-slot='dropdown-menu-checkbox-item'", () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>x</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuCheckboxItem checked>Toggle</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>,
);
expect(screen.getByText("Toggle")).toHaveAttribute("data-slot", "dropdown-menu-checkbox-item");
});
});
describe("DropdownMenuRadioItem", () => {
it("uses data-slot='dropdown-menu-radio-item'", () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>x</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuRadioGroup value="a">
<DropdownMenuRadioItem value="a">A</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>,
);
expect(screen.getByText("A")).toHaveAttribute("data-slot", "dropdown-menu-radio-item");
});
});
describe("DropdownMenuSub", () => {
it("renders nested submenu trigger", () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>x</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSub>
<DropdownMenuSubTrigger>More</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Inner</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>,
);
expect(screen.getByText("More")).toHaveAttribute("data-slot", "dropdown-menu-sub-trigger");
});
});
+185
View File
@@ -0,0 +1,185 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "./form";
describe("FormItem", () => {
it("renders with data-slot='form-item'", () => {
render(<FormItem data-testid="i">x</FormItem>);
expect(screen.getByTestId("i")).toHaveAttribute("data-slot", "form-item");
});
it("merges custom className", () => {
render(
<FormItem data-testid="i" className="my-class">
x
</FormItem>,
);
expect(screen.getByTestId("i")).toHaveClass("my-class");
});
it("provides id and hasError context to children", () => {
render(
<FormItem hasError>
<FormLabel>Name</FormLabel>
<FormControl render={(props) => <input {...props} data-testid="input" />} />
</FormItem>,
);
const label = screen.getByText("Name");
expect(label).toHaveAttribute("data-error", "true");
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("aria-invalid", "true");
});
it("hasError defaults to false", () => {
render(
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl render={(props) => <input {...props} data-testid="input" />} />
</FormItem>,
);
const input = screen.getByTestId("input");
expect(input).toHaveAttribute("aria-invalid", "false");
});
});
describe("FormLabel", () => {
it("renders with data-slot='form-label' and connects htmlFor to control via context id", () => {
render(
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl render={(props) => <input {...props} type="email" />} />
</FormItem>,
);
const label = screen.getByText("Email");
expect(label).toHaveAttribute("data-slot", "form-label");
const htmlFor = label.getAttribute("for");
expect(htmlFor).toMatch(/-form-item$/);
});
});
describe("FormControl", () => {
it("sets aria-describedby pointing only to description when no error", () => {
render(
<FormItem>
<FormControl render={(props) => <input {...props} data-testid="input" />} />
<FormDescription>Help text</FormDescription>
</FormItem>,
);
const input = screen.getByTestId("input");
const describedBy = input.getAttribute("aria-describedby");
expect(describedBy).toMatch(/-form-item-description$/);
expect(describedBy).not.toContain("message");
});
it("includes message in aria-describedby when hasError=true", () => {
render(
<FormItem hasError>
<FormControl render={(props) => <input {...props} data-testid="input" />} />
</FormItem>,
);
const input = screen.getByTestId("input");
const describedBy = input.getAttribute("aria-describedby");
expect(describedBy).toContain("description");
expect(describedBy).toContain("message");
});
});
describe("FormDescription", () => {
it("uses data-slot='form-description'", () => {
render(
<FormItem>
<FormDescription>Help</FormDescription>
</FormItem>,
);
expect(screen.getByText("Help")).toHaveAttribute("data-slot", "form-description");
});
it("id ends with '-form-item-description'", () => {
render(
<FormItem>
<FormDescription>Help</FormDescription>
</FormItem>,
);
expect(screen.getByText("Help").getAttribute("id")).toMatch(/-form-item-description$/);
});
});
describe("FormMessage", () => {
it("returns null when no errors", () => {
const { container } = render(
<FormItem>
<FormMessage />
</FormItem>,
);
expect(container.querySelector("[data-slot=form-message]")).not.toBeInTheDocument();
});
it("returns null when errors is empty", () => {
const { container } = render(
<FormItem>
<FormMessage errors={[]} />
</FormItem>,
);
expect(container.querySelector("[data-slot=form-message]")).not.toBeInTheDocument();
});
it("renders string error message", () => {
render(
<FormItem hasError>
<FormMessage errors={["String error"]} />
</FormItem>,
);
expect(screen.getByText("String error")).toBeInTheDocument();
});
it("renders error with .message property", () => {
render(
<FormItem hasError>
<FormMessage errors={[{ message: "Object error" }]} />
</FormItem>,
);
expect(screen.getByText("Object error")).toBeInTheDocument();
});
it("skips falsy and unrecognized errors and shows the first valid one", () => {
render(
<FormItem hasError>
<FormMessage errors={[null, undefined, { wrong: "field" }, "Valid"]} />
</FormItem>,
);
expect(screen.getByText("Valid")).toBeInTheDocument();
});
it("returns null when no extractable error message in array", () => {
const { container } = render(
<FormItem>
<FormMessage errors={[null, undefined, { wrong: "field" }]} />
</FormItem>,
);
expect(container.querySelector("[data-slot=form-message]")).not.toBeInTheDocument();
});
it("uses destructive class when hasError=true", () => {
render(
<FormItem hasError>
<FormMessage errors={["Bad"]} />
</FormItem>,
);
expect(screen.getByText("Bad")).toHaveClass("text-destructive");
});
it("uses muted class when hasError=false but errors are passed", () => {
render(
<FormItem>
<FormMessage errors={["Hint"]} />
</FormItem>,
);
expect(screen.getByText("Hint")).toHaveClass("text-muted-foreground");
});
});
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./hover-card";
describe("HoverCard", () => {
it("trigger has data-slot='hover-card-trigger'", () => {
render(
<HoverCard>
<HoverCardTrigger data-testid="trigger">Hover</HoverCardTrigger>
<HoverCardContent>preview</HoverCardContent>
</HoverCard>,
);
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "hover-card-trigger");
});
it("does not render content by default", () => {
render(
<HoverCard>
<HoverCardTrigger>Hover</HoverCardTrigger>
<HoverCardContent>Hidden preview</HoverCardContent>
</HoverCard>,
);
expect(screen.queryByText("Hidden preview")).not.toBeInTheDocument();
});
it("renders content when controlled open", () => {
render(
<HoverCard open>
<HoverCardTrigger>Hover</HoverCardTrigger>
<HoverCardContent>Visible preview</HoverCardContent>
</HoverCard>,
);
expect(screen.getByText("Visible preview")).toBeInTheDocument();
});
it("HoverCardContent uses data-slot='hover-card-content' when shown", () => {
render(
<HoverCard open>
<HoverCardTrigger>x</HoverCardTrigger>
<HoverCardContent>visible</HoverCardContent>
</HoverCard>,
);
expect(screen.getByText("visible")).toHaveAttribute("data-slot", "hover-card-content");
});
it("merges custom className on HoverCardContent", () => {
render(
<HoverCard open>
<HoverCardTrigger>x</HoverCardTrigger>
<HoverCardContent className="my-class">visible</HoverCardContent>
</HoverCard>,
);
expect(screen.getByText("visible")).toHaveClass("my-class");
});
});
@@ -0,0 +1,147 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "./input-group";
describe("InputGroup", () => {
it("renders a fieldset with data-slot='input-group'", () => {
render(<InputGroup data-testid="g" />);
const g = screen.getByTestId("g");
expect(g.tagName).toBe("FIELDSET");
expect(g).toHaveAttribute("data-slot", "input-group");
});
it("merges custom className", () => {
render(<InputGroup data-testid="g" className="my-class" />);
expect(screen.getByTestId("g")).toHaveClass("my-class");
});
});
describe("InputGroupAddon", () => {
it("defaults align to 'inline-start'", () => {
render(<InputGroupAddon data-testid="addon" />);
expect(screen.getByTestId("addon")).toHaveAttribute("data-align", "inline-start");
});
it.each(["inline-start", "inline-end", "block-start", "block-end"] as const)("supports align=%s", (align) => {
render(<InputGroupAddon data-testid="addon" align={align} />);
expect(screen.getByTestId("addon")).toHaveAttribute("data-align", align);
});
it("uses data-slot='input-group-addon'", () => {
render(<InputGroupAddon data-testid="addon" />);
expect(screen.getByTestId("addon")).toHaveAttribute("data-slot", "input-group-addon");
});
it("focuses sibling input on click outside of buttons", () => {
render(
<InputGroup>
<InputGroupAddon data-testid="addon">addon</InputGroupAddon>
<InputGroupInput data-testid="i" />
</InputGroup>,
);
const addon = screen.getByTestId("addon");
fireEvent.click(addon);
expect(document.activeElement).toBe(screen.getByTestId("i"));
});
it("focuses sibling input on Enter key", () => {
render(
<InputGroup>
<InputGroupAddon data-testid="addon">addon</InputGroupAddon>
<InputGroupInput data-testid="i" />
</InputGroup>,
);
const addon = screen.getByTestId("addon");
fireEvent.keyDown(addon, { key: "Enter" });
expect(document.activeElement).toBe(screen.getByTestId("i"));
});
it("focuses sibling input on Space key", () => {
render(
<InputGroup>
<InputGroupAddon data-testid="addon">addon</InputGroupAddon>
<InputGroupInput data-testid="i" />
</InputGroup>,
);
const addon = screen.getByTestId("addon");
fireEvent.keyDown(addon, { key: " " });
expect(document.activeElement).toBe(screen.getByTestId("i"));
});
it("ignores unrelated keys", () => {
render(
<InputGroup>
<InputGroupAddon data-testid="addon">addon</InputGroupAddon>
<InputGroupInput data-testid="i" />
</InputGroup>,
);
const addon = screen.getByTestId("addon");
fireEvent.keyDown(addon, { key: "a" });
expect(document.activeElement).not.toBe(screen.getByTestId("i"));
});
});
describe("InputGroupButton", () => {
it("defaults to type='button' and variant='ghost'", () => {
render(<InputGroupButton data-testid="b">x</InputGroupButton>);
const button = screen.getByTestId("b");
expect(button).toHaveAttribute("type", "button");
});
it("respects type override", () => {
render(
<InputGroupButton data-testid="b" type="submit">
x
</InputGroupButton>,
);
expect(screen.getByTestId("b")).toHaveAttribute("type", "submit");
});
it("applies data-size for size variants", () => {
render(
<InputGroupButton data-testid="b" size="sm">
x
</InputGroupButton>,
);
expect(screen.getByTestId("b")).toHaveAttribute("data-size", "sm");
});
});
describe("InputGroupText", () => {
it("renders as a span", () => {
render(<InputGroupText>label</InputGroupText>);
expect(screen.getByText("label").tagName).toBe("SPAN");
});
it("merges custom className", () => {
render(<InputGroupText className="my-class">x</InputGroupText>);
expect(screen.getByText("x")).toHaveClass("my-class");
});
});
describe("InputGroupInput", () => {
it("uses data-slot='input-group-control'", () => {
render(<InputGroupInput data-testid="i" />);
expect(screen.getByTestId("i")).toHaveAttribute("data-slot", "input-group-control");
});
});
describe("InputGroupTextarea", () => {
it("uses data-slot='input-group-control' and renders textarea", () => {
render(<InputGroupTextarea data-testid="t" />);
const t = screen.getByTestId("t");
expect(t.tagName).toBe("TEXTAREA");
expect(t).toHaveAttribute("data-slot", "input-group-control");
});
});
+49
View File
@@ -0,0 +1,49 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Input } from "./input";
describe("Input", () => {
it("renders an input element", () => {
render(<Input data-testid="x" />);
expect(screen.getByTestId("x").tagName).toBe("INPUT");
});
it("applies data-slot='input'", () => {
render(<Input data-testid="x" />);
expect(screen.getByTestId("x")).toHaveAttribute("data-slot", "input");
});
it("merges custom className", () => {
render(<Input data-testid="x" className="custom-input" />);
expect(screen.getByTestId("x")).toHaveClass("custom-input");
});
it("forwards type attribute", () => {
render(<Input data-testid="x" type="email" />);
expect(screen.getByTestId("x")).toHaveAttribute("type", "email");
});
it("calls onChange when typing", async () => {
const onChange = vi.fn();
render(<Input data-testid="x" onChange={onChange} />);
await userEvent.type(screen.getByTestId("x"), "abc");
expect(onChange).toHaveBeenCalled();
});
it("forwards placeholder", () => {
render(<Input placeholder="Enter name" />);
expect(screen.getByPlaceholderText("Enter name")).toBeInTheDocument();
});
it("respects disabled state", async () => {
render(<Input data-testid="x" disabled />);
const input = screen.getByTestId("x");
expect(input).toBeDisabled();
});
it("supports controlled value", () => {
render(<Input data-testid="x" value="hello" onChange={() => {}} />);
expect(screen.getByTestId("x")).toHaveValue("hello");
});
});
+55
View File
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Kbd, KbdGroup } from "./kbd";
describe("Kbd", () => {
it("renders as a <kbd> element with data-slot='kbd'", () => {
render(<Kbd data-testid="k">Ctrl</Kbd>);
const k = screen.getByTestId("k");
expect(k.tagName).toBe("KBD");
expect(k).toHaveAttribute("data-slot", "kbd");
});
it("renders the key text", () => {
render(<Kbd>⌘ K</Kbd>);
expect(screen.getByText("⌘ K")).toBeInTheDocument();
});
it("merges custom className", () => {
render(
<Kbd data-testid="k" className="my-class">
x
</Kbd>,
);
expect(screen.getByTestId("k")).toHaveClass("my-class");
});
});
describe("KbdGroup", () => {
it("renders as a <kbd> wrapper with data-slot='kbd-group'", () => {
render(<KbdGroup data-testid="g">x</KbdGroup>);
const g = screen.getByTestId("g");
expect(g.tagName).toBe("KBD");
expect(g).toHaveAttribute("data-slot", "kbd-group");
});
it("groups multiple Kbd elements", () => {
render(
<KbdGroup>
<Kbd>Ctrl</Kbd>
<Kbd>K</Kbd>
</KbdGroup>,
);
expect(screen.getByText("Ctrl")).toBeInTheDocument();
expect(screen.getByText("K")).toBeInTheDocument();
});
it("merges custom className", () => {
render(
<KbdGroup data-testid="g" className="my-group">
x
</KbdGroup>,
);
expect(screen.getByTestId("g")).toHaveClass("my-group");
});
});
+31
View File
@@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Label } from "./label";
describe("Label", () => {
it("renders as a <label> element", () => {
render(<Label>Name</Label>);
expect(screen.getByText("Name").tagName).toBe("LABEL");
});
it("applies data-slot='label'", () => {
render(<Label>x</Label>);
expect(screen.getByText("x")).toHaveAttribute("data-slot", "label");
});
it("merges custom className", () => {
render(<Label className="my-custom">x</Label>);
expect(screen.getByText("x")).toHaveClass("my-custom");
});
it("supports htmlFor association with inputs", () => {
render(
<>
<Label htmlFor="name">Name</Label>
<input id="name" />
</>,
);
const input = screen.getByLabelText("Name");
expect(input.tagName).toBe("INPUT");
});
});
+116
View File
@@ -0,0 +1,116 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "./popover";
describe("Popover", () => {
it("trigger element has data-slot='popover-trigger'", () => {
render(
<Popover>
<PopoverTrigger data-testid="trigger">Open</PopoverTrigger>
<PopoverContent>content</PopoverContent>
</Popover>,
);
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "popover-trigger");
});
it("opens content on trigger click", async () => {
render(
<Popover>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverContent>Hidden text</PopoverContent>
</Popover>,
);
expect(screen.queryByText("Hidden text")).not.toBeInTheDocument();
await userEvent.click(screen.getByText("Open"));
expect(await screen.findByText("Hidden text")).toBeInTheDocument();
});
it("supports default open via prop", () => {
render(
<Popover defaultOpen>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverContent>Initially visible</PopoverContent>
</Popover>,
);
expect(screen.getByText("Initially visible")).toBeInTheDocument();
});
it("can be controlled via open prop", () => {
render(
<Popover open>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverContent>Controlled</PopoverContent>
</Popover>,
);
expect(screen.getByText("Controlled")).toBeInTheDocument();
});
});
describe("PopoverHeader", () => {
it("uses data-slot='popover-header'", () => {
render(<PopoverHeader data-testid="h">Title</PopoverHeader>);
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "popover-header");
});
it("merges custom className", () => {
render(
<PopoverHeader data-testid="h" className="my-class">
x
</PopoverHeader>,
);
expect(screen.getByTestId("h")).toHaveClass("my-class");
});
});
describe("PopoverTitle", () => {
it("uses data-slot='popover-title'", () => {
render(
<Popover open>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverContent>
<PopoverTitle>My title</PopoverTitle>
</PopoverContent>
</Popover>,
);
expect(screen.getByText("My title")).toHaveAttribute("data-slot", "popover-title");
});
});
describe("PopoverDescription", () => {
it("uses data-slot='popover-description'", () => {
render(
<Popover open>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverContent>
<PopoverDescription>My description</PopoverDescription>
</PopoverContent>
</Popover>,
);
expect(screen.getByText("My description")).toHaveAttribute("data-slot", "popover-description");
});
});
describe("PopoverContent", () => {
it("uses data-slot='popover-content'", () => {
render(
<Popover open>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverContent>visible content</PopoverContent>
</Popover>,
);
const content = screen.getByText("visible content");
expect(content).toHaveAttribute("data-slot", "popover-content");
});
it("merges custom className", () => {
render(
<Popover open>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverContent className="my-class">visible</PopoverContent>
</Popover>,
);
expect(screen.getByText("visible")).toHaveClass("my-class");
});
});
@@ -0,0 +1,71 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Progress, ProgressIndicator, ProgressLabel, ProgressTrack, ProgressValue } from "./progress";
describe("Progress", () => {
it("renders with data-slot='progress'", () => {
render(<Progress data-testid="p" value={50} />);
expect(screen.getByTestId("p")).toHaveAttribute("data-slot", "progress");
});
it("auto-renders track and indicator children", () => {
const { container } = render(<Progress value={50} data-testid="p" />);
expect(container.querySelector("[data-slot=progress-track]")).toBeInTheDocument();
expect(container.querySelector("[data-slot=progress-indicator]")).toBeInTheDocument();
});
it("forwards value attribute via aria-valuenow", () => {
render(<Progress data-testid="p" value={75} />);
expect(screen.getByTestId("p")).toHaveAttribute("aria-valuenow", "75");
});
it("supports custom children rendered before the track", () => {
render(
<Progress data-testid="p" value={50}>
<ProgressLabel>My Progress</ProgressLabel>
</Progress>,
);
expect(screen.getByText("My Progress")).toBeInTheDocument();
});
});
describe("ProgressLabel", () => {
it("renders with data-slot='progress-label'", () => {
render(
<Progress value={50}>
<ProgressLabel>Label</ProgressLabel>
</Progress>,
);
expect(screen.getByText("Label")).toHaveAttribute("data-slot", "progress-label");
});
});
describe("ProgressValue", () => {
it("renders with data-slot='progress-value'", () => {
render(
<Progress value={50}>
<ProgressValue />
</Progress>,
);
// The value text is rendered by base-ui — just verify the slot exists
const { container } = render(
<Progress value={50}>
<ProgressValue data-testid="v" />
</Progress>,
);
expect(container.querySelector("[data-slot=progress-value]")).toBeInTheDocument();
});
});
describe("ProgressTrack and ProgressIndicator", () => {
it("can be rendered explicitly with custom classes", () => {
const { container } = render(
<Progress value={25}>
<ProgressTrack className="track-custom">
<ProgressIndicator className="ind-custom" />
</ProgressTrack>
</Progress>,
);
expect(container.querySelectorAll("[data-slot=progress-track]")).toHaveLength(2);
});
});
@@ -0,0 +1,64 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "./resizable";
describe("ResizableGroup", () => {
it("renders with data-slot='resizable-panel-group'", () => {
const { container } = render(
<ResizableGroup direction="horizontal">
<ResizablePanel>A</ResizablePanel>
<ResizableSeparator />
<ResizablePanel>B</ResizablePanel>
</ResizableGroup>,
);
expect(container.querySelector("[data-slot=resizable-panel-group]")).toBeInTheDocument();
});
it("merges custom className", () => {
const { container } = render(
<ResizableGroup direction="horizontal" className="my-group">
<ResizablePanel>x</ResizablePanel>
</ResizableGroup>,
);
expect(container.querySelector("[data-slot=resizable-panel-group]")).toHaveClass("my-group");
});
});
describe("ResizablePanel", () => {
it("renders with data-slot='resizable-panel'", () => {
const { container } = render(
<ResizableGroup direction="horizontal">
<ResizablePanel>x</ResizablePanel>
</ResizableGroup>,
);
expect(container.querySelector("[data-slot=resizable-panel]")).toBeInTheDocument();
});
});
describe("ResizableSeparator", () => {
it("renders with data-slot='resizable-handle' and no inner handle by default", () => {
const { container } = render(
<ResizableGroup direction="horizontal">
<ResizablePanel>A</ResizablePanel>
<ResizableSeparator />
<ResizablePanel>B</ResizablePanel>
</ResizableGroup>,
);
const handle = container.querySelector("[data-slot=resizable-handle]");
expect(handle).toBeInTheDocument();
// No inner handle div by default
expect(handle?.children).toHaveLength(0);
});
it("renders inner handle when withHandle=true", () => {
const { container } = render(
<ResizableGroup direction="horizontal">
<ResizablePanel>A</ResizablePanel>
<ResizableSeparator withHandle />
<ResizablePanel>B</ResizablePanel>
</ResizableGroup>,
);
const handle = container.querySelector("[data-slot=resizable-handle]");
expect(handle?.children.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,42 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ScrollArea } from "./scroll-area";
describe("ScrollArea", () => {
it("renders with data-slot='scroll-area'", () => {
render(
<ScrollArea data-testid="sa">
<div>content</div>
</ScrollArea>,
);
expect(screen.getByTestId("sa")).toHaveAttribute("data-slot", "scroll-area");
});
it("renders viewport with children", () => {
const { container } = render(
<ScrollArea>
<div>content</div>
</ScrollArea>,
);
expect(container.querySelector("[data-slot=scroll-area-viewport]")).toBeInTheDocument();
expect(screen.getByText("content")).toBeInTheDocument();
});
it("merges custom className on root", () => {
render(
<ScrollArea data-testid="sa" className="my-class">
<div>x</div>
</ScrollArea>,
);
expect(screen.getByTestId("sa")).toHaveClass("my-class");
});
it("preserves the relative positioning class", () => {
render(
<ScrollArea data-testid="sa">
<div>x</div>
</ScrollArea>,
);
expect(screen.getByTestId("sa")).toHaveClass("relative");
});
});
@@ -0,0 +1,25 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Separator } from "./separator";
describe("Separator", () => {
it("renders with data-slot='separator'", () => {
render(<Separator data-testid="sep" />);
expect(screen.getByTestId("sep")).toHaveAttribute("data-slot", "separator");
});
it("defaults to horizontal orientation", () => {
render(<Separator data-testid="sep" />);
expect(screen.getByTestId("sep")).toHaveAttribute("data-orientation", "horizontal");
});
it("supports vertical orientation", () => {
render(<Separator data-testid="sep" orientation="vertical" />);
expect(screen.getByTestId("sep")).toHaveAttribute("data-orientation", "vertical");
});
it("merges custom className", () => {
render(<Separator data-testid="sep" className="my-sep" />);
expect(screen.getByTestId("sep")).toHaveClass("my-sep");
});
});
+104
View File
@@ -0,0 +1,104 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./sheet";
describe("Sheet", () => {
it("does not render content by default", () => {
render(
<Sheet>
<SheetTrigger>Open</SheetTrigger>
<SheetContent>Hidden body</SheetContent>
</Sheet>,
);
expect(screen.queryByText("Hidden body")).not.toBeInTheDocument();
});
it("opens via defaultOpen", () => {
render(
<Sheet defaultOpen>
<SheetTrigger>Open</SheetTrigger>
<SheetContent>Visible body</SheetContent>
</Sheet>,
);
expect(screen.getByText("Visible body")).toBeInTheDocument();
});
it("renders default 'right' side", () => {
render(
<Sheet open>
<SheetTrigger>Open</SheetTrigger>
<SheetContent>visible</SheetContent>
</Sheet>,
);
const content = screen.getByText("visible");
expect(content).toHaveAttribute("data-side", "right");
});
it.each(["top", "right", "bottom", "left"] as const)("supports side=%s", (side) => {
render(
<Sheet open>
<SheetTrigger>x</SheetTrigger>
<SheetContent side={side}>visible {side}</SheetContent>
</Sheet>,
);
expect(screen.getByText(`visible ${side}`)).toHaveAttribute("data-side", side);
});
it("renders close button by default", () => {
render(
<Sheet open>
<SheetTrigger>x</SheetTrigger>
<SheetContent>x</SheetContent>
</Sheet>,
);
expect(screen.getByText("Close")).toBeInTheDocument();
});
it("hides close button with showCloseButton=false", () => {
render(
<Sheet open>
<SheetTrigger>x</SheetTrigger>
<SheetContent showCloseButton={false}>x</SheetContent>
</Sheet>,
);
expect(screen.queryByText("Close")).not.toBeInTheDocument();
});
});
describe("SheetHeader / SheetFooter", () => {
it("SheetHeader uses data-slot='sheet-header'", () => {
render(<SheetHeader data-testid="h">x</SheetHeader>);
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "sheet-header");
});
it("SheetFooter uses data-slot='sheet-footer'", () => {
render(<SheetFooter data-testid="f">x</SheetFooter>);
expect(screen.getByTestId("f")).toHaveAttribute("data-slot", "sheet-footer");
});
});
describe("SheetTitle / SheetDescription", () => {
it("SheetTitle uses data-slot='sheet-title'", () => {
render(
<Sheet open>
<SheetTrigger>x</SheetTrigger>
<SheetContent>
<SheetTitle>Title</SheetTitle>
</SheetContent>
</Sheet>,
);
expect(screen.getByText("Title")).toHaveAttribute("data-slot", "sheet-title");
});
it("SheetDescription uses data-slot='sheet-description'", () => {
render(
<Sheet open>
<SheetTrigger>x</SheetTrigger>
<SheetContent>
<SheetDescription>Description</SheetDescription>
</SheetContent>
</Sheet>,
);
expect(screen.getByText("Description")).toHaveAttribute("data-slot", "sheet-description");
});
});
+358
View File
@@ -0,0 +1,358 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
} from "./sidebar";
const renderInProvider = (children: React.ReactNode, props: Partial<Parameters<typeof SidebarProvider>[0]> = {}) =>
render(
<SidebarProvider {...props}>
<Sidebar>{children}</Sidebar>
</SidebarProvider>,
);
describe("SidebarProvider", () => {
it("renders children inside a sidebar-wrapper div", () => {
const { container } = render(
<SidebarProvider>
<div data-testid="child">child</div>
</SidebarProvider>,
);
expect(container.querySelector("[data-slot=sidebar-wrapper]")).toBeInTheDocument();
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("merges custom className on wrapper", () => {
const { container } = render(
<SidebarProvider className="my-wrapper">
<div>x</div>
</SidebarProvider>,
);
expect(container.querySelector("[data-slot=sidebar-wrapper]")).toHaveClass("my-wrapper");
});
it("calls onOpenChange when toggled in controlled mode", async () => {
const onOpenChange = vi.fn();
render(
<SidebarProvider open={true} onOpenChange={onOpenChange}>
<Sidebar>x</Sidebar>
<SidebarTrigger />
</SidebarProvider>,
);
await userEvent.click(screen.getByRole("button", { name: /toggle sidebar/i }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("toggles via Cmd/Ctrl+B keyboard shortcut", async () => {
const onOpenChange = vi.fn();
render(
<SidebarProvider open={true} onOpenChange={onOpenChange}>
<Sidebar>x</Sidebar>
</SidebarProvider>,
);
await userEvent.keyboard("{Control>}b{/Control}");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
describe("Sidebar", () => {
it("renders with data-slot='sidebar' when collapsible='none'", () => {
const { container } = render(
<SidebarProvider>
<Sidebar collapsible="none">x</Sidebar>
</SidebarProvider>,
);
expect(container.querySelector("[data-slot=sidebar]")).toBeInTheDocument();
});
it("renders with data-state='expanded' by default", () => {
const { container } = render(
<SidebarProvider defaultOpen>
<Sidebar>x</Sidebar>
</SidebarProvider>,
);
// data-state is on the desktop wrapper
const sidebar = container.querySelector("[data-slot=sidebar]");
expect(sidebar).toHaveAttribute("data-state", "expanded");
});
it("renders with data-state='collapsed' when defaultOpen=false", () => {
const { container } = render(
<SidebarProvider defaultOpen={false}>
<Sidebar>x</Sidebar>
</SidebarProvider>,
);
const sidebar = container.querySelector("[data-slot=sidebar]");
expect(sidebar).toHaveAttribute("data-state", "collapsed");
});
it.each(["sidebar", "floating", "inset"] as const)("supports variant=%s", (variant) => {
const { container } = render(
<SidebarProvider>
<Sidebar variant={variant}>x</Sidebar>
</SidebarProvider>,
);
const sidebar = container.querySelector("[data-slot=sidebar]");
expect(sidebar).toHaveAttribute("data-variant", variant);
});
it.each(["left", "right"] as const)("supports side=%s", (side) => {
const { container } = render(
<SidebarProvider>
<Sidebar side={side}>x</Sidebar>
</SidebarProvider>,
);
const sidebar = container.querySelector("[data-slot=sidebar]");
expect(sidebar).toHaveAttribute("data-side", side);
});
});
describe("SidebarTrigger", () => {
it("renders with sr-only label 'Toggle Sidebar'", () => {
render(
<SidebarProvider>
<SidebarTrigger />
</SidebarProvider>,
);
expect(screen.getByText("Toggle Sidebar")).toBeInTheDocument();
});
it("has data-slot='sidebar-trigger' and data-sidebar='trigger'", () => {
render(
<SidebarProvider>
<SidebarTrigger data-testid="trigger" />
</SidebarProvider>,
);
const trigger = screen.getByTestId("trigger");
expect(trigger).toHaveAttribute("data-slot", "sidebar-trigger");
expect(trigger).toHaveAttribute("data-sidebar", "trigger");
});
it("invokes user onClick before toggling", async () => {
const onClick = vi.fn();
const onOpenChange = vi.fn();
render(
<SidebarProvider open={true} onOpenChange={onOpenChange}>
<SidebarTrigger onClick={onClick} />
</SidebarProvider>,
);
await userEvent.click(screen.getByRole("button", { name: /toggle sidebar/i }));
expect(onClick).toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
describe("SidebarRail", () => {
it("renders with data-slot='sidebar-rail' and aria-label='Toggle Sidebar'", () => {
render(
<SidebarProvider>
<SidebarRail />
</SidebarProvider>,
);
const rail = screen.getByLabelText("Toggle Sidebar");
expect(rail).toHaveAttribute("data-slot", "sidebar-rail");
});
it("toggles sidebar on click", async () => {
const onOpenChange = vi.fn();
render(
<SidebarProvider open={true} onOpenChange={onOpenChange}>
<SidebarRail />
</SidebarProvider>,
);
await userEvent.click(screen.getByLabelText("Toggle Sidebar"));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
describe("SidebarInset", () => {
it("renders as <main> with data-slot='sidebar-inset'", () => {
render(<SidebarInset data-testid="inset">x</SidebarInset>);
const inset = screen.getByTestId("inset");
expect(inset.tagName).toBe("MAIN");
expect(inset).toHaveAttribute("data-slot", "sidebar-inset");
});
});
describe("SidebarInput", () => {
it("renders with data-slot='sidebar-input'", () => {
render(<SidebarInput data-testid="i" />);
expect(screen.getByTestId("i")).toHaveAttribute("data-slot", "sidebar-input");
});
});
describe("SidebarHeader / SidebarFooter / SidebarSeparator", () => {
it("SidebarHeader uses data-slot='sidebar-header'", () => {
render(<SidebarHeader data-testid="h">x</SidebarHeader>);
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "sidebar-header");
});
it("SidebarFooter uses data-slot='sidebar-footer'", () => {
render(<SidebarFooter data-testid="f">x</SidebarFooter>);
expect(screen.getByTestId("f")).toHaveAttribute("data-slot", "sidebar-footer");
});
it("SidebarSeparator uses data-slot='sidebar-separator'", () => {
render(<SidebarSeparator data-testid="s" />);
expect(screen.getByTestId("s")).toHaveAttribute("data-slot", "sidebar-separator");
});
});
describe("SidebarContent / SidebarGroup", () => {
it("SidebarContent uses data-slot='sidebar-content'", () => {
render(<SidebarContent data-testid="c">x</SidebarContent>);
expect(screen.getByTestId("c")).toHaveAttribute("data-slot", "sidebar-content");
});
it("SidebarGroup uses data-slot='sidebar-group'", () => {
render(<SidebarGroup data-testid="g">x</SidebarGroup>);
expect(screen.getByTestId("g")).toHaveAttribute("data-slot", "sidebar-group");
});
it("SidebarGroupContent uses data-slot='sidebar-group-content'", () => {
render(<SidebarGroupContent data-testid="g">x</SidebarGroupContent>);
expect(screen.getByTestId("g")).toHaveAttribute("data-slot", "sidebar-group-content");
});
it("SidebarGroupLabel uses data-slot='sidebar-group-label'", () => {
render(<SidebarGroupLabel data-testid="l">Group</SidebarGroupLabel>);
expect(screen.getByTestId("l")).toHaveAttribute("data-slot", "sidebar-group-label");
});
it("SidebarGroupAction uses data-slot='sidebar-group-action'", () => {
render(<SidebarGroupAction data-testid="a">+</SidebarGroupAction>);
expect(screen.getByTestId("a")).toHaveAttribute("data-slot", "sidebar-group-action");
});
});
describe("SidebarMenu and items", () => {
it("SidebarMenu uses data-slot='sidebar-menu' and renders as <ul>", () => {
render(<SidebarMenu data-testid="m" />);
const menu = screen.getByTestId("m");
expect(menu.tagName).toBe("UL");
expect(menu).toHaveAttribute("data-slot", "sidebar-menu");
});
it("SidebarMenuItem uses data-slot='sidebar-menu-item' and renders as <li>", () => {
render(<SidebarMenuItem data-testid="i">x</SidebarMenuItem>);
const item = screen.getByTestId("i");
expect(item.tagName).toBe("LI");
expect(item).toHaveAttribute("data-slot", "sidebar-menu-item");
});
it("SidebarMenuButton uses data-slot='sidebar-menu-button'", () => {
render(
<SidebarProvider>
<SidebarMenuButton data-testid="b">Click</SidebarMenuButton>
</SidebarProvider>,
);
expect(screen.getByTestId("b")).toHaveAttribute("data-slot", "sidebar-menu-button");
});
it("SidebarMenuButton supports isActive", () => {
render(
<SidebarProvider>
<SidebarMenuButton data-testid="b" isActive>
x
</SidebarMenuButton>
</SidebarProvider>,
);
// data-active is set as a presence attribute when isActive is truthy
expect(screen.getByTestId("b")).toHaveAttribute("data-active");
});
it("SidebarMenuAction uses data-slot='sidebar-menu-action'", () => {
render(<SidebarMenuAction data-testid="a">x</SidebarMenuAction>);
expect(screen.getByTestId("a")).toHaveAttribute("data-slot", "sidebar-menu-action");
});
it("SidebarMenuBadge uses data-slot='sidebar-menu-badge'", () => {
render(<SidebarMenuBadge data-testid="b">5</SidebarMenuBadge>);
expect(screen.getByTestId("b")).toHaveAttribute("data-slot", "sidebar-menu-badge");
});
it("SidebarMenuSkeleton renders without throwing", () => {
render(<SidebarMenuSkeleton data-testid="sk" />);
expect(screen.getByTestId("sk")).toBeInTheDocument();
});
it("SidebarMenuSkeleton supports showIcon", () => {
const { container } = render(<SidebarMenuSkeleton showIcon />);
// Should render the icon Skeleton
expect(container.querySelectorAll("[data-slot=skeleton]").length).toBeGreaterThan(1);
});
});
describe("SidebarMenuSub family", () => {
it("SidebarMenuSub uses data-slot='sidebar-menu-sub' and is <ul>", () => {
render(<SidebarMenuSub data-testid="ms" />);
const ms = screen.getByTestId("ms");
expect(ms.tagName).toBe("UL");
expect(ms).toHaveAttribute("data-slot", "sidebar-menu-sub");
});
it("SidebarMenuSubItem uses data-slot='sidebar-menu-sub-item' and is <li>", () => {
render(<SidebarMenuSubItem data-testid="ms">x</SidebarMenuSubItem>);
const ms = screen.getByTestId("ms");
expect(ms.tagName).toBe("LI");
expect(ms).toHaveAttribute("data-slot", "sidebar-menu-sub-item");
});
it("SidebarMenuSubButton uses data-slot='sidebar-menu-sub-button'", () => {
render(<SidebarMenuSubButton data-testid="b">x</SidebarMenuSubButton>);
expect(screen.getByTestId("b")).toHaveAttribute("data-slot", "sidebar-menu-sub-button");
});
});
describe("Sidebar composition", () => {
it("composes a complete sidebar without errors", () => {
expect(() =>
renderInProvider(
<>
<SidebarHeader>Header</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Group 1</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>Item</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>Footer</SidebarFooter>
</>,
),
).not.toThrow();
});
});
@@ -0,0 +1,27 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Skeleton } from "./skeleton";
describe("Skeleton", () => {
it("renders with data-slot='skeleton'", () => {
render(<Skeleton data-testid="sk" />);
expect(screen.getByTestId("sk")).toHaveAttribute("data-slot", "skeleton");
});
it("applies pulse animation classes", () => {
render(<Skeleton data-testid="sk" />);
expect(screen.getByTestId("sk")).toHaveClass("animate-pulse");
});
it("merges custom className", () => {
render(<Skeleton className="h-10 w-20" data-testid="sk" />);
const skeleton = screen.getByTestId("sk");
expect(skeleton).toHaveClass("h-10");
expect(skeleton).toHaveClass("w-20");
});
it("forwards arbitrary props", () => {
render(<Skeleton data-testid="sk" aria-busy="true" />);
expect(screen.getByTestId("sk")).toHaveAttribute("aria-busy", "true");
});
});
@@ -0,0 +1,43 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Slider } from "./slider";
describe("Slider", () => {
it("renders with data-slot='slider'", () => {
render(<Slider data-testid="s" defaultValue={[50]} />);
expect(screen.getByTestId("s")).toHaveAttribute("data-slot", "slider");
});
it("renders one thumb per value", () => {
const { container } = render(<Slider defaultValue={[10, 90]} />);
expect(container.querySelectorAll("[data-slot=slider-thumb]")).toHaveLength(2);
});
it("renders single thumb for single value", () => {
const { container } = render(<Slider defaultValue={[50]} />);
expect(container.querySelectorAll("[data-slot=slider-thumb]")).toHaveLength(1);
});
it("renders track and range", () => {
const { container } = render(<Slider defaultValue={[50]} />);
expect(container.querySelector("[data-slot=slider-track]")).toBeInTheDocument();
expect(container.querySelector("[data-slot=slider-range]")).toBeInTheDocument();
});
it("respects custom min and max", () => {
render(<Slider data-testid="s" defaultValue={[5]} min={0} max={10} />);
// Value, min, max are reflected on the root element via aria attrs in some envs
expect(screen.getByTestId("s")).toBeInTheDocument();
});
it("merges custom className", () => {
render(<Slider data-testid="s" defaultValue={[50]} className="custom-slider" />);
expect(screen.getByTestId("s")).toHaveClass("custom-slider");
});
it("renders 2 thumbs with default min/max if no defaultValue or value passed", () => {
const { container } = render(<Slider />);
// Source code: defaults to [min, max] when neither value nor defaultValue is array
expect(container.querySelectorAll("[data-slot=slider-thumb]")).toHaveLength(2);
});
});
@@ -0,0 +1,30 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ThemeProvider } from "next-themes";
import { Toaster } from "./sonner";
describe("Toaster", () => {
it("renders without errors when wrapped in ThemeProvider", () => {
const { container } = render(
<ThemeProvider>
<Toaster />
</ThemeProvider>,
);
// Sonner renders into document.body, but we just verify no crash
expect(container).toBeInTheDocument();
});
it("forwards arbitrary props to underlying Sonner", () => {
const { container } = render(
<ThemeProvider>
<Toaster position="top-right" />
</ThemeProvider>,
);
expect(container).toBeInTheDocument();
});
it("does not throw when rendered without theme context", () => {
// Toaster falls back to "system" theme when next-themes context is absent.
expect(() => render(<Toaster />)).not.toThrow();
});
});
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Spinner } from "./spinner";
describe("Spinner", () => {
it("renders with role='status' for screen readers", () => {
render(<Spinner />);
expect(screen.getByRole("status")).toBeInTheDocument();
});
it("has accessible label 'Loading'", () => {
render(<Spinner />);
expect(screen.getByLabelText("Loading")).toBeInTheDocument();
});
it("merges custom className", () => {
render(<Spinner className="my-spinner" />);
expect(screen.getByRole("status")).toHaveClass("my-spinner");
});
it("applies animate-spin by default", () => {
render(<Spinner />);
expect(screen.getByRole("status")).toHaveClass("animate-spin");
});
it("uses currentColor by default (mapped to SVG fill/stroke by Phosphor)", () => {
render(<Spinner />);
const svg = screen.getByRole("status");
// Phosphor maps the `color` prop to inline style (fill/stroke), not an attribute.
// The hex/RGB or "currentColor" appears somewhere in style/attr — verify presence in serialized output.
expect(svg.outerHTML).toContain("currentColor");
});
it("respects color override", () => {
render(<Spinner color="red" />);
const svg = screen.getByRole("status");
expect(svg.outerHTML).toMatch(/red/);
});
});
@@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Switch } from "./switch";
describe("Switch", () => {
it("renders with data-slot='switch'", () => {
render(<Switch data-testid="sw" />);
expect(screen.getByTestId("sw")).toHaveAttribute("data-slot", "switch");
});
it("defaults size to 'default'", () => {
render(<Switch data-testid="sw" />);
expect(screen.getByTestId("sw")).toHaveAttribute("data-size", "default");
});
it("supports size='sm'", () => {
render(<Switch data-testid="sw" size="sm" />);
expect(screen.getByTestId("sw")).toHaveAttribute("data-size", "sm");
});
it("starts unchecked by default", () => {
render(<Switch data-testid="sw" />);
expect(screen.getByTestId("sw").getAttribute("aria-checked")).toBe("false");
});
it("respects defaultChecked", () => {
render(<Switch data-testid="sw" defaultChecked />);
expect(screen.getByTestId("sw").getAttribute("aria-checked")).toBe("true");
});
it("calls onCheckedChange when toggled", async () => {
const onChange = vi.fn();
render(<Switch data-testid="sw" onCheckedChange={onChange} />);
await userEvent.click(screen.getByTestId("sw"));
expect(onChange).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith(true, expect.anything());
});
it("merges custom className", () => {
render(<Switch data-testid="sw" className="my-custom" />);
expect(screen.getByTestId("sw")).toHaveClass("my-custom");
});
});
+95
View File
@@ -0,0 +1,95 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
const renderTabs = (props: Partial<Parameters<typeof Tabs>[0]> = {}) => {
return render(
<Tabs defaultValue="a" {...props}>
<TabsList>
<TabsTrigger value="a">Tab A</TabsTrigger>
<TabsTrigger value="b">Tab B</TabsTrigger>
</TabsList>
<TabsContent value="a">Content A</TabsContent>
<TabsContent value="b">Content B</TabsContent>
</Tabs>,
);
};
describe("Tabs", () => {
it("uses data-slot='tabs'", () => {
const { container } = renderTabs();
expect(container.querySelector("[data-slot=tabs]")).toBeInTheDocument();
});
it("defaults orientation to 'horizontal'", () => {
const { container } = renderTabs();
expect(container.querySelector("[data-slot=tabs]")).toHaveAttribute("data-orientation", "horizontal");
});
it("supports vertical orientation", () => {
const { container } = renderTabs({ orientation: "vertical" });
expect(container.querySelector("[data-slot=tabs]")).toHaveAttribute("data-orientation", "vertical");
});
it("renders the default selected tab content", () => {
renderTabs();
expect(screen.getByText("Content A")).toBeInTheDocument();
});
it("switches to the other tab on click", async () => {
renderTabs();
await userEvent.click(screen.getByText("Tab B"));
expect(screen.getByText("Content B")).toBeInTheDocument();
});
it("merges custom className on root", () => {
const { container } = render(
<Tabs defaultValue="a" className="my-tabs">
<TabsList>
<TabsTrigger value="a">A</TabsTrigger>
</TabsList>
<TabsContent value="a">x</TabsContent>
</Tabs>,
);
expect(container.querySelector("[data-slot=tabs]")).toHaveClass("my-tabs");
});
});
describe("TabsList", () => {
it("uses data-slot='tabs-list'", () => {
const { container } = renderTabs();
expect(container.querySelector("[data-slot=tabs-list]")).toBeInTheDocument();
});
it("defaults variant to 'default'", () => {
const { container } = renderTabs();
expect(container.querySelector("[data-slot=tabs-list]")).toHaveAttribute("data-variant", "default");
});
it("supports variant='line'", () => {
const { container } = render(
<Tabs defaultValue="a">
<TabsList variant="line">
<TabsTrigger value="a">A</TabsTrigger>
</TabsList>
<TabsContent value="a">x</TabsContent>
</Tabs>,
);
expect(container.querySelector("[data-slot=tabs-list]")).toHaveAttribute("data-variant", "line");
});
});
describe("TabsTrigger", () => {
it("uses data-slot='tabs-trigger'", () => {
const { container } = renderTabs();
expect(container.querySelector("[data-slot=tabs-trigger]")).toBeInTheDocument();
});
});
describe("TabsContent", () => {
it("uses data-slot='tabs-content'", () => {
const { container } = renderTabs();
expect(container.querySelector("[data-slot=tabs-content]")).toBeInTheDocument();
});
});
@@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Textarea } from "./textarea";
describe("Textarea", () => {
it("renders a <textarea> element", () => {
render(<Textarea data-testid="t" />);
expect(screen.getByTestId("t").tagName).toBe("TEXTAREA");
});
it("applies data-slot='textarea'", () => {
render(<Textarea data-testid="t" />);
expect(screen.getByTestId("t")).toHaveAttribute("data-slot", "textarea");
});
it("merges custom className", () => {
render(<Textarea data-testid="t" className="custom" />);
expect(screen.getByTestId("t")).toHaveClass("custom");
});
it("calls onChange when typing", async () => {
const onChange = vi.fn();
render(<Textarea data-testid="t" onChange={onChange} />);
await userEvent.type(screen.getByTestId("t"), "hi");
expect(onChange).toHaveBeenCalled();
});
it("respects disabled state", () => {
render(<Textarea data-testid="t" disabled />);
expect(screen.getByTestId("t")).toBeDisabled();
});
it("forwards rows and cols", () => {
render(<Textarea data-testid="t" rows={5} cols={40} />);
expect(screen.getByTestId("t")).toHaveAttribute("rows", "5");
expect(screen.getByTestId("t")).toHaveAttribute("cols", "40");
});
it("supports controlled value", () => {
render(<Textarea data-testid="t" value="hello" onChange={() => {}} />);
expect(screen.getByTestId("t")).toHaveValue("hello");
});
});
@@ -0,0 +1,63 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Toggle } from "./toggle";
describe("Toggle", () => {
it("renders with data-slot='toggle'", () => {
render(<Toggle data-testid="t">on</Toggle>);
expect(screen.getByTestId("t")).toHaveAttribute("data-slot", "toggle");
});
it("starts unpressed by default", () => {
render(<Toggle data-testid="t">on</Toggle>);
expect(screen.getByTestId("t").getAttribute("aria-pressed")).toBe("false");
});
it("can be controlled via pressed prop", () => {
render(
<Toggle data-testid="t" pressed>
on
</Toggle>,
);
expect(screen.getByTestId("t").getAttribute("aria-pressed")).toBe("true");
});
it("calls onPressedChange when clicked", async () => {
const onChange = vi.fn();
render(
<Toggle data-testid="t" onPressedChange={onChange}>
x
</Toggle>,
);
await userEvent.click(screen.getByTestId("t"));
expect(onChange).toHaveBeenCalledWith(true, expect.anything());
});
it.each([["default"], ["outline"]] as const)("renders variant=%s", (variant) => {
render(
<Toggle data-testid="t" variant={variant}>
x
</Toggle>,
);
expect(screen.getByTestId("t")).toBeInTheDocument();
});
it.each([["default"], ["sm"], ["lg"]] as const)("renders size=%s", (size) => {
render(
<Toggle data-testid="t" size={size}>
x
</Toggle>,
);
expect(screen.getByTestId("t")).toBeInTheDocument();
});
it("merges custom className", () => {
render(
<Toggle data-testid="t" className="my-custom">
x
</Toggle>,
);
expect(screen.getByTestId("t")).toHaveClass("my-custom");
});
});
@@ -0,0 +1,88 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
describe("Tooltip", () => {
it("trigger has data-slot='tooltip-trigger'", () => {
render(
<TooltipProvider>
<Tooltip>
<TooltipTrigger data-testid="trigger">Hover me</TooltipTrigger>
<TooltipContent>Helpful info</TooltipContent>
</Tooltip>
</TooltipProvider>,
);
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "tooltip-trigger");
});
it("does not show content by default", () => {
render(
<TooltipProvider>
<Tooltip>
<TooltipTrigger>Hover me</TooltipTrigger>
<TooltipContent>Helpful info</TooltipContent>
</Tooltip>
</TooltipProvider>,
);
expect(screen.queryByText("Helpful info")).not.toBeInTheDocument();
});
it("shows content when controlled open", () => {
render(
<TooltipProvider>
<Tooltip open>
<TooltipTrigger>Hover me</TooltipTrigger>
<TooltipContent>Helpful info</TooltipContent>
</Tooltip>
</TooltipProvider>,
);
expect(screen.getByText("Helpful info")).toBeInTheDocument();
});
it("content has data-slot='tooltip-content' when shown", () => {
render(
<TooltipProvider>
<Tooltip open>
<TooltipTrigger>Hover me</TooltipTrigger>
<TooltipContent>Helpful info</TooltipContent>
</Tooltip>
</TooltipProvider>,
);
expect(screen.getByText("Helpful info")).toHaveAttribute("data-slot", "tooltip-content");
});
});
describe("TooltipProvider", () => {
it("renders children without errors", () => {
render(
<TooltipProvider>
<div data-testid="child">child</div>
</TooltipProvider>,
);
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("accepts custom delay", () => {
render(
<TooltipProvider delay={500}>
<div>x</div>
</TooltipProvider>,
);
// No throw = pass
expect(true).toBe(true);
});
});
describe("TooltipContent", () => {
it("merges custom className when shown", () => {
render(
<TooltipProvider>
<Tooltip open>
<TooltipTrigger>x</TooltipTrigger>
<TooltipContent className="my-tooltip">visible</TooltipContent>
</Tooltip>
</TooltipProvider>,
);
expect(screen.getByText("visible")).toHaveClass("my-tooltip");
});
});
+154
View File
@@ -0,0 +1,154 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { ConfirmDialogProvider, useConfirm } from "./use-confirm";
const ConfirmRunner = ({
onResult,
title,
options,
}: {
onResult: (v: boolean) => void;
title: string;
options?: Parameters<ReturnType<typeof useConfirm>>[1];
}) => {
const confirm = useConfirm();
return (
<button
type="button"
onClick={async () => {
const result = await confirm(title, options);
onResult(result);
}}
>
Trigger
</button>
);
};
describe("useConfirm", () => {
it("throws when used outside ConfirmDialogProvider", () => {
// React 19 logs the error rather than throwing — we use renderHook+ErrorBoundary alt:
// Use the hook in a component and check that React surfaces the throw.
expect(() =>
render(
<ConfirmRunner
title="x"
onResult={() => {
/* noop */
}}
/>,
),
).toThrow(/useConfirm must be used within/);
});
it("renders children inside provider", () => {
render(
<ConfirmDialogProvider>
<div data-testid="child">child</div>
</ConfirmDialogProvider>,
);
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("opens an alert dialog when confirm is called", async () => {
const results: boolean[] = [];
render(
<ConfirmDialogProvider>
<ConfirmRunner title="Delete it?" onResult={(v) => results.push(v)} />
</ConfirmDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("Delete it?")).toBeInTheDocument();
});
it("resolves true when confirm is clicked", async () => {
const results: boolean[] = [];
render(
<ConfirmDialogProvider>
<ConfirmRunner title="Sure?" onResult={(v) => results.push(v)} />
</ConfirmDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
await screen.findByText("Sure?");
await userEvent.click(screen.getByText("Confirm"));
// Wait for promise to resolve
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(results).toEqual([true]);
});
it("resolves false when cancel is clicked", async () => {
const results: boolean[] = [];
render(
<ConfirmDialogProvider>
<ConfirmRunner title="Cancel?" onResult={(v) => results.push(v)} />
</ConfirmDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
await screen.findByText("Cancel?");
await userEvent.click(screen.getByText("Cancel"));
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(results).toEqual([false]);
});
it("renders custom confirmText and cancelText", async () => {
render(
<ConfirmDialogProvider>
<ConfirmRunner
title="x"
options={{ confirmText: "Yes!", cancelText: "Nope" }}
onResult={() => {
/* noop */
}}
/>
</ConfirmDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("Yes!")).toBeInTheDocument();
expect(screen.getByText("Nope")).toBeInTheDocument();
});
it("renders description when provided", async () => {
render(
<ConfirmDialogProvider>
<ConfirmRunner
title="x"
options={{ description: "This will permanently delete the record." }}
onResult={() => {
/* noop */
}}
/>
</ConfirmDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("This will permanently delete the record.")).toBeInTheDocument();
});
it("falls back to 'Confirm' / 'Cancel' default labels", async () => {
render(
<ConfirmDialogProvider>
<ConfirmRunner
title="x"
onResult={() => {
/* noop */
}}
/>
</ConfirmDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("Confirm")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
});
});
@@ -0,0 +1,78 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useControlledState } from "./use-controlled-state";
describe("useControlledState", () => {
it("returns the defaultValue when uncontrolled", () => {
const { result } = renderHook(() => useControlledState({ defaultValue: 0 }));
expect(result.current[0]).toBe(0);
});
it("returns the value when controlled", () => {
const { result } = renderHook(() => useControlledState({ value: 42, defaultValue: 0 }));
expect(result.current[0]).toBe(42);
});
it("updates internal state when value prop changes (controlled)", () => {
const { result, rerender } = renderHook(({ value }: { value: number }) => useControlledState({ value }), {
initialProps: { value: 1 },
});
expect(result.current[0]).toBe(1);
rerender({ value: 2 });
expect(result.current[0]).toBe(2);
});
it("updates state via setter when uncontrolled", () => {
const { result } = renderHook(() => useControlledState<number>({ defaultValue: 0 }));
act(() => {
result.current[1](10);
});
expect(result.current[0]).toBe(10);
});
it("calls onChange when state is updated", () => {
const onChange = vi.fn();
const { result } = renderHook(() => useControlledState<string>({ defaultValue: "a", onChange }));
act(() => {
result.current[1]("b");
});
expect(onChange).toHaveBeenCalledWith("b");
});
it("forwards extra args to onChange", () => {
const onChange = vi.fn();
const { result } = renderHook(() => useControlledState<string, [number, boolean]>({ defaultValue: "a", onChange }));
act(() => {
result.current[1]("b", 42, true);
});
expect(onChange).toHaveBeenCalledWith("b", 42, true);
});
it("does not call onChange when value prop changes from outside", () => {
const onChange = vi.fn();
const { rerender } = renderHook(({ value }: { value: number }) => useControlledState<number>({ value, onChange }), {
initialProps: { value: 1 },
});
rerender({ value: 2 });
// onChange is only called via setState, not the controlled-value sync effect.
expect(onChange).not.toHaveBeenCalled();
});
it("returns the same setter reference when onChange is stable", () => {
const onChange = vi.fn();
const { result, rerender } = renderHook(
({ value }: { value: number }) => useControlledState<number>({ value, onChange }),
{ initialProps: { value: 1 } },
);
const initialSetter = result.current[1];
rerender({ value: 2 });
expect(result.current[1]).toBe(initialSetter);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useIsMobile } from "./use-mobile";
type ChangeListener = (e: { matches: boolean }) => void;
describe("useIsMobile", () => {
let listeners: ChangeListener[];
let matches: boolean;
let originalMatchMedia: typeof window.matchMedia;
beforeEach(() => {
listeners = [];
matches = false;
originalMatchMedia = window.matchMedia;
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn((_event: string, listener: ChangeListener) => {
listeners.push(listener);
}),
removeEventListener: vi.fn((_event: string, listener: ChangeListener) => {
const idx = listeners.indexOf(listener);
if (idx >= 0) listeners.splice(idx, 1);
}),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
it("returns false when matchMedia.matches is false", () => {
matches = false;
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it("returns true when matchMedia.matches is true", () => {
matches = true;
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
it("uses the documented mobile breakpoint of 768px", () => {
renderHook(() => useIsMobile());
expect(window.matchMedia).toHaveBeenCalledWith("(max-width: 767px)");
});
it("updates when the media query changes", () => {
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
act(() => {
for (const listener of listeners) listener({ matches: true });
});
expect(result.current).toBe(true);
act(() => {
for (const listener of listeners) listener({ matches: false });
});
expect(result.current).toBe(false);
});
it("removes the change listener on unmount", () => {
const { unmount } = renderHook(() => useIsMobile());
expect(listeners).toHaveLength(1);
unmount();
expect(listeners).toHaveLength(0);
});
});
+177
View File
@@ -0,0 +1,177 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { PromptDialogProvider, usePrompt } from "./use-prompt";
const PromptRunner = ({
onResult,
title,
options,
}: {
onResult: (v: string | null) => void;
title: string;
options?: Parameters<ReturnType<typeof usePrompt>>[1];
}) => {
const prompt = usePrompt();
return (
<button
type="button"
onClick={async () => {
const result = await prompt(title, options);
onResult(result);
}}
>
Trigger
</button>
);
};
describe("usePrompt", () => {
it("throws when used outside PromptDialogProvider", () => {
expect(() =>
render(
<PromptRunner
title="x"
onResult={() => {
/* noop */
}}
/>,
),
).toThrow(/usePrompt must be used within/);
});
it("renders children inside provider", () => {
render(
<PromptDialogProvider>
<div data-testid="child">child</div>
</PromptDialogProvider>,
);
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("opens an alert dialog when prompt is called", async () => {
render(
<PromptDialogProvider>
<PromptRunner
title="Enter name"
onResult={() => {
/* noop */
}}
/>
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("Enter name")).toBeInTheDocument();
});
it("resolves with input value when confirm clicked", async () => {
const results: (string | null)[] = [];
render(
<PromptDialogProvider>
<PromptRunner title="Name?" onResult={(v) => results.push(v)} />
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
await screen.findByText("Name?");
const input = screen.getByRole("textbox");
await userEvent.type(input, "Alice");
await userEvent.click(screen.getByText("Confirm"));
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(results).toEqual(["Alice"]);
});
it("resolves null when cancel is clicked", async () => {
const results: (string | null)[] = [];
render(
<PromptDialogProvider>
<PromptRunner title="x" onResult={(v) => results.push(v)} />
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
await screen.findByText("x");
await userEvent.click(screen.getByText("Cancel"));
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(results).toEqual([null]);
});
it("uses defaultValue as initial input value", async () => {
render(
<PromptDialogProvider>
<PromptRunner
title="x"
options={{ defaultValue: "default-name" }}
onResult={() => {
/* noop */
}}
/>
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
const input = await screen.findByRole("textbox");
expect(input).toHaveValue("default-name");
});
it("submits on Enter key", async () => {
const results: (string | null)[] = [];
render(
<PromptDialogProvider>
<PromptRunner title="x" onResult={(v) => results.push(v)} />
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
const input = await screen.findByRole("textbox");
await userEvent.type(input, "via-enter{Enter}");
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(results).toEqual(["via-enter"]);
});
it("renders custom confirmText and cancelText", async () => {
render(
<PromptDialogProvider>
<PromptRunner
title="x"
options={{ confirmText: "Save", cancelText: "Discard" }}
onResult={() => {
/* noop */
}}
/>
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("Save")).toBeInTheDocument();
expect(screen.getByText("Discard")).toBeInTheDocument();
});
it("renders description when provided", async () => {
render(
<PromptDialogProvider>
<PromptRunner
title="x"
options={{ description: "Explain why" }}
onResult={() => {
/* noop */
}}
/>
</PromptDialogProvider>,
);
await userEvent.click(screen.getByText("Trigger"));
expect(await screen.findByText("Explain why")).toBeInTheDocument();
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { parseColorString, rgbaStringToHex } from "./color";
describe("rgbaStringToHex", () => {
it("converts opaque rgb to hex", () => {
expect(rgbaStringToHex("rgb(255, 0, 0)")).toBe("#ff0000");
});
it("converts opaque rgba to hex (alpha not represented)", () => {
expect(rgbaStringToHex("rgba(0, 255, 0, 1)")).toBe("#00ff00");
});
it("converts black", () => {
expect(rgbaStringToHex("rgb(0, 0, 0)")).toBe("#000000");
});
it("converts white", () => {
expect(rgbaStringToHex("rgb(255, 255, 255)")).toBe("#ffffff");
});
it("converts arbitrary mid-range color", () => {
expect(rgbaStringToHex("rgb(128, 64, 200)")).toBe("#8040c8");
});
});
describe("parseColorString", () => {
describe("rgb format", () => {
it("parses rgb without alpha as alpha=1", () => {
expect(parseColorString("rgb(255, 128, 64)")).toEqual({ r: 255, g: 128, b: 64, a: 1 });
});
it("parses rgba with alpha", () => {
expect(parseColorString("rgba(10, 20, 30, 0.5)")).toEqual({ r: 10, g: 20, b: 30, a: 0.5 });
});
it("parses rgba with alpha=0", () => {
expect(parseColorString("rgba(0, 0, 0, 0)")).toEqual({ r: 0, g: 0, b: 0, a: 0 });
});
it("handles extra whitespace in rgb", () => {
expect(parseColorString("rgb( 255 , 0 , 0 )")).toEqual({ r: 255, g: 0, b: 0, a: 1 });
});
it("trims surrounding whitespace", () => {
expect(parseColorString(" rgb(1, 2, 3) ")).toEqual({ r: 1, g: 2, b: 3, a: 1 });
});
it("returns null for malformed rgb", () => {
expect(parseColorString("rgb(255, 0)")).toBeNull();
expect(parseColorString("rgb(a, b, c)")).toBeNull();
});
});
describe("hex format", () => {
it("parses 6-digit hex", () => {
expect(parseColorString("#ff8040")).toEqual({ r: 255, g: 128, b: 64, a: 1 });
});
it("parses 6-digit hex uppercase", () => {
expect(parseColorString("#FF8040")).toEqual({ r: 255, g: 128, b: 64, a: 1 });
});
it("parses 3-digit hex by doubling each digit", () => {
expect(parseColorString("#f80")).toEqual({ r: 0xff, g: 0x88, b: 0x00, a: 1 });
});
it("parses #000 as black", () => {
expect(parseColorString("#000")).toEqual({ r: 0, g: 0, b: 0, a: 1 });
});
it("parses #fff as white", () => {
expect(parseColorString("#fff")).toEqual({ r: 255, g: 255, b: 255, a: 1 });
});
it("returns null for hex without #", () => {
expect(parseColorString("ff0000")).toBeNull();
});
it("returns null for invalid hex length", () => {
expect(parseColorString("#ff00")).toBeNull();
expect(parseColorString("#ff00ff00")).toBeNull();
});
it("returns null for hex with non-hex chars", () => {
expect(parseColorString("#zz0000")).toBeNull();
});
});
describe("invalid input", () => {
it("returns null for empty string", () => {
expect(parseColorString("")).toBeNull();
});
it("returns null for plain color names", () => {
expect(parseColorString("red")).toBeNull();
});
it("returns null for hsl format", () => {
expect(parseColorString("hsl(0, 100%, 50%)")).toBeNull();
});
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { formatDate, formatPeriod, formatSingleDate } from "./date";
describe("formatDate", () => {
it("formats YYYY-MM as 'Month Year'", () => {
expect(formatDate("2024-03")).toBe("March 2024");
});
it("formats YYYY-MM-DD as 'Month Year' by default (day excluded)", () => {
expect(formatDate("2024-03-15")).toBe("March 2024");
});
it("formats YYYY-MM-DD with includeDay=true as 'Month DD, Year'", () => {
expect(formatDate("2024-03-15", true)).toBe("March 15, 2024");
});
it("returns YYYY unchanged when only year provided", () => {
expect(formatDate("2024")).toBe("2024");
});
it("handles January (month 1)", () => {
expect(formatDate("2024-01")).toBe("January 2024");
});
it("handles December (month 12)", () => {
expect(formatDate("2024-12")).toBe("December 2024");
});
it("includes day even with single-digit days", () => {
expect(formatDate("2024-03-05", true)).toBe("March 05, 2024");
});
it("returns 'undefined' month name when out-of-range month is supplied", () => {
// Defensive: behavior documents what happens with bad input
expect(formatDate("2024-13")).toBe("undefined 2024");
});
});
describe("formatPeriod", () => {
it("returns empty string when both dates missing", () => {
expect(formatPeriod()).toBe("");
});
it("returns end date alone when start is missing", () => {
expect(formatPeriod(undefined, "2024-05")).toBe("2024-05");
});
it("returns start - Present when end is missing", () => {
expect(formatPeriod("2024-01")).toBe("January 2024 - Present");
});
it("formats both ends when both provided", () => {
expect(formatPeriod("2020-06", "2024-03")).toBe("June 2020 - March 2024");
});
it("treats empty string start same as undefined", () => {
expect(formatPeriod("", "2024-05")).toBe("2024-05");
});
it("treats empty string end same as undefined", () => {
expect(formatPeriod("2024-01", "")).toBe("January 2024 - Present");
});
it("returns empty when start empty and end empty", () => {
expect(formatPeriod("", "")).toBe("");
});
});
describe("formatSingleDate", () => {
it("returns empty string when date is undefined", () => {
expect(formatSingleDate()).toBe("");
});
it("returns empty string when date is empty string", () => {
expect(formatSingleDate("")).toBe("");
});
it("formats full date with day included", () => {
expect(formatSingleDate("2024-03-15")).toBe("March 15, 2024");
});
it("formats year-month even with includeDay flag implied", () => {
expect(formatSingleDate("2024-03")).toBe("March 2024");
});
it("returns plain year when only year present", () => {
expect(formatSingleDate("2024")).toBe("2024");
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { filterFieldValues } from "./field";
describe("filterFieldValues", () => {
it("returns map of fields whose values are non-empty", () => {
const fields = [{ key: "name" as const }, { key: "email" as const }];
const result = filterFieldValues({ name: "Alice", email: "alice@example.com" }, ...fields);
expect(result.size).toBe(2);
expect(result.get("name")).toEqual({ key: "name" });
expect(result.get("email")).toEqual({ key: "email" });
});
it("filters out fields with empty string values", () => {
const fields = [{ key: "name" as const }, { key: "email" as const }];
const result = filterFieldValues({ name: "Alice", email: "" }, ...fields);
expect(result.size).toBe(1);
expect(result.has("email")).toBe(false);
expect(result.has("name")).toBe(true);
});
it("filters out fields with whitespace-only values", () => {
const fields = [{ key: "name" as const }];
const result = filterFieldValues({ name: " " }, ...fields);
expect(result.size).toBe(0);
});
it("filters out fields with null values", () => {
const fields = [{ key: "name" as const }];
const result = filterFieldValues({ name: null }, ...fields);
expect(result.size).toBe(0);
});
it("filters out fields with undefined values", () => {
const fields = [{ key: "name" as const }];
const result = filterFieldValues({ name: undefined }, ...fields);
expect(result.size).toBe(0);
});
it("filters out fields with missing keys", () => {
const fields = [{ key: "name" as const }];
const result = filterFieldValues({}, ...fields);
expect(result.size).toBe(0);
});
it("preserves additional field properties on output", () => {
const fields = [{ key: "name" as const, label: "Name", icon: "person" }];
const result = filterFieldValues({ name: "Alice" }, ...fields);
expect(result.get("name")).toEqual({ key: "name", label: "Name", icon: "person" });
});
it("returns empty map when no fields supplied", () => {
const result = filterFieldValues({ name: "Alice" });
expect(result.size).toBe(0);
});
it("preserves field order in iteration", () => {
const fields = [{ key: "a" as const }, { key: "b" as const }, { key: "c" as const }];
const result = filterFieldValues({ a: "1", b: "2", c: "3" }, ...fields);
const keys = [...result.keys()];
expect(keys).toEqual(["a", "b", "c"]);
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "./file";
describe("generateFilename", () => {
it("slugifies the prefix without extension", () => {
expect(generateFilename("My Resume")).toBe("my-resume");
});
it("appends extension when provided", () => {
expect(generateFilename("My Resume", "pdf")).toBe("my-resume.pdf");
});
it("appends extension verbatim (no extra dot)", () => {
expect(generateFilename("Name", "json")).toBe("name.json");
});
it("handles empty extension as no extension", () => {
expect(generateFilename("foo", "")).toBe("foo");
});
it("strips diacritics", () => {
expect(generateFilename("Résumé", "pdf")).toBe("resume.pdf");
});
it("handles empty prefix", () => {
expect(generateFilename("", "pdf")).toBe(".pdf");
});
});
describe("downloadWithAnchor", () => {
let createObjectURLSpy: ReturnType<typeof vi.fn>;
let revokeObjectURLSpy: ReturnType<typeof vi.fn>;
let originalCreate: typeof URL.createObjectURL;
let originalRevoke: typeof URL.revokeObjectURL;
beforeEach(() => {
vi.useFakeTimers();
originalCreate = URL.createObjectURL;
originalRevoke = URL.revokeObjectURL;
createObjectURLSpy = vi.fn(() => "blob:mock-url");
revokeObjectURLSpy = vi.fn();
URL.createObjectURL = createObjectURLSpy;
URL.revokeObjectURL = revokeObjectURLSpy;
});
afterEach(() => {
vi.useRealTimers();
URL.createObjectURL = originalCreate;
URL.revokeObjectURL = originalRevoke;
});
it("creates an anchor with correct href, rel, and download attributes", () => {
const blob = new Blob(["hello"], { type: "text/plain" });
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
downloadWithAnchor(blob, "test.txt");
expect(createObjectURLSpy).toHaveBeenCalledWith(blob);
expect(clickSpy).toHaveBeenCalledOnce();
clickSpy.mockRestore();
});
it("removes the anchor from the DOM after click", () => {
const blob = new Blob(["hello"]);
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
const beforeChildCount = document.body.childElementCount;
downloadWithAnchor(blob, "test.txt");
const afterChildCount = document.body.childElementCount;
expect(afterChildCount).toBe(beforeChildCount);
});
it("revokes the object URL after 5 seconds", () => {
const blob = new Blob(["hello"]);
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
downloadWithAnchor(blob, "test.txt");
expect(revokeObjectURLSpy).not.toHaveBeenCalled();
vi.advanceTimersByTime(4999);
expect(revokeObjectURLSpy).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock-url");
});
});
describe("downloadFromUrl", () => {
let originalFetch: typeof global.fetch;
let createObjectURLSpy: ReturnType<typeof vi.fn>;
let revokeObjectURLSpy: ReturnType<typeof vi.fn>;
let originalCreate: typeof URL.createObjectURL;
let originalRevoke: typeof URL.revokeObjectURL;
beforeEach(() => {
originalFetch = global.fetch;
originalCreate = URL.createObjectURL;
originalRevoke = URL.revokeObjectURL;
createObjectURLSpy = vi.fn(() => "blob:mock-url");
revokeObjectURLSpy = vi.fn();
URL.createObjectURL = createObjectURLSpy;
URL.revokeObjectURL = revokeObjectURLSpy;
});
afterEach(() => {
global.fetch = originalFetch;
URL.createObjectURL = originalCreate;
URL.revokeObjectURL = originalRevoke;
});
it("fetches the URL and downloads the resulting blob", async () => {
const mockBlob = new Blob(["data"], { type: "application/pdf" });
global.fetch = vi.fn(() =>
Promise.resolve(new Response(mockBlob, { status: 200 })),
) as unknown as typeof global.fetch;
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
await downloadFromUrl("https://example.com/file.pdf", "file.pdf");
expect(global.fetch).toHaveBeenCalledWith("https://example.com/file.pdf");
expect(createObjectURLSpy).toHaveBeenCalled();
});
it("propagates fetch errors", async () => {
global.fetch = vi.fn(() => Promise.reject(new Error("network down"))) as unknown as typeof global.fetch;
await expect(downloadFromUrl("https://example.com", "x.pdf")).rejects.toThrow("network down");
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { arrayToHtmlList, toHtmlDescription } from "./html";
describe("toHtmlDescription", () => {
it("returns empty string when no inputs provided", () => {
expect(toHtmlDescription()).toBe("");
});
it("returns just <p> with summary when no highlights", () => {
expect(toHtmlDescription("Summary here")).toBe("<p>Summary here</p>");
});
it("returns just <ul> when only highlights", () => {
expect(toHtmlDescription(undefined, ["a", "b"])).toBe("<ul><li>a</li><li>b</li></ul>");
});
it("returns combined output with both summary and highlights", () => {
expect(toHtmlDescription("Sum", ["a"])).toBe("<p>Sum</p><ul><li>a</li></ul>");
});
it("omits ul when highlights is empty array", () => {
expect(toHtmlDescription("Sum", [])).toBe("<p>Sum</p>");
});
it("preserves order: summary first, list second", () => {
const result = toHtmlDescription("S", ["x", "y", "z"]);
expect(result.indexOf("<p>")).toBeLessThan(result.indexOf("<ul>"));
});
it("treats empty string summary as falsy", () => {
expect(toHtmlDescription("", ["a"])).toBe("<ul><li>a</li></ul>");
});
it("does not escape HTML in inputs (caller's responsibility)", () => {
// Document existing behavior: caller must sanitize before passing
expect(toHtmlDescription("<script>")).toBe("<p><script></p>");
});
});
describe("arrayToHtmlList", () => {
it("returns empty string for empty array", () => {
expect(arrayToHtmlList([])).toBe("");
});
it("renders single item list", () => {
expect(arrayToHtmlList(["one"])).toBe("<ul><li>one</li></ul>");
});
it("renders multi-item list", () => {
expect(arrayToHtmlList(["one", "two", "three"])).toBe("<ul><li>one</li><li>two</li><li>three</li></ul>");
});
it("preserves item order", () => {
const result = arrayToHtmlList(["c", "a", "b"]);
expect(result.indexOf("c")).toBeLessThan(result.indexOf("a"));
expect(result.indexOf("a")).toBeLessThan(result.indexOf("b"));
});
it("does not escape HTML in items", () => {
expect(arrayToHtmlList(["<b>bold</b>"])).toBe("<ul><li><b>bold</b></li></ul>");
});
});
+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { parseLevel } from "./level";
describe("parseLevel", () => {
describe("invalid input", () => {
it("returns 0 for undefined", () => {
expect(parseLevel()).toBe(0);
});
it("returns 0 for empty string", () => {
expect(parseLevel("")).toBe(0);
});
it("returns 0 for unrecognized text", () => {
expect(parseLevel("hello world")).toBe(0);
});
});
describe("numeric values", () => {
it("returns 0 for '0'", () => {
expect(parseLevel("0")).toBe(0);
});
it("returns 5 for '5'", () => {
expect(parseLevel("5")).toBe(5);
});
it("returns 3 for '3'", () => {
expect(parseLevel("3")).toBe(3);
});
it("falls through to text matching for out-of-range numerics", () => {
// 6 is out of range so the numeric branch fails, no text matches → 0
expect(parseLevel("6")).toBe(0);
});
it("falls through for negative numerics", () => {
expect(parseLevel("-1")).toBe(0);
});
it("parses leading-numeric strings via parseInt", () => {
// "3 stars" → parseInt yields 3, in [0,5] → returns 3
expect(parseLevel("3 stars")).toBe(3);
});
});
describe("text levels", () => {
it("returns 5 for 'native'", () => {
expect(parseLevel("native")).toBe(5);
});
it("returns 5 for 'expert'", () => {
expect(parseLevel("Expert")).toBe(5);
});
it("returns 5 for 'master'", () => {
expect(parseLevel("master")).toBe(5);
});
it("returns 4 for 'fluent'", () => {
expect(parseLevel("fluent")).toBe(4);
});
it("returns 4 for 'advanced'", () => {
expect(parseLevel("Advanced")).toBe(4);
});
it("returns 4 for 'proficient'", () => {
expect(parseLevel("proficient")).toBe(4);
});
it("returns 3 for 'intermediate'", () => {
expect(parseLevel("intermediate")).toBe(3);
});
it("returns 3 for 'conversational'", () => {
expect(parseLevel("conversational")).toBe(3);
});
it("returns 2 for 'beginner'", () => {
expect(parseLevel("beginner")).toBe(2);
});
it("returns 2 for 'basic'", () => {
expect(parseLevel("basic")).toBe(2);
});
it("returns 2 for 'elementary'", () => {
expect(parseLevel("Elementary")).toBe(2);
});
it("returns 1 for 'novice'", () => {
expect(parseLevel("novice")).toBe(1);
});
});
describe("CEFR levels", () => {
it("returns 5 for C2", () => {
expect(parseLevel("C2")).toBe(5);
});
it("returns 4 for C1", () => {
expect(parseLevel("c1")).toBe(4);
});
it("returns 3 for B2", () => {
expect(parseLevel("B2")).toBe(3);
});
it("returns 2 for B1", () => {
expect(parseLevel("b1")).toBe(2);
});
it("returns 1 for A2", () => {
expect(parseLevel("A2")).toBe(1);
});
it("returns 1 for A1", () => {
expect(parseLevel("a1")).toBe(1);
});
it("matches CEFR levels embedded in surrounding text", () => {
expect(parseLevel("Level: B2")).toBe(3);
});
});
describe("priority of matchers", () => {
it("expert beats CEFR when both substrings appear", () => {
// "expert" matches first and short-circuits before CEFR check
expect(parseLevel("expert C2")).toBe(5);
});
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { defaultLocale, isLocale } from "./locale";
describe("defaultLocale", () => {
it("is en-US", () => {
expect(defaultLocale).toBe("en-US");
});
});
describe("isLocale", () => {
it("returns true for non-empty string", () => {
expect(isLocale("en-US")).toBe(true);
});
it("returns true for any non-empty string (no validation of locale shape)", () => {
expect(isLocale("xyz")).toBe(true);
});
it("returns false for empty string", () => {
expect(isLocale("")).toBe(false);
});
it("returns false for number", () => {
expect(isLocale(42)).toBe(false);
});
it("returns false for null", () => {
expect(isLocale(null)).toBe(false);
});
it("returns false for undefined", () => {
expect(isLocale(undefined)).toBe(false);
});
it("returns false for object", () => {
expect(isLocale({})).toBe(false);
});
it("returns false for array", () => {
expect(isLocale([])).toBe(false);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { findWorkspaceRoot, getLocalDataDirectory } from "./monorepo.node";
describe("findWorkspaceRoot", () => {
let tempDir: string;
beforeEach(() => {
// Resolve symlinks so comparisons match findWorkspaceRoot's realpathSync output.
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "rr-monorepo-test-")));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("returns the directory containing pnpm-workspace.yaml", () => {
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
expect(findWorkspaceRoot(tempDir)).toBe(tempDir);
});
it("walks up directories to find pnpm-workspace.yaml", () => {
const nested = join(tempDir, "apps", "web");
mkdirSync(nested, { recursive: true });
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
expect(findWorkspaceRoot(nested)).toBe(tempDir);
});
it("returns null if no workspace manifest is found", () => {
// Use a temp dir that has no pnpm-workspace.yaml above it. Any descendant of /tmp
// either has the file or eventually hits /. We use a deep-enough sibling here:
const isolated = join(tempDir, "isolated");
mkdirSync(isolated, { recursive: true });
// Walk up from isolated; nothing in tempDir has a workspace manifest.
// findWorkspaceRoot will return null when it reaches the filesystem root.
// Verify behavior: at minimum, it should not return our tempDir.
const result = findWorkspaceRoot(isolated);
expect(result).not.toBe(tempDir);
});
});
describe("getLocalDataDirectory", () => {
let tempDir: string;
beforeEach(() => {
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "rr-data-test-")));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("returns override when provided", () => {
expect(getLocalDataDirectory("/custom/path")).toBe("/custom/path");
});
it("returns workspaceRoot/data when manifest is found", () => {
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
expect(getLocalDataDirectory(undefined, tempDir)).toBe(join(tempDir, "data"));
});
it("falls back to cwd/data when no manifest found", () => {
const isolated = join(tempDir, "isolated");
mkdirSync(isolated, { recursive: true });
const result = getLocalDataDirectory(undefined, isolated);
// Should end with /data and not be the manifest workspace path
expect(result.endsWith("data")).toBe(true);
});
it("override takes precedence even if workspace exists", () => {
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
expect(getLocalDataDirectory("/elsewhere", tempDir)).toBe("/elsewhere");
});
it("manifest file actually exists for the test setup", () => {
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
expect(existsSync(join(tempDir, "pnpm-workspace.yaml"))).toBe(true);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { getNetworkIcon } from "./network-icons";
describe("getNetworkIcon", () => {
it("returns 'star' default when network is undefined", () => {
expect(getNetworkIcon()).toBe("star");
});
it("returns 'star' default when network is empty string", () => {
expect(getNetworkIcon("")).toBe("star");
});
it("returns 'star' default when no match found", () => {
expect(getNetworkIcon("unknown-network")).toBe("star");
});
it("matches case-insensitively", () => {
expect(getNetworkIcon("GitHub")).toBe("github-logo");
expect(getNetworkIcon("LINKEDIN")).toBe("linkedin-logo");
});
it("matches GitHub", () => {
expect(getNetworkIcon("github")).toBe("github-logo");
});
it("matches LinkedIn", () => {
expect(getNetworkIcon("linkedin")).toBe("linkedin-logo");
});
it("matches Twitter and X variants", () => {
expect(getNetworkIcon("twitter")).toBe("twitter-logo");
// "x" alone matches, including in unrelated words
expect(getNetworkIcon("x")).toBe("twitter-logo");
expect(getNetworkIcon("x.com")).toBe("twitter-logo");
});
it("matches Facebook", () => {
expect(getNetworkIcon("facebook")).toBe("facebook-logo");
});
it("matches Instagram", () => {
expect(getNetworkIcon("instagram")).toBe("instagram-logo");
});
it("matches YouTube", () => {
expect(getNetworkIcon("youtube")).toBe("youtube-logo");
});
it("matches Stack Overflow variants", () => {
expect(getNetworkIcon("stackoverflow")).toBe("stack-overflow-logo");
expect(getNetworkIcon("stack-overflow")).toBe("stack-overflow-logo");
});
it("matches Medium", () => {
expect(getNetworkIcon("medium")).toBe("medium-logo");
});
it("matches Dev.to variants to 'code'", () => {
expect(getNetworkIcon("dev.to")).toBe("code");
expect(getNetworkIcon("devto")).toBe("code");
});
it("matches Dribbble", () => {
expect(getNetworkIcon("dribbble")).toBe("dribbble-logo");
});
it("matches Behance", () => {
expect(getNetworkIcon("behance")).toBe("behance-logo");
});
it("matches GitLab to 'git-branch'", () => {
expect(getNetworkIcon("gitlab")).toBe("git-branch");
});
it("matches Bitbucket and CodePen to 'code'", () => {
expect(getNetworkIcon("bitbucket")).toBe("code");
expect(getNetworkIcon("codepen")).toBe("code");
});
it("matches when keyword is substring of input", () => {
expect(getNetworkIcon("My GitHub Profile")).toBe("github-logo");
});
it("returns first matching icon (priority order)", () => {
// "github" comes before "x" in the map, so github wins
// (and this is also how it should behave for ambiguous strings)
expect(getNetworkIcon("xgithub")).toBe("github-logo");
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { rateLimitConfig, TRUSTED_IP_HEADERS } from "./rate-limit";
describe("TRUSTED_IP_HEADERS", () => {
it("includes Cloudflare connecting headers", () => {
expect(TRUSTED_IP_HEADERS).toContain("CF-Connecting-IP");
expect(TRUSTED_IP_HEADERS).toContain("CF-Connecting-IPv6");
});
it("includes True-Client-IP", () => {
expect(TRUSTED_IP_HEADERS).toContain("True-Client-IP");
});
it("includes X-Forwarded-For and X-Real-IP", () => {
expect(TRUSTED_IP_HEADERS).toContain("X-Forwarded-For");
expect(TRUSTED_IP_HEADERS).toContain("X-Real-IP");
});
it("contains exactly the documented set of trusted headers", () => {
expect(TRUSTED_IP_HEADERS).toHaveLength(5);
});
});
describe("rateLimitConfig", () => {
describe("betterAuth.global", () => {
it("is enabled with sensible defaults", () => {
expect(rateLimitConfig.betterAuth.global.enabled).toBe(true);
expect(rateLimitConfig.betterAuth.global.window).toBe(60);
expect(rateLimitConfig.betterAuth.global.max).toBe(60);
});
it("rate-limits sign-in/email more strictly than global default", () => {
expect(rateLimitConfig.betterAuth.global.customRules["/sign-in/email"]).toEqual({ window: 60, max: 5 });
});
it("rate-limits sign-up/email even more strictly", () => {
expect(rateLimitConfig.betterAuth.global.customRules["/sign-up/email"]).toEqual({ window: 60, max: 3 });
});
it("uses long window for password reset to deter abuse", () => {
expect(rateLimitConfig.betterAuth.global.customRules["/request-password-reset"]).toEqual({
window: 600,
max: 3,
});
});
it("rate-limits 2FA verification with longer windows", () => {
expect(rateLimitConfig.betterAuth.global.customRules["/two-factor/verify-otp"].window).toBe(600);
expect(rateLimitConfig.betterAuth.global.customRules["/two-factor/verify-totp"].window).toBe(600);
expect(rateLimitConfig.betterAuth.global.customRules["/two-factor/verify-backup-code"].window).toBe(600);
});
it("allows generous polling for username availability", () => {
expect(rateLimitConfig.betterAuth.global.customRules["/is-username-available"]).toEqual({
window: 60,
max: 20,
});
});
});
describe("betterAuth.oauthProvider", () => {
it("rate-limits register most strictly", () => {
expect(rateLimitConfig.betterAuth.oauthProvider.register.max).toBe(5);
});
it("allows higher introspect/userinfo throughput", () => {
expect(rateLimitConfig.betterAuth.oauthProvider.introspect.max).toBe(60);
expect(rateLimitConfig.betterAuth.oauthProvider.userinfo.max).toBe(60);
});
});
describe("betterAuth.apiKey", () => {
it("is enabled with hourly window of 1000 requests", () => {
expect(rateLimitConfig.betterAuth.apiKey.enabled).toBe(true);
expect(rateLimitConfig.betterAuth.apiKey.timeWindow).toBe(60 * 60 * 1000);
expect(rateLimitConfig.betterAuth.apiKey.maxRequests).toBe(1000);
});
});
describe("orpc", () => {
it("limits resume password reset to 5 per 10 minutes", () => {
expect(rateLimitConfig.orpc.resumePassword).toEqual({ maxRequests: 5, window: 10 * 60 * 1000 });
});
it("limits PDF export to 5 per minute", () => {
expect(rateLimitConfig.orpc.pdfExport).toEqual({ maxRequests: 5, window: 60 * 1000 });
});
it("limits AI requests to 20 per minute", () => {
expect(rateLimitConfig.orpc.aiRequest).toEqual({ maxRequests: 20, window: 60 * 1000 });
});
it("provides reasonable mutation throughput for resume edits", () => {
expect(rateLimitConfig.orpc.resumeMutations).toEqual({ maxRequests: 60, window: 60 * 1000 });
});
it("limits storage uploads more strictly than deletes", () => {
expect(rateLimitConfig.orpc.storageUpload.maxRequests).toBeLessThan(
rateLimitConfig.orpc.storageDelete.maxRequests,
);
});
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { toSafeDocxLink } from "./link-utils";
describe("toSafeDocxLink", () => {
it("returns null for empty string", () => {
expect(toSafeDocxLink("")).toBeNull();
});
it("returns null for whitespace-only string", () => {
expect(toSafeDocxLink(" ")).toBeNull();
});
it("trims surrounding whitespace", () => {
expect(toSafeDocxLink(" https://example.com ")).toBe("https://example.com/");
});
it("accepts https URLs", () => {
expect(toSafeDocxLink("https://example.com")).toBe("https://example.com/");
});
it("accepts http URLs", () => {
expect(toSafeDocxLink("http://example.com")).toBe("http://example.com/");
});
it("preserves path and query", () => {
expect(toSafeDocxLink("https://example.com/p?a=1")).toBe("https://example.com/p?a=1");
});
it("returns mailto: for valid email", () => {
expect(toSafeDocxLink("mailto:user@example.com")).toBe("mailto:user@example.com");
});
it("returns null for empty mailto:", () => {
expect(toSafeDocxLink("mailto:")).toBeNull();
expect(toSafeDocxLink("mailto: ")).toBeNull();
});
it("trims email body within mailto:", () => {
expect(toSafeDocxLink("mailto: user@example.com ")).toBe("mailto:user@example.com");
});
it("rejects javascript: protocol", () => {
expect(toSafeDocxLink("javascript:alert(1)")).toBeNull();
});
it("rejects data: protocol", () => {
expect(toSafeDocxLink("data:text/html,<script>alert(1)</script>")).toBeNull();
});
it("rejects file: protocol", () => {
expect(toSafeDocxLink("file:///etc/passwd")).toBeNull();
});
it("rejects malformed URLs", () => {
expect(toSafeDocxLink("not a url at all")).toBeNull();
});
it("rejects ftp: protocol", () => {
expect(toSafeDocxLink("ftp://example.com")).toBeNull();
});
});
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { applyResumePatches, createResumePatches, jsonPatchOperationSchema, ResumePatchError } from "./patch";
describe("jsonPatchOperationSchema", () => {
it("validates add op", () => {
const result = jsonPatchOperationSchema.safeParse({ op: "add", path: "/foo", value: 1 });
expect(result.success).toBe(true);
});
it("requires value for add", () => {
const result = jsonPatchOperationSchema.safeParse({ op: "add", path: "/foo" });
expect(result.success).toBe(false);
});
it("validates remove op without value", () => {
const result = jsonPatchOperationSchema.safeParse({ op: "remove", path: "/foo" });
expect(result.success).toBe(true);
});
it("validates replace op", () => {
const result = jsonPatchOperationSchema.safeParse({ op: "replace", path: "/foo", value: 2 });
expect(result.success).toBe(true);
});
it("validates move op with from", () => {
const result = jsonPatchOperationSchema.safeParse({ op: "move", path: "/a", from: "/b" });
expect(result.success).toBe(true);
});
it("requires from for move", () => {
const result = jsonPatchOperationSchema.safeParse({ op: "move", path: "/a" });
expect(result.success).toBe(false);
});
it("validates copy op with from", () => {
const result = jsonPatchOperationSchema.safeParse({ op: "copy", path: "/a", from: "/b" });
expect(result.success).toBe(true);
});
it("validates test op with value", () => {
const result = jsonPatchOperationSchema.safeParse({ op: "test", path: "/a", value: 1 });
expect(result.success).toBe(true);
});
it("rejects unknown op", () => {
const result = jsonPatchOperationSchema.safeParse({ op: "swap", path: "/a", value: 1 });
expect(result.success).toBe(false);
});
it("permits any value type for add (unknown)", () => {
const result = jsonPatchOperationSchema.safeParse({ op: "add", path: "/x", value: { nested: { array: [1] } } });
expect(result.success).toBe(true);
});
});
describe("createResumePatches", () => {
it("returns empty array when documents are equal", () => {
const patches = createResumePatches(defaultResumeData, defaultResumeData);
expect(patches).toEqual([]);
});
it("emits a replace op when a scalar field changes", () => {
const next = { ...defaultResumeData, basics: { ...defaultResumeData.basics, name: "Alice" } };
const patches = createResumePatches(defaultResumeData, next);
expect(patches.some((p) => p.op === "replace" && p.path === "/basics/name")).toBe(true);
});
it("captures multiple changes in distinct operations", () => {
const next = {
...defaultResumeData,
basics: { ...defaultResumeData.basics, name: "Alice", email: "alice@example.com" },
};
const patches = createResumePatches(defaultResumeData, next);
expect(patches.length).toBeGreaterThanOrEqual(2);
});
});
describe("applyResumePatches", () => {
it("applies a single replace op", () => {
const result = applyResumePatches(defaultResumeData, [{ op: "replace", path: "/basics/name", value: "Alice" }]);
expect(result.basics.name).toBe("Alice");
// Original is preserved (not mutated)
expect(defaultResumeData.basics.name).toBe("");
});
it("does not mutate the input data", () => {
const before = JSON.stringify(defaultResumeData);
applyResumePatches(defaultResumeData, [{ op: "replace", path: "/basics/name", value: "Bob" }]);
expect(JSON.stringify(defaultResumeData)).toBe(before);
});
it("applies multiple ops in sequence", () => {
const result = applyResumePatches(defaultResumeData, [
{ op: "replace", path: "/basics/name", value: "Alice" },
{ op: "replace", path: "/basics/email", value: "alice@example.com" },
]);
expect(result.basics.name).toBe("Alice");
expect(result.basics.email).toBe("alice@example.com");
});
it("throws ResumePatchError for unresolvable path", () => {
expect(() =>
applyResumePatches(defaultResumeData, [{ op: "replace", path: "/does/not/exist", value: "x" }]),
).toThrow(ResumePatchError);
});
it("throws ResumePatchError for failed test op", () => {
try {
applyResumePatches(defaultResumeData, [{ op: "test", path: "/basics/name", value: "wrong-value" }]);
expect.unreachable();
} catch (error) {
expect(error).toBeInstanceOf(ResumePatchError);
const patchError = error as ResumePatchError;
expect(patchError.code).toBe("TEST_OPERATION_FAILED");
expect(patchError.index).toBe(0);
expect(patchError.message).toContain("Test operation failed");
}
});
it("includes the failing operation in the error", () => {
try {
applyResumePatches(defaultResumeData, [{ op: "test", path: "/basics/name", value: "wrong" }]);
expect.unreachable();
} catch (error) {
expect((error as ResumePatchError).operation).toEqual({
op: "test",
path: "/basics/name",
value: "wrong",
});
}
});
it("rolls back if patched document fails schema validation", () => {
// Setting picture.size to a value outside schema bounds (min 32, max 512)
expect(() =>
applyResumePatches(defaultResumeData, [{ op: "replace", path: "/picture/size", value: 9999 }]),
).toThrow(/Patch produced invalid resume data/);
});
it("supports adding to arrays", () => {
const result = applyResumePatches(defaultResumeData, [
{
op: "add",
path: "/sections/skills/items/-",
value: {
id: "abcdef0123456789",
hidden: false,
icon: "",
iconColor: "",
name: "TypeScript",
proficiency: "Advanced",
level: 4,
keywords: [],
},
},
]);
expect(result.sections.skills.items).toHaveLength(1);
expect(result.sections.skills.items[0]?.name).toBe("TypeScript");
});
it("supports remove operation on existing path", () => {
// First add an item, then remove it
const withItem = applyResumePatches(defaultResumeData, [
{
op: "add",
path: "/sections/skills/items/-",
value: {
id: "abcdef0123456789",
hidden: false,
icon: "",
iconColor: "",
name: "Go",
proficiency: "Advanced",
level: 4,
keywords: [],
},
},
]);
const removed = applyResumePatches(withItem, [{ op: "remove", path: "/sections/skills/items/0" }]);
expect(removed.sections.skills.items).toHaveLength(0);
});
});
describe("ResumePatchError", () => {
it("captures all expected properties", () => {
const error = new ResumePatchError("CODE", "msg", 2, { op: "test", path: "/x", value: 1 });
expect(error.name).toBe("ResumePatchError");
expect(error.code).toBe("CODE");
expect(error.message).toBe("msg");
expect(error.index).toBe(2);
expect(error.operation).toEqual({ op: "test", path: "/x", value: 1 });
});
it("is instanceof Error", () => {
const error = new ResumePatchError("X", "y", 0, { op: "remove", path: "/x" });
expect(error).toBeInstanceOf(Error);
});
});
+168
View File
@@ -0,0 +1,168 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it } from "vitest";
import { isObject, sanitizeCss, sanitizeHtml } from "./sanitize";
describe("sanitizeHtml", () => {
it("returns empty string for empty input", () => {
expect(sanitizeHtml("")).toBe("");
});
it("preserves allowed tags", () => {
const result = sanitizeHtml("<p>Hello <strong>world</strong></p>");
expect(result).toContain("<p>");
expect(result).toContain("<strong>");
expect(result).toContain("Hello");
expect(result).toContain("world");
});
it("strips disallowed tags like <script>", () => {
const result = sanitizeHtml("<p>Hello</p><script>alert(1)</script>");
expect(result).not.toContain("<script>");
expect(result).not.toContain("alert(1)");
expect(result).toContain("<p>Hello</p>");
});
it("strips disallowed attributes like onerror", () => {
const result = sanitizeHtml('<img src="x" onerror="alert(1)" />');
expect(result).not.toContain("onerror");
});
it("strips iframe and object tags", () => {
expect(sanitizeHtml("<iframe src='evil'></iframe>")).not.toContain("iframe");
expect(sanitizeHtml("<object data='evil'></object>")).not.toContain("<object");
});
it("preserves links with safe href", () => {
const result = sanitizeHtml('<a href="https://example.com">Click</a>');
expect(result).toContain("https://example.com");
});
it("blocks javascript: URLs in href", () => {
const result = sanitizeHtml('<a href="javascript:alert(1)">x</a>');
expect(result).not.toContain("javascript:");
});
it("preserves table structure tags", () => {
const result = sanitizeHtml("<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody></table>");
expect(result).toContain("<table>");
expect(result).toContain("<thead>");
expect(result).toContain("<tbody>");
});
it("preserves list tags", () => {
const result = sanitizeHtml("<ul><li>one</li><li>two</li></ul>");
expect(result).toContain("<ul>");
expect(result).toContain("<li>");
});
it("preserves headings", () => {
const result = sanitizeHtml("<h1>Title</h1><h2>Sub</h2>");
expect(result).toContain("<h1>");
expect(result).toContain("<h2>");
});
});
describe("sanitizeCss", () => {
it("returns empty string for empty input", () => {
expect(sanitizeCss("")).toBe("");
});
it("strips CSS comments", () => {
const result = sanitizeCss("/* malicious */ body { color: red; }");
expect(result).not.toContain("/*");
expect(result).not.toContain("malicious");
});
it("decodes hex CSS escapes before sanitizing", () => {
// \\6a\\61... encodes "javascript" — the decoder should expose this and the javascript: pattern then strips
const css = "a { background: \\6a avascript:alert(1) }";
const result = sanitizeCss(css);
expect(result.toLowerCase()).not.toContain("javascript:");
});
it("strips javascript: protocol", () => {
const result = sanitizeCss("a { background: javascript:alert(1) }");
expect(result.toLowerCase()).not.toContain("javascript:");
});
it("strips expression() syntax", () => {
const result = sanitizeCss("a { width: expression(alert(1)) }");
expect(result.toLowerCase()).not.toContain("expression(");
});
it("strips behavior: declarations", () => {
const result = sanitizeCss("a { behavior: url(#default#VML); }");
expect(result.toLowerCase()).not.toContain("behavior:");
});
it("strips -moz-binding declarations", () => {
const result = sanitizeCss("a { -moz-binding: url(evil.xml#xss); }");
expect(result.toLowerCase()).not.toContain("-moz-binding");
});
it("strips @import directives", () => {
const result = sanitizeCss("@import url('evil.css'); body { color: red; }");
expect(result).not.toContain("@import");
});
it("strips @font-face blocks", () => {
const result = sanitizeCss("@font-face { font-family: x; src: url(evil); } body {}");
expect(result).not.toContain("@font-face");
});
it("strips url() values", () => {
const result = sanitizeCss("a { background: url(evil.png); }");
expect(result).not.toContain("url(");
});
it("strips standalone src: declarations", () => {
const result = sanitizeCss("src: url(x.woff);");
expect(result).not.toContain("src:");
});
it("preserves benign CSS", () => {
const result = sanitizeCss("body { color: red; font-size: 14px; }");
expect(result).toContain("color: red");
expect(result).toContain("font-size: 14px");
});
});
describe("isObject", () => {
it("returns true for plain objects", () => {
expect(isObject({})).toBe(true);
expect(isObject({ a: 1 })).toBe(true);
});
it("returns false for arrays", () => {
expect(isObject([])).toBe(false);
expect(isObject([1, 2])).toBe(false);
});
it("returns false for null", () => {
expect(isObject(null)).toBe(false);
});
it("returns false for undefined", () => {
expect(isObject(undefined)).toBe(false);
});
it("returns false for primitives", () => {
expect(isObject(0)).toBe(false);
expect(isObject("string")).toBe(false);
expect(isObject(true)).toBe(false);
});
it("returns true for class instances (objects)", () => {
expect(isObject(new Date())).toBe(true);
});
it("narrows the type correctly", () => {
const value: unknown = { foo: "bar" };
if (isObject(value)) {
// should compile-check: value has Record<string, unknown>
expect(typeof value.foo).toBe("string");
}
});
});
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import { generateId, generateRandomName, getInitials, slugify, stripHtml, toUsername } from "./string";
describe("generateId", () => {
it("returns a UUIDv7 string", () => {
const id = generateId();
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
it("returns unique values across invocations", () => {
const ids = new Set(Array.from({ length: 50 }, () => generateId()));
expect(ids.size).toBe(50);
});
it("returns ids that sort lexicographically by creation time", async () => {
const first = generateId();
await new Promise((resolve) => setTimeout(resolve, 5));
const second = generateId();
expect(second.localeCompare(first)).toBeGreaterThan(0);
});
});
describe("slugify", () => {
it("lowercases and replaces spaces with hyphens", () => {
expect(slugify("Hello World")).toBe("hello-world");
});
it("preserves camelCase due to decamelize: false", () => {
expect(slugify("HelloWorld")).toBe("helloworld");
});
it("strips diacritics and accents", () => {
expect(slugify("Café")).toBe("cafe");
expect(slugify("naïve résumé")).toBe("naive-resume");
});
it("removes punctuation", () => {
expect(slugify("Hello, World!")).toBe("hello-world");
expect(slugify("a/b\\c.d")).toBe("a-b-c-d");
});
it("collapses repeated separators", () => {
expect(slugify("a b---c")).toBe("a-b-c");
});
it("returns empty string for empty input", () => {
expect(slugify("")).toBe("");
});
it("handles unicode emojis by stripping them", () => {
expect(slugify("Hello 🌍 World")).toBe("hello-world");
});
});
describe("getInitials", () => {
it("returns first letters of two-word names uppercased", () => {
expect(getInitials("John Doe")).toBe("JD");
});
it("uses only first two parts for longer names", () => {
expect(getInitials("John Michael Doe")).toBe("JM");
});
it("returns single uppercase initial for single word", () => {
expect(getInitials("John")).toBe("J");
});
it("uppercases lowercase input", () => {
expect(getInitials("jane smith")).toBe("JS");
});
it("handles empty string by returning empty string", () => {
expect(getInitials("")).toBe("");
});
});
describe("toUsername", () => {
it("lowercases and trims", () => {
expect(toUsername(" JohnDoe ")).toBe("johndoe");
});
it("removes characters outside [a-z0-9._-]", () => {
expect(toUsername("john!@#doe$%^")).toBe("johndoe");
});
it("preserves dots, underscores, and hyphens", () => {
expect(toUsername("john.doe_test-123")).toBe("john.doe_test-123");
});
it("truncates to 64 characters max", () => {
const longName = "a".repeat(100);
expect(toUsername(longName)).toHaveLength(64);
});
it("handles unicode characters by stripping them", () => {
expect(toUsername("Café")).toBe("caf");
expect(toUsername("日本語")).toBe("");
});
it("returns empty string for whitespace-only input", () => {
expect(toUsername(" ")).toBe("");
});
});
describe("generateRandomName", () => {
it("returns a string with three capitalized words", () => {
const name = generateRandomName();
const words = name.split(" ");
expect(words).toHaveLength(3);
for (const word of words) {
expect(word[0]).toBe(word[0]?.toUpperCase());
}
});
it("produces varying values", () => {
const names = new Set(Array.from({ length: 20 }, () => generateRandomName()));
// Allow rare collisions but expect variety from a 20-sample pool
expect(names.size).toBeGreaterThan(5);
});
});
describe("stripHtml", () => {
it("removes simple tags and trims", () => {
expect(stripHtml("<p>Hello</p>")).toBe("Hello");
});
it("removes nested tags and preserves text", () => {
expect(stripHtml("<div><span>Hello</span> <strong>World</strong></div>")).toBe("Hello World");
});
it("returns empty string for undefined input", () => {
expect(stripHtml(undefined)).toBe("");
});
it("returns empty string for empty input", () => {
expect(stripHtml("")).toBe("");
});
it("handles self-closing tags", () => {
expect(stripHtml("Line one<br/>Line two")).toBe("Line oneLine two");
});
it("strips attributes within tags", () => {
expect(stripHtml('<a href="https://example.com" title="link">Click</a>')).toBe("Click");
});
it("preserves text containing angle brackets when not malformed tags", () => {
// Greater-than after a closed tag stays
expect(stripHtml("<p>5 > 3</p>")).toBe("5 > 3");
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { cn } from "./style";
describe("cn", () => {
it("joins simple class strings", () => {
expect(cn("a", "b", "c")).toBe("a b c");
});
it("filters out falsy values", () => {
expect(cn("a", null, undefined, false, "b", 0)).toBe("a b");
});
it("conditionally applies classes via object syntax", () => {
expect(cn({ a: true, b: false, c: true })).toBe("a c");
});
it("flattens arrays of inputs", () => {
expect(cn(["a", "b"], ["c"])).toBe("a b c");
});
it("merges conflicting tailwind classes (later wins)", () => {
expect(cn("p-4", "p-2")).toBe("p-2");
expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500");
});
it("preserves non-conflicting tailwind classes", () => {
expect(cn("text-red-500", "bg-white")).toContain("text-red-500");
expect(cn("text-red-500", "bg-white")).toContain("bg-white");
});
it("handles responsive variants distinctly from base classes", () => {
expect(cn("p-4", "md:p-2")).toBe("p-4 md:p-2");
});
it("returns empty string for no input", () => {
expect(cn()).toBe("");
});
it("returns empty string when all inputs are falsy", () => {
expect(cn(null, undefined, false)).toBe("");
});
it("merges twMerge group conflicts (e.g., flex-row vs flex-col)", () => {
expect(cn("flex-row", "flex-col")).toBe("flex-col");
});
});
@@ -0,0 +1,239 @@
import { describe, expect, it } from "vitest";
import {
isAllowedExternalUrl,
isAllowedOAuthRedirectUri,
isPrivateOrLoopbackHost,
parseAllowedHostList,
parseUrl,
} from "./url-security.node";
describe("isPrivateOrLoopbackHost", () => {
describe("loopback hostnames", () => {
it("matches localhost", () => {
expect(isPrivateOrLoopbackHost("localhost")).toBe(true);
});
it("matches LOCALHOST (case-insensitive)", () => {
expect(isPrivateOrLoopbackHost("LOCALHOST")).toBe(true);
});
it("matches subdomains of localhost", () => {
expect(isPrivateOrLoopbackHost("api.localhost")).toBe(true);
});
it("matches IPv6 loopback ::1", () => {
expect(isPrivateOrLoopbackHost("::1")).toBe(true);
});
it("matches bracketed IPv6 loopback [::1]", () => {
expect(isPrivateOrLoopbackHost("[::1]")).toBe(true);
});
});
describe("private IPv4 ranges", () => {
it("matches 10.0.0.0/8", () => {
expect(isPrivateOrLoopbackHost("10.0.0.1")).toBe(true);
expect(isPrivateOrLoopbackHost("10.255.255.255")).toBe(true);
});
it("matches 127.0.0.0/8 (loopback)", () => {
expect(isPrivateOrLoopbackHost("127.0.0.1")).toBe(true);
});
it("matches 169.254.0.0/16 (link-local)", () => {
expect(isPrivateOrLoopbackHost("169.254.1.1")).toBe(true);
});
it("matches 172.16.0.0/12", () => {
expect(isPrivateOrLoopbackHost("172.16.0.1")).toBe(true);
expect(isPrivateOrLoopbackHost("172.31.255.255")).toBe(true);
});
it("does NOT match outside 172.16-31", () => {
expect(isPrivateOrLoopbackHost("172.15.0.1")).toBe(false);
expect(isPrivateOrLoopbackHost("172.32.0.1")).toBe(false);
});
it("matches 192.168.0.0/16", () => {
expect(isPrivateOrLoopbackHost("192.168.0.1")).toBe(true);
expect(isPrivateOrLoopbackHost("192.168.255.255")).toBe(true);
});
it("matches 0.0.0.0/8", () => {
expect(isPrivateOrLoopbackHost("0.0.0.0")).toBe(true);
});
it("does NOT match public IPs", () => {
expect(isPrivateOrLoopbackHost("8.8.8.8")).toBe(false);
expect(isPrivateOrLoopbackHost("1.1.1.1")).toBe(false);
});
});
describe("private IPv6 ranges", () => {
it("matches unique-local fc00::/7", () => {
expect(isPrivateOrLoopbackHost("fc00::1")).toBe(true);
expect(isPrivateOrLoopbackHost("fd12::1")).toBe(true);
});
it("matches link-local fe80::/10", () => {
expect(isPrivateOrLoopbackHost("fe80::1")).toBe(true);
});
it("does NOT match global IPv6", () => {
expect(isPrivateOrLoopbackHost("2001:db8::1")).toBe(false);
});
});
describe("non-IP, non-loopback hostnames", () => {
it("returns false for public domain", () => {
expect(isPrivateOrLoopbackHost("example.com")).toBe(false);
});
it("returns false for arbitrary string", () => {
expect(isPrivateOrLoopbackHost("not-a-real-host")).toBe(false);
});
});
});
describe("parseUrl", () => {
it("returns URL object for valid URL", () => {
const url = parseUrl("https://example.com/path");
expect(url).not.toBeNull();
expect(url?.hostname).toBe("example.com");
});
it("returns null for invalid URL", () => {
expect(parseUrl("not a url")).toBeNull();
});
it("returns null for empty string", () => {
expect(parseUrl("")).toBeNull();
});
it("returns null for relative URL", () => {
expect(parseUrl("/path/only")).toBeNull();
});
});
describe("parseAllowedHostList", () => {
it("returns empty Set for undefined", () => {
expect(parseAllowedHostList()).toEqual(new Set());
});
it("returns empty Set for empty string", () => {
expect(parseAllowedHostList("")).toEqual(new Set());
});
it("parses single host", () => {
expect(parseAllowedHostList("example.com")).toEqual(new Set(["example.com"]));
});
it("parses comma-separated list", () => {
expect(parseAllowedHostList("example.com,api.example.com")).toEqual(new Set(["example.com", "api.example.com"]));
});
it("trims whitespace around entries", () => {
expect(parseAllowedHostList(" a.com , b.com ")).toEqual(new Set(["a.com", "b.com"]));
});
it("lowercases entries", () => {
expect(parseAllowedHostList("Example.COM")).toEqual(new Set(["example.com"]));
});
it("filters out empty entries", () => {
expect(parseAllowedHostList("a.com,,b.com,")).toEqual(new Set(["a.com", "b.com"]));
});
});
describe("isAllowedExternalUrl", () => {
const allowed = new Set(["api.example.com", "https://full.example.com"]);
it("returns false for malformed URLs", () => {
expect(isAllowedExternalUrl("not a url", allowed)).toBe(false);
});
it("returns false for non-https protocols", () => {
expect(isAllowedExternalUrl("http://api.example.com", allowed)).toBe(false);
expect(isAllowedExternalUrl("ftp://api.example.com", allowed)).toBe(false);
});
it("returns false when URL contains credentials", () => {
expect(isAllowedExternalUrl("https://user:pass@api.example.com", allowed)).toBe(false);
expect(isAllowedExternalUrl("https://user@api.example.com", allowed)).toBe(false);
});
it("returns false for private/loopback hosts", () => {
expect(isAllowedExternalUrl("https://localhost", allowed)).toBe(false);
expect(isAllowedExternalUrl("https://127.0.0.1", allowed)).toBe(false);
expect(isAllowedExternalUrl("https://192.168.1.1", allowed)).toBe(false);
});
it("matches by hostname", () => {
expect(isAllowedExternalUrl("https://api.example.com/path", allowed)).toBe(true);
});
it("matches by full origin", () => {
expect(isAllowedExternalUrl("https://full.example.com/x", allowed)).toBe(true);
});
it("rejects non-listed hostnames", () => {
expect(isAllowedExternalUrl("https://evil.com", allowed)).toBe(false);
});
it("matches case-insensitively in hostname", () => {
expect(isAllowedExternalUrl("https://API.EXAMPLE.COM", allowed)).toBe(true);
});
});
describe("isAllowedOAuthRedirectUri", () => {
const trustedOrigins = ["https://app.example.com"];
const allowed = new Set(["api.example.com"]);
it("returns false for malformed URI", () => {
expect(isAllowedOAuthRedirectUri("nope", trustedOrigins, allowed)).toBe(false);
});
it("returns false when credentials present", () => {
expect(isAllowedOAuthRedirectUri("https://u:p@app.example.com", trustedOrigins, allowed)).toBe(false);
});
it("returns false when fragment present", () => {
expect(isAllowedOAuthRedirectUri("https://app.example.com/cb#x", trustedOrigins, allowed)).toBe(false);
});
it("allows http for loopback (localhost)", () => {
expect(isAllowedOAuthRedirectUri("http://localhost:3000/cb", trustedOrigins, allowed)).toBe(true);
});
it("allows http for 127.0.0.1", () => {
expect(isAllowedOAuthRedirectUri("http://127.0.0.1/cb", trustedOrigins, allowed)).toBe(true);
});
it("allows http for IPv6 loopback", () => {
expect(isAllowedOAuthRedirectUri("http://[::1]/cb", trustedOrigins, allowed)).toBe(true);
});
it("rejects http for non-loopback hosts", () => {
expect(isAllowedOAuthRedirectUri("http://example.com/cb", trustedOrigins, allowed)).toBe(false);
});
it("rejects non-https/non-http protocols", () => {
expect(isAllowedOAuthRedirectUri("ftp://example.com/cb", trustedOrigins, allowed)).toBe(false);
});
it("rejects https with private/loopback host", () => {
expect(isAllowedOAuthRedirectUri("https://192.168.1.1/cb", trustedOrigins, allowed)).toBe(false);
});
it("matches trusted origins", () => {
expect(isAllowedOAuthRedirectUri("https://app.example.com/cb", trustedOrigins, allowed)).toBe(true);
});
it("matches against allowed origins set", () => {
expect(isAllowedOAuthRedirectUri("https://api.example.com/cb", trustedOrigins, allowed)).toBe(true);
});
it("rejects non-allowed public host", () => {
expect(isAllowedOAuthRedirectUri("https://evil.com/cb", trustedOrigins, allowed)).toBe(false);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { createUrl } from "./url";
describe("createUrl", () => {
it("returns empty url and label when no url provided", () => {
expect(createUrl()).toEqual({ url: "", label: "" });
});
it("returns empty pair when url is empty string (falsy)", () => {
expect(createUrl("")).toEqual({ url: "", label: "" });
});
it("uses url as label when no label provided", () => {
expect(createUrl("https://example.com")).toEqual({
url: "https://example.com",
label: "https://example.com",
});
});
it("preserves provided label", () => {
expect(createUrl("https://example.com", "Example")).toEqual({
url: "https://example.com",
label: "Example",
});
});
it("falls back to url when label is empty string", () => {
expect(createUrl("https://example.com", "")).toEqual({
url: "https://example.com",
label: "https://example.com",
});
});
it("does not validate the url format (caller's responsibility)", () => {
expect(createUrl("not-a-url", "Label")).toEqual({
url: "not-a-url",
label: "Label",
});
});
});
+52
View File
@@ -1 +1,53 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
// React Testing Library auto-cleanup hooks into afterEach as a global, but Vitest
// only exposes `afterEach` globally when `test.globals: true` is set. We register
// the cleanup explicitly so component tests do not leak DOM between runs.
afterEach(() => {
cleanup();
});
// jsdom polyfills for browser APIs that some libraries (cmdk, base-ui)
// rely on but jsdom does not implement.
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
if (typeof globalThis.IntersectionObserver === "undefined") {
globalThis.IntersectionObserver = class IntersectionObserver {
root = null;
rootMargin = "";
thresholds: ReadonlyArray<number> = [];
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
} as unknown as typeof IntersectionObserver;
}
// scrollIntoView is used by cmdk and other libs; jsdom does not implement it.
if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = function scrollIntoView() {};
}
// matchMedia is used by next-themes and other UI libs; jsdom does not provide it.
if (typeof window !== "undefined" && !window.matchMedia) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}));
}