diff --git a/apps/web/package.json b/apps/web/package.json index f8cc79ac..9880ea21 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,8 @@ "build": "pnpm with-env next build", "clean": "git clean -xdf .next .turbo node_modules", "dev": "pnpm with-env next dev", + "test": "vitest", + "test:run": "vitest run", "format": "prettier --check . --ignore-path ../../.gitignore", "lint": "eslint", "start": "pnpm with-env next start", @@ -42,6 +44,8 @@ "@cooper/prettier-config": "workspace:*", "@cooper/tailwind-config": "workspace:*", "@cooper/tsconfig": "workspace:*", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", "@types/node": "^20.14.15", "@types/react": "catalog:react18", "@types/react-dom": "catalog:react18", @@ -49,9 +53,11 @@ "dotenv-cli": "^7.4.2", "eslint": "catalog:", "jiti": "^1.21.6", + "jsdom": "^25.0.1", "prettier": "catalog:", "tailwindcss": "^3.4.4", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" }, "prettier": "@cooper/prettier-config" } diff --git a/apps/web/src/app/_components/auth/actions.test.ts b/apps/web/src/app/_components/auth/actions.test.ts new file mode 100644 index 00000000..c3969b39 --- /dev/null +++ b/apps/web/src/app/_components/auth/actions.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test, vi } from "vitest"; +import { handleGoogleSignIn, handleSignOut } from "./actions"; + +const mockSignIn = vi.fn(); +const mockSignOut = vi.fn(); +vi.mock("@cooper/auth", () => ({ + signIn: (...args: unknown[]) => mockSignIn(...args) as unknown, + signOut: (...args: unknown[]) => mockSignOut(...args) as unknown, +})); + +describe("auth actions", () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- vitest beforeEach callback with hoisted mocks + beforeEach(() => { + mockSignIn.mockReset(); + mockSignOut.mockReset(); + }); + + describe("handleGoogleSignIn", () => { + test("calls signIn with google and redirectTo", async () => { + mockSignIn.mockResolvedValue(undefined); + await handleGoogleSignIn(); + expect(mockSignIn).toHaveBeenCalledTimes(1); + expect(mockSignIn).toHaveBeenCalledWith("google", { redirectTo: "/" }); + }); + }); + + describe("handleSignOut", () => { + test("calls signOut with redirectTo", async () => { + mockSignOut.mockResolvedValue(undefined); + await handleSignOut(); + expect(mockSignOut).toHaveBeenCalledTimes(1); + expect(mockSignOut).toHaveBeenCalledWith({ redirectTo: "/" }); + }); + }); +}); diff --git a/apps/web/src/utils/companyStatistics.test.ts b/apps/web/src/utils/companyStatistics.test.ts new file mode 100644 index 00000000..4605b79c --- /dev/null +++ b/apps/web/src/utils/companyStatistics.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "vitest"; +import { + calculatePay, + calculatePayRange, + calculateWorkModels, +} from "./companyStatistics"; +import { ReviewType } from "@cooper/db/schema"; + +describe("companyStatistics", () => { + const mockReviews = [ + { + workEnvironment: "REMOTE", + hourlyPay: "25", + }, + { + workEnvironment: "REMOTE", + hourlyPay: "25", + }, + { + workEnvironment: "HYBRID", + hourlyPay: "30", + }, + { + workEnvironment: "INPERSON", + hourlyPay: "20", + }, + ] as ReviewType[]; + + describe("calculateWorkModels", () => { + test("returns empty array for no reviews", () => { + expect(calculateWorkModels([])).toEqual([]); + }); + + test("returns unique work models with count and percentage", () => { + const result = calculateWorkModels(mockReviews); + expect(result).toHaveLength(3); + expect(result).toContainEqual({ + name: "Remote", + percentage: 50, + count: 2, + }); + expect(result).toContainEqual({ + name: "Hybrid", + percentage: 25, + count: 1, + }); + expect(result).toContainEqual({ + name: "Inperson", + percentage: 25, + count: 1, + }); + }); + + test("title-cases model names", () => { + const result = calculateWorkModels([ + { workEnvironment: "REMOTE" } as never, + ]); + expect(result[0]?.name).toBe("Remote"); + }); + }); + + describe("calculatePay", () => { + test("returns empty array for no reviews", () => { + expect(calculatePay([])).toEqual([]); + }); + + test("returns pay breakdown with count and percentage", () => { + const result = calculatePay(mockReviews); + expect(result).toHaveLength(3); + expect(result).toContainEqual({ + pay: "25", + percentage: 50, + count: 2, + }); + expect(result).toContainEqual({ + pay: "30", + percentage: 25, + count: 1, + }); + expect(result).toContainEqual({ + pay: "20", + percentage: 25, + count: 1, + }); + }); + + test("treats null hourlyPay as 0", () => { + const result = calculatePay([ + { hourlyPay: null } as never, + { hourlyPay: "10" } as never, + ]); + expect(result).toContainEqual({ + pay: "0", + percentage: 50, + count: 1, + }); + }); + }); + + describe("calculatePayRange", () => { + test("returns single range with min/max 0 for no reviews", () => { + expect(calculatePayRange([])).toEqual([{ min: 0, max: 0 }]); + }); + + test("splits into Low/Mid/High when 3+ unique pay values", () => { + const reviews = [ + { hourlyPay: "10" }, + { hourlyPay: "20" }, + { hourlyPay: "30" }, + ] as ReviewType[]; + const result = calculatePayRange(reviews); + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ label: "Low", min: 10 }); + expect(result[1]).toMatchObject({ label: "Mid" }); + expect(result[2]).toMatchObject({ label: "High", max: 30 }); + }); + + test("returns Low and Mid ranges when fewer than 3 unique pays", () => { + const reviews = [ + { hourlyPay: "15" }, + { hourlyPay: "25" }, + ] as ReviewType[]; + const result = calculatePayRange(reviews); + expect(result).toHaveLength(2); + expect(result[0]?.label).toBe("Low"); + expect(result[1]?.label).toBe("Mid"); + }); + }); +}); diff --git a/apps/web/src/utils/dateHelpers.test.ts b/apps/web/src/utils/dateHelpers.test.ts new file mode 100644 index 00000000..53cf6bd0 --- /dev/null +++ b/apps/web/src/utils/dateHelpers.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; +import { formatDate } from "./dateHelpers"; + +describe("dateHelpers", () => { + describe("formatDate", () => { + test("returns empty string for undefined", () => { + expect(formatDate(undefined)).toBe(""); + }); + + test("returns formatted date as dd mth yyyy", () => { + expect(formatDate(new Date(2025, 0, 15))).toBe("15 Jan 2025"); + expect(formatDate(new Date(2024, 11, 1))).toBe("01 Dec 2024"); + expect(formatDate(new Date(2023, 5, 7))).toBe("07 Jun 2023"); + }); + + test("pads single-digit day with zero", () => { + expect(formatDate(new Date(2025, 2, 5))).toBe("05 Mar 2025"); + }); + }); +}); diff --git a/apps/web/src/utils/locationHelpers.test.ts b/apps/web/src/utils/locationHelpers.test.ts new file mode 100644 index 00000000..8be3eac1 --- /dev/null +++ b/apps/web/src/utils/locationHelpers.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; +import { abbreviatedStateName, prettyLocationName } from "./locationHelpers"; + +describe("locationHelpers", () => { + describe("prettyLocationName", () => { + test("returns N/A for undefined location", () => { + expect(prettyLocationName(undefined)).toBe("N/A"); + }); + + test("returns city and abbreviated state when state present (no country)", () => { + expect( + prettyLocationName({ + city: "Boston", + state: "Massachusetts", + country: "USA", + } as never), + ).toBe("Boston, MA"); + }); + + test("returns city, country when no state", () => { + expect( + prettyLocationName({ + city: "Toronto", + state: undefined, + country: "Canada", + } as never), + ).toBe("Toronto, Canada"); + }); + + test("abbreviates full state name", () => { + expect( + prettyLocationName({ + city: "San Francisco", + state: "California", + country: "USA", + } as never), + ).toBe("San Francisco, CA"); + }); + }); + + describe("abbreviatedStateName", () => { + test("uppercases 2-letter state codes", () => { + expect(abbreviatedStateName("ca")).toBe("CA"); + expect(abbreviatedStateName("ny")).toBe("NY"); + }); + + test("converts full state names to abbreviations", () => { + expect(abbreviatedStateName("California")).toBe("CA"); + expect(abbreviatedStateName("New York")).toBe("NY"); + expect(abbreviatedStateName("Texas")).toBe("TX"); + expect(abbreviatedStateName("Alabama")).toBe("AL"); + expect(abbreviatedStateName("District of Columbia")).toBe("DC"); + expect(abbreviatedStateName("New Hampshire")).toBe("NH"); + expect(abbreviatedStateName("West Virginia")).toBe("WV"); + }); + + test("returns state unchanged when not in map", () => { + expect(abbreviatedStateName("Unknown")).toBe("Unknown"); + }); + }); +}); diff --git a/apps/web/src/utils/reviewCountByStars.test.ts b/apps/web/src/utils/reviewCountByStars.test.ts new file mode 100644 index 00000000..f75c7326 --- /dev/null +++ b/apps/web/src/utils/reviewCountByStars.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "vitest"; +import { calculateRatings } from "./reviewCountByStars"; +import { ReviewType } from "@cooper/db/schema"; + +describe("reviewCountByStars", () => { + describe("calculateRatings", () => { + test("returns zeros for empty reviews", () => { + const result = calculateRatings([]); + expect(result).toHaveLength(5); + result.forEach((r) => { + expect(r.percentage).toBe(0); + expect([1, 2, 3, 4, 5]).toContain(r.stars); + }); + }); + + test("returns stars 1-5 with percentage", () => { + const reviews = [ + { overallRating: 5 }, + { overallRating: 5 }, + { overallRating: 4 }, + { overallRating: 3 }, + ] as ReviewType[]; + const result = calculateRatings(reviews); + expect(result).toHaveLength(5); + const five = result.find((r) => r.stars === 5); + const four = result.find((r) => r.stars === 4); + const three = result.find((r) => r.stars === 3); + expect(five?.percentage).toBe(50); + expect(four?.percentage).toBe(25); + expect(three?.percentage).toBe(25); + }); + }); +}); diff --git a/apps/web/src/utils/reviewsAggregationHelpers.test.ts b/apps/web/src/utils/reviewsAggregationHelpers.test.ts new file mode 100644 index 00000000..62ef5179 --- /dev/null +++ b/apps/web/src/utils/reviewsAggregationHelpers.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "vitest"; +import { + averageStarRating, + listBenefits, + mostCommonWorkEnviornment, +} from "./reviewsAggregationHelpers"; +import { ReviewType } from "@cooper/db/schema"; + +describe("reviewsAggregationHelpers", () => { + describe("mostCommonWorkEnviornment", () => { + test("returns In Person when INPERSON is most common", () => { + const reviews = [ + { workEnvironment: "INPERSON" }, + { workEnvironment: "INPERSON" }, + { workEnvironment: "REMOTE" }, + ] as ReviewType[]; + expect(mostCommonWorkEnviornment(reviews)).toBe("In Person"); + }); + + test("returns Hybrid when HYBRID is most common", () => { + const reviews = [ + { workEnvironment: "HYBRID" }, + { workEnvironment: "HYBRID" }, + { workEnvironment: "REMOTE" }, + ] as ReviewType[]; + expect(mostCommonWorkEnviornment(reviews)).toBe("Hybrid"); + }); + + test("returns Remote when REMOTE is most common", () => { + const reviews = [ + { workEnvironment: "REMOTE" }, + { workEnvironment: "REMOTE" }, + { workEnvironment: "INPERSON" }, + ] as ReviewType[]; + expect(mostCommonWorkEnviornment(reviews)).toBe("Remote"); + }); + + test("ties: In Person wins over others", () => { + const reviews = [ + { workEnvironment: "INPERSON" }, + { workEnvironment: "HYBRID" }, + { workEnvironment: "REMOTE" }, + ] as ReviewType[]; + expect(mostCommonWorkEnviornment(reviews)).toBe("In Person"); + }); + }); + + describe("averageStarRating", () => { + test("returns average of overall ratings", () => { + const reviews = [ + { overallRating: 4 }, + { overallRating: 5 }, + { overallRating: 3 }, + ] as ReviewType[]; + expect(averageStarRating(reviews)).toBe(4); + }); + + test("handles decimal average", () => { + const reviews = [ + { overallRating: 4 }, + { overallRating: 5 }, + ] as ReviewType[]; + expect(averageStarRating(reviews)).toBe(4.5); + }); + }); + + describe("listBenefits", () => { + test("returns empty array when no benefits", () => { + const review = { + pto: false, + federalHolidays: false, + freeLunch: false, + travelBenefits: false, + freeMerch: false, + snackBar: false, + } as never; + expect(listBenefits(review)).toEqual([]); + }); + + test("returns labels for each benefit that is true", () => { + const review = { + pto: true, + federalHolidays: true, + freeLunch: false, + travelBenefits: true, + freeMerch: false, + snackBar: true, + } as never; + const result = listBenefits(review); + expect(result).toContain("Paid Time Off"); + expect(result).toContain("Federal Holidays Off"); + expect(result).toContain("Free Transporation"); + expect(result).toContain("Snack Bar"); + expect(result).not.toContain("Free Lunch"); + expect(result).not.toContain("Free Merch"); + }); + }); +}); diff --git a/apps/web/src/utils/stringHelpers.test.ts b/apps/web/src/utils/stringHelpers.test.ts new file mode 100644 index 00000000..1ba158cb --- /dev/null +++ b/apps/web/src/utils/stringHelpers.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "vitest"; +import { + abbreviatedWorkTerm, + prettyDescription, + prettyIndustry, + prettyWorkEnviornment, + truncateText, +} from "./stringHelpers"; + +describe("stringHelpers", () => { + describe("truncateText", () => { + test("returns text unchanged when shorter than length", () => { + expect(truncateText("hi", 10)).toBe("hi"); + }); + + test("returns text with ellipsis when at or over length", () => { + const result = truncateText("hello world", 5); + expect(result).toContain("hello"); + expect(result).toContain("..."); + }); + }); + + describe("abbreviatedWorkTerm", () => { + test("abbreviates SPRING, SUMMER, FALL", () => { + expect(abbreviatedWorkTerm("SPRING")).toBe("Spr"); + expect(abbreviatedWorkTerm("SUMMER")).toBe("Sum"); + expect(abbreviatedWorkTerm("FALL")).toBe("Fall"); + }); + + test("returns other terms unchanged", () => { + expect(abbreviatedWorkTerm("WINTER")).toBe("WINTER"); + }); + }); + + describe("prettyWorkEnviornment", () => { + test("formats work environment labels", () => { + expect(prettyWorkEnviornment("HYBRID")).toBe("Hybrid"); + expect(prettyWorkEnviornment("INPERSON")).toBe("In-person"); + expect(prettyWorkEnviornment("REMOTE")).toBe("Remote"); + }); + }); + + describe("prettyDescription", () => { + test("returns empty string for undefined or null", () => { + expect(prettyDescription(undefined)).toBe(""); + expect(prettyDescription(null)).toBe(""); + }); + + test("returns description when under max length", () => { + const short = "Short text"; + expect(prettyDescription(short)).toBe(short); + }); + + test("truncates and appends ... when over max length", () => { + const long = "a".repeat(250); + const result = prettyDescription(long, 200); + expect(result).toHaveLength(203); + expect(result.endsWith("...")).toBe(true); + }); + + test("uses default max length 200", () => { + const long = "a".repeat(250); + expect(prettyDescription(long).length).toBe(203); + }); + }); + + describe("prettyIndustry", () => { + test("returns Unknown Industry for undefined or empty", () => { + expect(prettyIndustry(undefined)).toBe("Unknown Industry"); + expect(prettyIndustry("")).toBe("Unknown Industry"); + }); + + test("formats known industry codes", () => { + expect(prettyIndustry("TECHNOLOGY")).toBe("Technology"); + expect(prettyIndustry("HEALTHCARE")).toBe("Healthcare"); + expect(prettyIndustry("FINANCE")).toBe("Finance"); + expect(prettyIndustry("REALESTATE")).toBe("Real Estate"); + expect(prettyIndustry("FASHIONANDBEAUTY")).toBe("Fashion & Beauty"); + expect(prettyIndustry("FOODANDBEVERAGE")).toBe("Food & Beverage"); + }); + + test("returns Unknown Industry for unknown code", () => { + expect(prettyIndustry("UNKNOWN")).toBe("Unknown Industry"); + }); + }); +}); diff --git a/apps/web/test/app-layout.test.tsx b/apps/web/test/app-layout.test.tsx new file mode 100644 index 00000000..80fc9a72 --- /dev/null +++ b/apps/web/test/app-layout.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import RootLayout from "~/app/layout"; + +vi.mock("~/env", () => ({ + env: { VERCEL_ENV: "development", NODE_ENV: "test" }, +})); + +vi.mock("~/trpc/react", () => ({ + TRPCReactProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("~/app/_components/compare/compare-context", () => ({ + CompareProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("~/app/styles/font", () => ({ + hankenGroteskFont: { variable: "font-hanken" }, +})); + +describe("RootLayout", () => { + test("renders children inside providers", () => { + render( + + Child content + , + ); + expect(screen.getByTestId("trpc-provider")).toBeInTheDocument(); + expect(screen.getByTestId("compare-provider")).toBeInTheDocument(); + expect(screen.getByTestId("child")).toHaveTextContent("Child content"); + }); + + test("renders html with lang and font class", () => { + const { container } = render( + + Child + , + ); + const html = container.querySelector("html"); + expect(html).toHaveAttribute("lang", "en"); + expect(html).toHaveClass("font-hanken"); + }); + + test("renders body with expected classes", () => { + const { container } = render( + + Child + , + ); + const body = container.querySelector("body"); + expect(body).toHaveClass("bg-cooper-cream-100", "font-sans", "antialiased"); + }); +}); diff --git a/apps/web/test/auth/login-button-client.test.tsx b/apps/web/test/auth/login-button-client.test.tsx new file mode 100644 index 00000000..3369dcf7 --- /dev/null +++ b/apps/web/test/auth/login-button-client.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import LoginButtonClient from "~/app/_components/auth/login-button-client"; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +vi.mock("~/app/_components/auth/actions", () => ({ + handleGoogleSignIn: vi.fn(), +})); + +describe("LoginButtonClient", () => { + test("renders form with submit button", () => { + render(); + const form = screen.getByRole("button", { name: /login/i }).closest("form"); + expect(form).toBeInTheDocument(); + }); + + test("renders login image with correct alt", () => { + render(); + expect(screen.getByRole("img", { name: /login/i })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/auth/login-button.test.tsx b/apps/web/test/auth/login-button.test.tsx new file mode 100644 index 00000000..7dfd7f83 --- /dev/null +++ b/apps/web/test/auth/login-button.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import LoginButton from "~/app/_components/auth/login-button"; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +vi.mock("@cooper/auth", () => ({ + signIn: vi.fn(), +})); + +describe("LoginButton", () => { + test("renders mobile form with image button", () => { + render(); + const forms = screen.getAllByRole("button"); + expect(forms.length).toBeGreaterThanOrEqual(1); + expect(screen.getByRole("img", { name: /login/i })).toBeInTheDocument(); + }); + + test("renders Log in button for larger screens", () => { + render(); + expect(screen.getByText("Log in")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/auth/logout-button.test.tsx b/apps/web/test/auth/logout-button.test.tsx new file mode 100644 index 00000000..ce365383 --- /dev/null +++ b/apps/web/test/auth/logout-button.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import LogoutButton from "~/app/_components/auth/logout-button"; + +vi.mock("@cooper/auth", () => ({ + signOut: vi.fn(), +})); + +describe("LogoutButton", () => { + test("renders Sign Out button", () => { + render(); + expect( + screen.getByRole("button", { name: /sign out/i }), + ).toBeInTheDocument(); + }); + + test("button is inside a form", () => { + render(); + const button = screen.getByRole("button", { name: /sign out/i }); + expect(button.closest("form")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/back-button.test.tsx b/apps/web/test/back-button.test.tsx new file mode 100644 index 00000000..743666b0 --- /dev/null +++ b/apps/web/test/back-button.test.tsx @@ -0,0 +1,23 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import BackButton from "~/app/_components/back-button"; + +const mockBack = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ back: mockBack }), +})); + +describe("BackButton", () => { + test("renders Go Back button", () => { + render(); + expect( + screen.getByRole("button", { name: /go back/i }), + ).toBeInTheDocument(); + }); + + test("calls router.back on click", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /go back/i })); + expect(mockBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/test/bar-graph.test.tsx b/apps/web/test/bar-graph.test.tsx new file mode 100644 index 00000000..ca3b6575 --- /dev/null +++ b/apps/web/test/bar-graph.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import BarGraph from "~/app/_components/reviews/bar-graph"; + +describe("BarGraph", () => { + test("renders title", () => { + render(); + expect(screen.getByText("Culture")).toBeInTheDocument(); + }); + + test("renders value with precision 2", () => { + render(); + expect(screen.getByText("4.2")).toBeInTheDocument(); + }); + + test("renders industry average when provided", () => { + render( + , + ); + expect(screen.getByText("Industry average: 3.5")).toBeInTheDocument(); + }); + + test("does not render industry average when not provided", () => { + render(); + expect(screen.queryByText(/Industry average/)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/body-logo.test.tsx b/apps/web/test/body-logo.test.tsx new file mode 100644 index 00000000..04a4c7c5 --- /dev/null +++ b/apps/web/test/body-logo.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import BodyLogo from "~/app/_components/body-logo"; + +vi.mock("next/image", () => ({ + default: ({ + src, + alt, + width, + height, + }: { + src: string; + alt: string; + width?: number; + height?: number; + }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})); + +describe("BodyLogo", () => { + test("renders logo with default dimensions when no width", () => { + render(); + const img = screen.getByTestId("body-logo-img"); + expect(img).toHaveAttribute("src", "/svg/bodyLogo.svg"); + expect(img).toHaveAttribute("alt", "Logo Picture"); + expect(img).toHaveAttribute("width", "80"); + expect(img).toHaveAttribute("height", "70"); + }); + + test("renders logo with custom width", () => { + render(); + const img = screen.getByTestId("body-logo-img"); + expect(img).toHaveAttribute("width", "200"); + expect(Number(img.getAttribute("height"))).toBeCloseTo(200 / 1.17); + }); +}); diff --git a/apps/web/test/combo-box.test.tsx b/apps/web/test/combo-box.test.tsx new file mode 100644 index 00000000..970fab96 --- /dev/null +++ b/apps/web/test/combo-box.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import ComboBox from "~/app/_components/combo-box"; + +const defaultProps = { + defaultLabel: "Select option", + searchPlaceholder: "Search...", + searchEmpty: "No results.", + valuesAndLabels: [ + { value: "a", label: "Option A" }, + { value: "b", label: "Option B" }, + { value: "c", label: "Option C" }, + ], + currLabel: "", + onSelect: vi.fn(), +}; + +describe("ComboBox", () => { + test("renders trigger with default label when currLabel is empty", () => { + render(); + expect(screen.getByText("Select option")).toBeInTheDocument(); + }); + + test("renders trigger with currLabel when set", () => { + render(); + expect(screen.getByText("Option B")).toBeInTheDocument(); + }); + + test("opens popover and shows options when trigger clicked", () => { + render(); + fireEvent.click(screen.getByRole("combobox")); + expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument(); + expect(screen.getByText("Option A")).toBeInTheDocument(); + expect(screen.getByText("Option B")).toBeInTheDocument(); + expect(screen.getByText("Option C")).toBeInTheDocument(); + }); + + test("calls onSelect when option clicked", () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByRole("combobox")); + fireEvent.click(screen.getByText("Option B")); + expect(onSelect).toHaveBeenCalledWith("Option B"); + }); + + test("shows search empty message when no match", () => { + render(); + fireEvent.click(screen.getByRole("combobox")); + const input = screen.getByPlaceholderText("Search..."); + fireEvent.change(input, { target: { value: "zzz" } }); + expect(screen.getByText("No results.")).toBeInTheDocument(); + }); + + test("renders Clear selection button when onClear provided", () => { + const onClear = vi.fn(); + render( + , + ); + expect(screen.getByLabelText("Clear selection")).toBeInTheDocument(); + }); + + test("calls onClear when clear button clicked", () => { + const onClear = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText("Clear selection")); + expect(onClear).toHaveBeenCalled(); + }); + + test("calls onChange when typing in search", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("combobox")); + const input = screen.getByPlaceholderText("Search..."); + fireEvent.change(input, { target: { value: "test" } }); + expect(onChange).toHaveBeenCalledWith("test"); + }); +}); diff --git a/apps/web/test/companies/all-company-roles-loading.test.tsx b/apps/web/test/companies/all-company-roles-loading.test.tsx new file mode 100644 index 00000000..aaeedacf --- /dev/null +++ b/apps/web/test/companies/all-company-roles-loading.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import RenderAllRoles from "~/app/_components/companies/all-company-roles"; + +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(() => ({ push: vi.fn() })), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + role: { + getByCompany: { + useQuery: () => ({ + isPending: true, + isSuccess: false, + data: undefined, + }), + }, + }, + }, +})); + +vi.mock("~/app/_components/loading-results", () => ({ + default: () =>
Loading...
, +})); + +vi.mock("~/app/_components/reviews/role-card-preview", () => ({ + RoleCardPreview: () =>
Role
, +})); + +vi.mock("~/app/_components/reviews/new-role-card", () => ({ + default: () =>
New Role
, +})); + +describe("RenderAllRoles loading state", () => { + test("renders LoadingResults when query is pending", () => { + render(); + expect(screen.getByTestId("loading-results")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/companies/all-company-roles.test.tsx b/apps/web/test/companies/all-company-roles.test.tsx new file mode 100644 index 00000000..95e77a63 --- /dev/null +++ b/apps/web/test/companies/all-company-roles.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import RenderAllRoles from "~/app/_components/companies/all-company-roles"; + +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(() => ({ push: vi.fn() })), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + role: { + getByCompany: { + useQuery: () => ({ + isPending: false, + isSuccess: true, + data: [{ id: "role-1", title: "Engineer", companyName: "Acme" }], + }), + }, + }, + }, +})); + +vi.mock("~/app/_components/loading-results", () => ({ + default: () =>
Loading...
, +})); + +vi.mock("~/app/_components/reviews/role-card-preview", () => ({ + RoleCardPreview: () =>
Role
, +})); + +vi.mock("~/app/_components/reviews/new-role-card", () => ({ + default: () =>
New Role
, +})); + +describe("RenderAllRoles", () => { + test("renders Roles at company name with count", () => { + render( + , + ); + expect(screen.getByText(/Roles at Acme Corp \(1\)/)).toBeInTheDocument(); + }); + + test("renders NewRoleCard when company is provided", () => { + render(); + expect(screen.getByTestId("new-role-card")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/companies/company-about.test.tsx b/apps/web/test/companies/company-about.test.tsx new file mode 100644 index 00000000..724e4bf1 --- /dev/null +++ b/apps/web/test/companies/company-about.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { CompanyAbout } from "~/app/_components/companies/company-about"; + +describe("CompanyAbout", () => { + test("renders About heading with company name", () => { + render( + , + ); + expect(screen.getByText("About Acme Corp")).toBeInTheDocument(); + expect(screen.getByText("We build things.")).toBeInTheDocument(); + }); + + test("handles undefined companyObj", () => { + render(); + expect(screen.getByText(/About/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/companies/company-card-preview.test.tsx b/apps/web/test/companies/company-card-preview.test.tsx new file mode 100644 index 00000000..a2f48b7a --- /dev/null +++ b/apps/web/test/companies/company-card-preview.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { CompanyCardPreview } from "~/app/_components/companies/company-card-preview"; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + companyToLocation: { + getLocationsByCompanyId: { + useQuery: () => ({ data: [] }), + }, + }, + company: { + getAverageById: { + useQuery: () => ({ data: { averageOverallRating: 4.2 } }), + }, + }, + review: { + getByCompany: { + useQuery: () => ({ data: [{ id: "1" }] }), + }, + }, + }, +})); + +vi.mock("@cooper/ui/logo", () => ({ + default: () =>
Logo
, +})); + +vi.mock("~/app/_components/shared/favorite-button", () => ({ + FavoriteButton: () => , +})); + +describe("CompanyCardPreview", () => { + const companyObj = { + id: "c1", + name: "Test Company", + } as never; + + test("renders company name", () => { + render(); + expect(screen.getByText("Test Company")).toBeInTheDocument(); + }); + + test("renders average rating when present", () => { + render(); + expect(screen.getByText("4.2")).toBeInTheDocument(); + }); + + test("renders review count", () => { + render(); + expect(screen.getByText(/1 review/)).toBeInTheDocument(); + }); + + test("renders Location not specified when no locations", () => { + render(); + expect(screen.getByText("Location not specified")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/companies/company-info.test.tsx b/apps/web/test/companies/company-info.test.tsx new file mode 100644 index 00000000..031faa2f --- /dev/null +++ b/apps/web/test/companies/company-info.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import CompanyInfo from "~/app/_components/companies/company-info"; + +vi.mock("~/trpc/react", () => ({ + api: { + company: { + getById: { + useQuery: () => ({ + isSuccess: true, + data: { + id: "c1", + name: "Acme Corp", + }, + }), + }, + }, + review: { + getByCompany: { + useQuery: () => ({ data: [] }), + }, + }, + useQueries: () => [], + }, +})); + +vi.mock("@cooper/ui/logo", () => ({ + default: () =>
Logo
, +})); + +vi.mock("~/app/_components/shared/favorite-button", () => ({ + FavoriteButton: () => , +})); + +vi.mock("~/app/_components/companies/all-company-roles", () => ({ + default: () =>
All Roles
, +})); + +vi.mock("~/app/_components/companies/company-about", () => ({ + CompanyAbout: () =>
About
, +})); + +vi.mock("~/app/_components/companies/company-reviews", () => ({ + CompanyReview: () =>
Reviews
, +})); + +vi.mock("~/app/_components/no-results", () => ({ + default: () =>
No results
, +})); + +describe("CompanyInfo", () => { + const companyObj = { id: "c1", name: "Acme Corp" } as never; + + test("renders company name when query succeeds", () => { + render(); + expect(screen.getByText("Acme Corp")).toBeInTheDocument(); + }); + + test("renders CompanyAbout and CompanyReview", () => { + render(); + expect(screen.getByTestId("company-about")).toBeInTheDocument(); + expect(screen.getByTestId("company-review")).toBeInTheDocument(); + }); + + test("renders RenderAllRoles", () => { + render(); + expect(screen.getByTestId("all-roles")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/companies/company-popup.test.tsx b/apps/web/test/companies/company-popup.test.tsx new file mode 100644 index 00000000..75ec66e5 --- /dev/null +++ b/apps/web/test/companies/company-popup.test.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { CompanyPopup } from "~/app/_components/companies/company-popup"; + +vi.mock("node_modules/@cooper/ui/src/logo", () => ({ + default: ({ company }: { company: { name: string } }) => ( + {company.name} + ), +})); + +vi.mock("~/app/_components/shared/favorite-button", () => ({ + FavoriteButton: () => , +})); + +vi.mock("~/app/_components/companies/all-company-roles", () => ({ + default: () =>
Roles
, +})); + +vi.mock("~/app/_components/companies/company-about", () => ({ + CompanyAbout: () =>
About
, +})); + +vi.mock("~/app/_components/companies/company-reviews", () => ({ + CompanyReview: () =>
Reviews
, +})); + +const mockCompany = { + id: "company-1", + name: "Test Company", + slug: "test-company", +} as never; + +describe("CompanyPopup", () => { + test("renders trigger when provided", () => { + render( + Open company} + company={mockCompany} + />, + ); + expect(screen.getByText("Open company")).toBeInTheDocument(); + }); + + test("opens dialog and shows company name when trigger clicked", () => { + render( + View company} + company={mockCompany} + />, + ); + fireEvent.click(screen.getByText("View company")); + expect( + screen.getByRole("heading", { name: "Test Company" }), + ).toBeInTheDocument(); + }); + + test("shows single location when locations has one item", () => { + render( + Open} + company={mockCompany} + locations={["Boston, MA"]} + />, + ); + fireEvent.click(screen.getByText("Open")); + expect(screen.getByText("Boston, MA")).toBeInTheDocument(); + }); + + test("shows location count when locations has multiple items", () => { + render( + Open} + company={mockCompany} + locations={["Boston, MA", "New York, NY", "Chicago, IL"]} + />, + ); + fireEvent.click(screen.getByText("Open")); + expect(screen.getByText(/Boston, MA \+2 others/)).toBeInTheDocument(); + }); + + test("shows CompanyAbout and CompanyReview in dialog", () => { + render( + Open} + company={mockCompany} + />, + ); + fireEvent.click(screen.getByText("Open")); + expect(screen.getByTestId("company-about")).toBeInTheDocument(); + expect(screen.getByTestId("company-reviews")).toBeInTheDocument(); + }); + + test("shows FavoriteButton in dialog", () => { + render( + Open} + company={mockCompany} + />, + ); + fireEvent.click(screen.getByText("Open")); + expect(screen.getByTestId("favorite-button")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/companies/company-reviews.test.tsx b/apps/web/test/companies/company-reviews.test.tsx new file mode 100644 index 00000000..9d77f628 --- /dev/null +++ b/apps/web/test/companies/company-reviews.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { CompanyReview } from "~/app/_components/companies/company-reviews"; + +vi.mock("~/trpc/react", () => ({ + api: { + review: { + getByCompany: { + useQuery: () => ({ + data: [ + { + id: "r1", + overallRating: 4, + workEnvironment: "REMOTE", + hourlyPay: "25", + }, + ], + }), + }, + list: { useQuery: () => ({ data: [{ overallRating: 4 }] }) }, + }, + company: { + getAverageById: { + useQuery: () => ({ data: { averageOverallRating: 4.2 } }), + }, + }, + }, +})); + +vi.mock("~/app/_components/shared/star-graph", () => ({ + default: () =>
StarGraph
, +})); + +vi.mock("~/app/_components/companies/company-statistics", () => ({ + default: () =>
Statistics
, +})); + +describe("CompanyReview", () => { + test("renders StarGraph", () => { + render(); + expect(screen.getByTestId("star-graph")).toBeInTheDocument(); + }); + + test("renders CompanyStatistics when reviews exist", () => { + render(); + expect(screen.getByTestId("company-statistics")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/companies/company-statistics.test.tsx b/apps/web/test/companies/company-statistics.test.tsx new file mode 100644 index 00000000..d1211005 --- /dev/null +++ b/apps/web/test/companies/company-statistics.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import CompanyStatistics from "~/app/_components/companies/company-statistics"; + +describe("CompanyStatistics", () => { + const workModels = [ + { name: "Remote", percentage: 50, count: 5 }, + { name: "Hybrid", percentage: 30, count: 3 }, + { name: "In-person", percentage: 20, count: 2 }, + ]; + const payStats = [ + { pay: "25", percentage: 40, count: 4 }, + { pay: "30", percentage: 60, count: 6 }, + ]; + const payRange = [ + { label: "Low", min: 0, max: 20 }, + { label: "Mid", min: 21, max: 35 }, + { label: "High", min: 36, max: 50 }, + ]; + + test("renders Job type section with Co-op", () => { + render( + , + ); + expect(screen.getByText("Job type")).toBeInTheDocument(); + expect(screen.getByText("Co-op")).toBeInTheDocument(); + }); + + test("renders Work model section with model names", () => { + render( + , + ); + expect(screen.getByText("Work model")).toBeInTheDocument(); + expect(screen.getByText("Remote")).toBeInTheDocument(); + expect(screen.getByText("Hybrid")).toBeInTheDocument(); + expect(screen.getByText("In-person")).toBeInTheDocument(); + }); + + test("renders Pay section", () => { + render( + , + ); + expect(screen.getByText("Pay")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/compare/compare-context.test.tsx b/apps/web/test/compare/compare-context.test.tsx new file mode 100644 index 00000000..28d063c1 --- /dev/null +++ b/apps/web/test/compare/compare-context.test.tsx @@ -0,0 +1,148 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { + CompareProvider, + useCompare, +} from "~/app/_components/compare/compare-context"; + +function TestConsumer() { + const compare = useCompare(); + return ( +
+ {String(compare.isCompareMode)} + {compare.comparedRoleIds.join(",")} + + + + + + +
+ ); +} + +const mockGetItem = vi.fn((): string | null => null); +const mockSetItem = vi.fn(); +const mockRemoveItem = vi.fn(); + +const mockLocalStorage = { + getItem: mockGetItem, + setItem: mockSetItem, + removeItem: mockRemoveItem, +}; + +describe("CompareContext", () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- vitest beforeEach callback with mocks + beforeEach(() => { + if (typeof window !== "undefined") { + Object.defineProperty(window, "localStorage", { + value: mockLocalStorage, + writable: true, + }); + } + }); + + test("useCompare throws when outside provider", () => { + expect(() => render()).toThrow( + "useCompare must be used within CompareProvider", + ); + }); + + test("provider exposes initial state", () => { + render( + + + , + ); + expect(screen.getByTestId("mode")).toHaveTextContent("false"); + expect(screen.getByTestId("ids")).toHaveTextContent(""); + }); + + test("enterCompareMode sets isCompareMode true", () => { + render( + + + , + ); + fireEvent.click(screen.getByTestId("enter")); + expect(screen.getByTestId("mode")).toHaveTextContent("true"); + }); + + test("addRoleId adds id to comparedRoleIds", () => { + render( + + + , + ); + fireEvent.click(screen.getByTestId("enter")); + fireEvent.click(screen.getByTestId("add")); + expect(screen.getByTestId("ids")).toHaveTextContent("role-1"); + }); + + test("removeRoleId removes id", () => { + render( + + + , + ); + fireEvent.click(screen.getByTestId("enter")); + fireEvent.click(screen.getByTestId("add")); + fireEvent.click(screen.getByTestId("remove")); + expect(screen.getByTestId("ids")).toHaveTextContent(""); + }); + + test("exitCompareMode sets isCompareMode false", () => { + render( + + + , + ); + fireEvent.click(screen.getByTestId("enter")); + fireEvent.click(screen.getByTestId("exit")); + expect(screen.getByTestId("mode")).toHaveTextContent("false"); + }); + + test("clear resets comparedRoleIds", () => { + render( + + + , + ); + fireEvent.click(screen.getByTestId("enter")); + fireEvent.click(screen.getByTestId("add")); + fireEvent.click(screen.getByTestId("clear")); + expect(screen.getByTestId("ids")).toHaveTextContent(""); + }); +}); diff --git a/apps/web/test/compare/compare-ui.test.tsx b/apps/web/test/compare/compare-ui.test.tsx new file mode 100644 index 00000000..b79535a0 --- /dev/null +++ b/apps/web/test/compare/compare-ui.test.tsx @@ -0,0 +1,110 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { + CompareControls, + CompareColumns, +} from "~/app/_components/compare/compare-ui"; + +const mockCompare = { + isCompareMode: false, + comparedRoleIds: [] as string[], + reservedSlots: 0, + maxColumns: 3, + enterCompareMode: vi.fn(), + exitCompareMode: vi.fn(), + addRoleId: vi.fn(), + removeRoleId: vi.fn(), + addSlot: vi.fn(), + clear: vi.fn(), +}; + +vi.mock("~/app/_components/compare/compare-context", () => ({ + useCompare: () => mockCompare, +})); + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + role: { + getManyByIds: { + useQuery: () => ({ data: [], isLoading: false }), + }, + }, + }, +})); + +vi.mock("~/app/_components/reviews/role-info", () => ({ + RoleInfo: () =>
RoleInfo
, +})); + +describe("CompareControls", () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- vitest beforeEach callback with mocks + beforeEach(() => { + mockCompare.isCompareMode = false; + mockCompare.comparedRoleIds = []; + mockCompare.reservedSlots = 0; + mockCompare.enterCompareMode.mockClear(); + mockCompare.exitCompareMode.mockClear(); + mockCompare.addSlot.mockClear(); + }); + + test("returns null when anchorRoleId is not provided", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test("returns null when inTopBar and not in compare mode", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + test("renders COMPARE WITH button when not in compare mode", () => { + render(); + expect( + screen.getByRole("button", { name: /compare with/i }), + ).toBeInTheDocument(); + }); + + test("clicking COMPARE WITH calls enterCompareMode", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /compare with/i })); + expect(mockCompare.enterCompareMode).toHaveBeenCalledTimes(1); + }); + + test("when in compare mode renders ADD COMPARISON and EXIT", () => { + mockCompare.isCompareMode = true; + render(); + expect( + screen.getByRole("button", { name: /add comparison/i }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /exit/i })).toBeInTheDocument(); + }); + + test("clicking EXIT calls exitCompareMode", () => { + mockCompare.isCompareMode = true; + render(); + fireEvent.click(screen.getByRole("button", { name: /exit/i })); + expect(mockCompare.exitCompareMode).toHaveBeenCalledTimes(1); + }); +}); + +describe("CompareColumns", () => { + const anchorRole = { + id: "anchor-1", + title: "Engineer", + companyName: "Acme", + } as never; + + test("renders anchor role column", () => { + render(); + expect(screen.getByTestId("role-info")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/cooper-logo.test.tsx b/apps/web/test/cooper-logo.test.tsx new file mode 100644 index 00000000..1dbfd7ac --- /dev/null +++ b/apps/web/test/cooper-logo.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import CooperLogo from "~/app/_components/cooper-logo"; + +vi.mock("next/image", () => ({ + default: ({ + src, + alt, + width, + height, + }: { + src: string; + alt: string; + width?: number; + height?: number; + }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})); + +describe("CooperLogo", () => { + test("renders logo with default dimensions when no width", () => { + render(); + const img = screen.getByTestId("cooper-logo-img"); + expect(img).toHaveAttribute("src", "/svg/logoOutline.svg"); + expect(img).toHaveAttribute("alt", "Logo Picture"); + expect(img).toHaveAttribute("width", "80"); + expect(img).toHaveAttribute("height", "70"); + }); + + test("renders logo with custom width", () => { + render(); + const img = screen.getByTestId("cooper-logo-img"); + expect(img).toHaveAttribute("width", "120"); + expect(Number(img.getAttribute("height"))).toBeCloseTo(120 / 1.17); + }); +}); diff --git a/apps/web/test/dashboard-layout.test.tsx b/apps/web/test/dashboard-layout.test.tsx new file mode 100644 index 00000000..735df603 --- /dev/null +++ b/apps/web/test/dashboard-layout.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import DashboardLayout from "~/app/(pages)/(dashboard)/layout"; + +vi.mock("@cooper/ui", () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(" "), + CustomToaster: () =>
Toaster
, +})); + +vi.mock("~/app/_components/header/header-layout", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("~/app/_components/onboarding/onboarding-wrapper", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +describe("DashboardLayout", () => { + test("renders children inside HeaderLayout and OnboardingWrapper", () => { + render( + + Page content + , + ); + expect(screen.getByTestId("header-layout")).toBeInTheDocument(); + expect(screen.getByTestId("onboarding-wrapper")).toBeInTheDocument(); + expect(screen.getByTestId("page-child")).toHaveTextContent("Page content"); + }); + + test("renders CustomToaster", () => { + render( + + Child + , + ); + expect(screen.getByTestId("custom-toaster")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/filters/dropdown-filters-bar.test.tsx b/apps/web/test/filters/dropdown-filters-bar.test.tsx new file mode 100644 index 00000000..12ce85cc --- /dev/null +++ b/apps/web/test/filters/dropdown-filters-bar.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import DropdownFiltersBar from "~/app/_components/filters/dropdown-filters-bar"; + +vi.mock("~/trpc/react", () => ({ + api: { + location: { + getByPrefix: { + useQuery: () => ({ data: [], isLoading: false }), + }, + getByPopularity: { + useQuery: () => ({ data: [], isLoading: false }), + }, + }, + useQueries: () => [], + }, +})); + +vi.mock("~/app/_components/onboarding/constants", () => ({ + industryOptions: { + TECHNOLOGY: { value: "TECHNOLOGY", label: "Technology" }, + HEALTHCARE: { value: "HEALTHCARE", label: "Healthcare" }, + }, + jobTypeOptions: [ + { value: "CO-OP", label: "Co-op" }, + { value: "INTERNSHIP", label: "Internship" }, + ], +})); + +describe("DropdownFiltersBar", () => { + const onFilterChange = vi.fn(); + + beforeEach(() => { + onFilterChange.mockClear(); + }); + + test("renders Industry, Location, Job type, Hourly pay, Overall rating filters", () => { + render( + , + ); + expect(screen.getByText("Industry")).toBeInTheDocument(); + expect(screen.getByText("Location")).toBeInTheDocument(); + expect(screen.getByText("Job type")).toBeInTheDocument(); + expect(screen.getByText("Overall rating")).toBeInTheDocument(); + }); + + test("calls onFilterChange when filter changes", () => { + render( + , + ); + fireEvent.click(screen.getByText("Job type")); + const coop = screen.queryByText("Co-op"); + if (coop) { + fireEvent.click(coop); + expect(onFilterChange).toHaveBeenCalledWith( + expect.objectContaining({ + jobTypes: expect.any(Array) as unknown as string[], + }), + ); + } + }); +}); diff --git a/apps/web/test/form-section.test.tsx b/apps/web/test/form-section.test.tsx new file mode 100644 index 00000000..f78e41c2 --- /dev/null +++ b/apps/web/test/form-section.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { FormSection } from "~/app/_components/form/form-section"; + +describe("FormSection", () => { + test("renders children", () => { + render( + + Section content + , + ); + expect(screen.getByText("Section content")).toBeInTheDocument(); + }); + + test("applies layout classes", () => { + const { container } = render( + +
Content
+
, + ); + const section = container.firstChild as HTMLElement; + expect(section).toHaveClass("flex", "flex-col", "rounded-lg", "w-full"); + }); +}); diff --git a/apps/web/test/form-sections/basic-info-section.test.tsx b/apps/web/test/form-sections/basic-info-section.test.tsx new file mode 100644 index 00000000..1f2b9681 --- /dev/null +++ b/apps/web/test/form-sections/basic-info-section.test.tsx @@ -0,0 +1,147 @@ +import React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { BasicInfoSection } from "~/app/_components/form/sections/basic-info-section"; + +vi.mock("~/trpc/react", () => ({ + api: { + location: { + getByPrefix: { useQuery: () => ({ data: [] }) }, + getById: { useQuery: () => ({ data: undefined }) }, + getByPopularity: { + useQuery: () => ({ data: [], isLoading: false }), + }, + }, + }, +})); + +vi.mock( + "~/app/_components/reviews/new-review/existing-company-content", + () => ({ + default: () =>
Company
, + }), +); + +vi.mock("~/app/_components/location", () => ({ + default: () =>
Location
, +})); + +function Wrapper({ + children, + defaultValues = {}, +}: { + children: React.ReactNode; + defaultValues?: Record; +}) { + const form = useForm({ + defaultValues: { + jobType: "", + workTerm: "", + workYear: undefined, + locationId: "", + industry: "", + companyName: "", + roleName: "", + ...defaultValues, + }, + }); + return {children}; +} + +describe("BasicInfoSection", () => { + test("renders ExistingCompanyContent", () => { + render( + + + , + ); + expect(screen.getByTestId("existing-company-content")).toBeInTheDocument(); + }); + + test("renders Job type label", () => { + render( + + + , + ); + expect(screen.getByText(/Job type/)).toBeInTheDocument(); + }); + + test("renders Co-op/internship term label", () => { + render( + + + , + ); + expect(screen.getByText(/Co-op\/internship term/)).toBeInTheDocument(); + }); + + test("renders Year of co-op/internship term label", () => { + render( + + + , + ); + expect( + screen.getByText(/Year of co-op\/internship term/), + ).toBeInTheDocument(); + }); + + test("renders Industry label", () => { + render( + + + , + ); + expect(screen.getByText(/Industry/)).toBeInTheDocument(); + }); + + test("renders Location label", () => { + render( + + + , + ); + expect(screen.getAllByText(/Location/).length).toBeGreaterThanOrEqual(1); + }); + + test("renders LocationBox", () => { + render( + + + , + ); + expect(screen.getByTestId("location-box")).toBeInTheDocument(); + }); + + test("renders with existing jobType value", () => { + render( + + + , + ); + expect(screen.getByText(/Job type/)).toBeInTheDocument(); + }); + + test("renders with existing workTerm and workYear values", () => { + render( + + + , + ); + expect(screen.getByText(/Co-op\/internship term/)).toBeInTheDocument(); + expect( + screen.getByText(/Year of co-op\/internship term/), + ).toBeInTheDocument(); + }); + + test("renders with existing industry value", () => { + render( + + + , + ); + expect(screen.getByText(/Industry/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/form-sections/company-details-section.test.tsx b/apps/web/test/form-sections/company-details-section.test.tsx new file mode 100644 index 00000000..e069ccea --- /dev/null +++ b/apps/web/test/form-sections/company-details-section.test.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { CompanyDetailsSection } from "~/app/_components/form/sections/company-details-section"; + +function Wrapper({ + children, + defaultValues = {}, +}: { + children: React.ReactNode; + defaultValues?: Record; +}) { + const form = useForm({ + defaultValues: { + workEnvironment: "", + drugTest: undefined, + cultureRating: 0, + supervisorRating: 0, + benefits: undefined, + ...defaultValues, + }, + }); + return {children}; +} + +describe("CompanyDetailsSection", () => { + test("renders Work model label", () => { + render( + + + , + ); + expect(screen.getByText(/Work model/)).toBeInTheDocument(); + }); + + test("renders work environment options", () => { + render( + + + , + ); + expect(screen.getByText(/Work model/)).toBeInTheDocument(); + expect(screen.getByText("In person")).toBeInTheDocument(); + expect(screen.getByText("Hybrid")).toBeInTheDocument(); + expect(screen.getByText("Remote")).toBeInTheDocument(); + }); + + test("renders Drug Test label", () => { + render( + + + , + ); + expect(screen.getByText(/Drug Test/)).toBeInTheDocument(); + }); + + test("renders Drug Test Yes and No options", () => { + render( + + + , + ); + expect(screen.getByText("Yes")).toBeInTheDocument(); + expect(screen.getByText("No")).toBeInTheDocument(); + }); + + test("renders Company Culture label", () => { + render( + + + , + ); + expect(screen.getByText(/Company Culture/)).toBeInTheDocument(); + }); + + test("renders Supervisor rating label", () => { + render( + + + , + ); + expect(screen.getByText(/Supervisor rating/)).toBeInTheDocument(); + }); + + test("renders Benefits label", () => { + render( + + + , + ); + expect(screen.getAllByText(/Benefits/).length).toBeGreaterThanOrEqual(1); + }); + + test("renders with existing workEnvironment value", () => { + render( + + + , + ); + expect(screen.getByText(/Work model/)).toBeInTheDocument(); + }); + + test("renders with existing cultureRating and supervisorRating values", () => { + render( + + + , + ); + expect(screen.getByText(/Company Culture/)).toBeInTheDocument(); + expect(screen.getByText(/Supervisor rating/)).toBeInTheDocument(); + }); + + test("can select drug test Yes", () => { + render( + + + , + ); + const yesLabel = screen.getByText("Yes"); + fireEvent.click(yesLabel); + expect(yesLabel).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/form-sections/interview-section.test.tsx b/apps/web/test/form-sections/interview-section.test.tsx new file mode 100644 index 00000000..9ce96801 --- /dev/null +++ b/apps/web/test/form-sections/interview-section.test.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { InterviewSection } from "~/app/_components/form/sections/interview-section"; + +function Wrapper({ + children, + defaultValues = {}, +}: { + children: React.ReactNode; + defaultValues?: Record; +}) { + const form = useForm({ + defaultValues: { + interviewDifficulty: 0, + ...defaultValues, + }, + }); + return {children}; +} + +describe("InterviewSection", () => { + test("renders Interview difficulty label", () => { + render( + + + , + ); + expect(screen.getByText(/Interview difficulty/)).toBeInTheDocument(); + }); + + test("renders difficulty select with options", () => { + render( + + + , + ); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByText("Select")).toBeInTheDocument(); + }); + + test("renders with existing interviewDifficulty value", () => { + render( + + + , + ); + expect(screen.getByText(/Interview difficulty/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/form-sections/pay-section.test.tsx b/apps/web/test/form-sections/pay-section.test.tsx new file mode 100644 index 00000000..1677e80b --- /dev/null +++ b/apps/web/test/form-sections/pay-section.test.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { PaySection } from "~/app/_components/form/sections/pay-section"; + +vi.mock("~/trpc/react", () => ({ + api: { + location: { + getById: { useQuery: () => ({ data: undefined }) }, + }, + }, +})); + +function Wrapper({ + children, + defaultValues = {}, +}: { + children: React.ReactNode; + defaultValues?: Record; +}) { + const form = useForm({ + defaultValues: { + hourlyPay: "", + locationId: "", + overtimeNormal: undefined, + pto: undefined, + ...defaultValues, + }, + }); + return {children}; +} + +describe("PaySection", () => { + test("renders Hourly pay label", () => { + render( + + + , + ); + expect(screen.getByText(/Hourly pay/)).toBeInTheDocument(); + }); + + test("renders hourly pay input placeholder", () => { + render( + + + , + ); + expect(screen.getByPlaceholderText("$")).toBeInTheDocument(); + }); + + test("renders Unpaid position checkbox label", () => { + render( + + + , + ); + expect(screen.getByText(/Unpaid position/)).toBeInTheDocument(); + }); + + test("renders Worked overtime label", () => { + render( + + + , + ); + expect(screen.getByText(/Worked overtime/)).toBeInTheDocument(); + }); + + test("renders Worked overtime Yes and No options", () => { + render( + + + , + ); + const yesLabels = screen.getAllByText("Yes"); + const noLabels = screen.getAllByText("No"); + expect(yesLabels.length).toBeGreaterThanOrEqual(1); + expect(noLabels.length).toBeGreaterThanOrEqual(1); + }); + + test("renders Received PTO label", () => { + render( + + + , + ); + expect(screen.getByText(/Received PTO/)).toBeInTheDocument(); + }); + + test("toggling Unpaid position disables hourly pay input", () => { + render( + + + , + ); + const unpaidLabel = screen.getByText(/Unpaid position/); + const input = screen.getByPlaceholderText("$"); + expect(input).not.toBeDisabled(); + fireEvent.click(unpaidLabel); + expect(input).toBeDisabled(); + }); + + test("renders with existing hourlyPay value", () => { + render( + + + , + ); + const input = screen.getByPlaceholderText("$"); + expect(input).toHaveValue("25"); + }); + + test("renders with overtimeNormal and pto values", () => { + render( + + + , + ); + expect(screen.getByText(/Worked overtime/)).toBeInTheDocument(); + expect(screen.getByText(/Received PTO/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/form-sections/review-section.test.tsx b/apps/web/test/form-sections/review-section.test.tsx new file mode 100644 index 00000000..68f6f4ff --- /dev/null +++ b/apps/web/test/form-sections/review-section.test.tsx @@ -0,0 +1,58 @@ +import { FormProvider, useForm } from "react-hook-form"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { ReviewSection } from "~/app/_components/form/sections/review-section"; + +function Wrapper({ children }: { children: React.ReactNode }) { + const form = useForm({ + defaultValues: { + overallRating: 0, + textReview: "", + }, + }); + return {children}; +} + +describe("ReviewSection", () => { + test("renders Overall rating label", () => { + render( + + + , + ); + expect(screen.getByText(/Overall rating/)).toBeInTheDocument(); + }); + + test("renders Review text label", () => { + render( + + + , + ); + expect(screen.getByText(/Review text/)).toBeInTheDocument(); + }); + + test("renders review textarea placeholder", () => { + render( + + + , + ); + expect( + screen.getByPlaceholderText(/job duties not mentioned/), + ).toBeInTheDocument(); + }); + + test("renders respectful hint text", () => { + render( + + + , + ); + expect( + screen.getByText( + /Please be respectful and do not mention any specific names/, + ), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/header/header-layout.test.tsx b/apps/web/test/header/header-layout.test.tsx new file mode 100644 index 00000000..4c5b4f15 --- /dev/null +++ b/apps/web/test/header/header-layout.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("@cooper/auth", () => ({ + auth: vi.fn().mockResolvedValue(null), +})); + +vi.mock("~/app/_components/header/header", () => ({ + default: ({ auth }: { auth: React.ReactNode }) => ( +
{auth}
+ ), +})); + +vi.mock("~/app/_components/auth/login-button", () => ({ + default: () => , +})); + +vi.mock("~/app/_components/profile/profile-button", () => ({ + default: () => , +})); + +describe("HeaderLayout", () => { + test("renders children and Header when session is null", async () => { + const HeaderLayout = ( + await import("~/app/_components/header/header-layout") + ).default; + const element = await HeaderLayout({ + children: Page content, + }); + render(element); + expect(screen.getByTestId("header")).toBeInTheDocument(); + expect(screen.getByTestId("child")).toHaveTextContent("Page content"); + expect(screen.getByRole("button", { name: /login/i })).toBeInTheDocument(); + }); + + test("renders article wrapper for children", async () => { + const HeaderLayout = ( + await import("~/app/_components/header/header-layout") + ).default; + const element = await HeaderLayout({ + children: Content, + }); + const { container } = render(element); + const article = container.querySelector("article"); + expect(article).toBeInTheDocument(); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/header/header.test.tsx b/apps/web/test/header/header.test.tsx new file mode 100644 index 00000000..d90bd7eb --- /dev/null +++ b/apps/web/test/header/header.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import Header from "~/app/_components/header/header"; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + onClick, + }: { + children: React.ReactNode; + href: string; + onClick?: (e: React.MouseEvent) => void; + }) => ( + + {children} + + ), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + auth: { + getSession: { + useQuery: () => ({ data: null, isLoading: false }), + }, + }, + }, +})); + +vi.mock("~/app/_components/cooper-logo", () => ({ + default: () =>
Logo
, +})); + +vi.mock("~/app/_components/header/mobile-header-button", () => ({ + default: ({ + label, + children, + href: _href, + }: { + href?: string; + label?: string; + children?: React.ReactNode; + }) => ( +
+ {label} + {children} +
+ ), +})); + +describe("Header", () => { + test("renders Cooper title", () => { + render(
Auth} />); + expect( + screen.getByRole("heading", { name: /cooper/i }), + ).toBeInTheDocument(); + }); + + test("renders Submit Feedback link", () => { + render(
Auth} />); + expect( + screen.getByRole("link", { name: /submit feedback or bug reports/i }), + ).toBeInTheDocument(); + }); + + test("opens mobile menu when burger is clicked", () => { + render(
Auth} />); + const burger = screen.getByRole("button", { name: /hamburger menu/i }); + fireEvent.click(burger); + expect(screen.getByText("Jobs")).toBeInTheDocument(); + expect(screen.getByText("Profile")).toBeInTheDocument(); + }); + + test("closes mobile menu when X is clicked", () => { + render(
Auth} />); + fireEvent.click(screen.getByRole("button", { name: /hamburger menu/i })); + const closeButton = screen.getByRole("button", { name: /^x$/i }); + fireEvent.click(closeButton); + expect(screen.queryByText("Jobs")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/header/mobile-header-button-selected.test.tsx b/apps/web/test/header/mobile-header-button-selected.test.tsx new file mode 100644 index 00000000..0e5b934c --- /dev/null +++ b/apps/web/test/header/mobile-header-button-selected.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import MobileHeaderButton from "~/app/_components/header/mobile-header-button"; + +vi.mock("next/image", () => ({ + default: ({ alt, src }: { alt: string; src: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + onClick, + }: { + children: React.ReactNode; + href: string; + onClick?: () => void; + }) => ( + + {children} + + ), +})); + +vi.mock("next/navigation", () => ({ + usePathname: vi.fn(() => "/roles"), +})); + +describe("MobileHeaderButton selected state", () => { + test("shows selected logo when pathname matches href", () => { + render(); + expect(screen.getByRole("img", { name: "Selected" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/header/mobile-header-button.test.tsx b/apps/web/test/header/mobile-header-button.test.tsx new file mode 100644 index 00000000..35450683 --- /dev/null +++ b/apps/web/test/header/mobile-header-button.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import MobileHeaderButton from "~/app/_components/header/mobile-header-button"; + +vi.mock("next/image", () => ({ + default: ({ alt, src }: { alt: string; src: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + onClick, + }: { + children: React.ReactNode; + href: string; + onClick?: () => void; + }) => ( + + {children} + + ), +})); + +vi.mock("next/navigation", () => ({ + usePathname: vi.fn(() => "/"), +})); + +describe("MobileHeaderButton", () => { + test("renders label", () => { + render(); + expect(screen.getByText("Jobs")).toBeInTheDocument(); + }); + + test("renders as Link when href is provided", () => { + render(); + const link = screen.getByRole("link", { name: /roles/i }); + expect(link).toHaveAttribute("href", "/roles"); + }); + + test("renders icon when iconSrc is provided", () => { + render( + , + ); + expect(screen.getByRole("img", { name: /jobs/i })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/loading-results.test.tsx b/apps/web/test/loading-results.test.tsx new file mode 100644 index 00000000..98e2ef50 --- /dev/null +++ b/apps/web/test/loading-results.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import LoadingResults from "~/app/_components/loading-results"; + +vi.mock("next/image", () => ({ + default: ({ + src, + alt, + width, + }: { + src: string; + alt: string; + width?: number; + }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})); + +describe("LoadingResults", () => { + test("renders loading message", () => { + render(); + expect(screen.getByText("Loading ...")).toBeInTheDocument(); + }); + + test("renders body logo", () => { + render(); + expect(screen.getByTestId("body-logo")).toBeInTheDocument(); + }); + + test("applies custom className", () => { + const { container } = render(); + const section = container.querySelector("section"); + expect(section).toHaveClass("custom-class"); + }); +}); diff --git a/apps/web/test/location.test.tsx b/apps/web/test/location.test.tsx new file mode 100644 index 00000000..c855e7bb --- /dev/null +++ b/apps/web/test/location.test.tsx @@ -0,0 +1,174 @@ +import React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import LocationBox from "~/app/_components/location"; + +vi.mock("~/app/_components/combo-box", () => ({ + default: ({ + defaultLabel, + currLabel, + onSelect, + onClear, + onChange, + }: { + defaultLabel: string; + currLabel: string; + onSelect: (v: string) => void; + onClear?: () => void; + onChange?: (v: string) => void; + }) => ( +
+ {defaultLabel} + {currLabel} + + + onChange?.(e.target.value)} + /> +
+ ), +})); + +function Wrapper({ + children, + defaultValues = {}, +}: { + children: (form: unknown) => React.ReactNode; + defaultValues?: Record; +}) { + const form = useForm({ + defaultValues: { + locationId: "", + searchLocation: "", + searchIndustry: "", + ...defaultValues, + }, + }); + return {children(form)}; +} + +describe("LocationBox", () => { + test("renders ComboBox with form variant when searchBar is false", () => { + render( + + {(form) => ( + + )} + , + ); + expect(screen.getByTestId("location-combobox")).toBeInTheDocument(); + expect(screen.getByTestId("default-label")).toHaveTextContent( + "Search by location...", + ); + }); + + test("renders with Location default label when searchBar is true", () => { + render( + + {(form) => ( + + )} + , + ); + expect(screen.getByTestId("default-label")).toHaveTextContent("Location"); + }); + + test("calls setLocationLabel and field.onChange when option selected (form mode)", () => { + const setLocationLabel = vi.fn(); + let formRef: ReturnType | undefined; + render( + + {(form) => { + formRef = form as ReturnType; + return ( + + ); + }} + , + ); + fireEvent.click(screen.getByTestId("select-location")); + expect(setLocationLabel).toHaveBeenCalledWith("Boston, MA, USA"); + if (!formRef) throw new Error("formRef not set"); + expect(formRef.getValues("locationId")).toBe("loc-1"); + }); + + test("calls setSearchTerm when search input changes", () => { + const setSearchTerm = vi.fn(); + render( + + {(form) => ( + + )} + , + ); + const input = screen.getByTestId("location-search"); + fireEvent.change(input, { target: { value: "Bos" } }); + expect(setSearchTerm).toHaveBeenCalledWith("Bos"); + }); + + test("calls onClear when clear clicked and onClear prop provided", () => { + const onClear = vi.fn(); + render( + + {(form) => ( + + )} + , + ); + fireEvent.click(screen.getByTestId("clear-location")); + expect(onClear).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/test/new-review/existing-company-content.test.tsx b/apps/web/test/new-review/existing-company-content.test.tsx new file mode 100644 index 00000000..6a6dc85b --- /dev/null +++ b/apps/web/test/new-review/existing-company-content.test.tsx @@ -0,0 +1,330 @@ +import React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import ExistingCompanyContent from "~/app/_components/reviews/new-review/existing-company-content"; + +const mockRefetch = vi.fn(); +const mockCompanyListData = [ + { id: "c1", name: "Acme Corp" }, + { id: "c2", name: "Beta Inc" }, +]; + +vi.mock("~/trpc/react", () => ({ + api: { + company: { + list: { + useQuery: () => ({ + data: mockCompanyListData, + refetch: mockRefetch, + }), + }, + createWithRole: { + useMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), + }, + }, + role: { + getByCompany: { + useQuery: () => ({ data: [], refetch: mockRefetch }), + }, + create: { + useMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), + }, + }, + location: { + getByPrefix: { + useQuery: () => ({ data: [] }), + }, + getByPopularity: { + useQuery: () => ({ data: [], isLoading: false }), + }, + }, + }, +})); + +const mockToastError = vi.fn(); +const mockToastSuccess = vi.fn(); +vi.mock("@cooper/ui/hooks/use-custom-toast", () => ({ + useCustomToast: () => ({ + toast: { + error: mockToastError, + success: mockToastSuccess, + }, + }), +})); + +vi.mock("~/app/_components/location", () => ({ + default: () =>
Location
, +})); + +vi.mock("~/app/_components/companies/company-card-preview", () => ({ + CompanyCardPreview: () => ( +
Preview
+ ), +})); + +vi.mock("~/app/_components/combo-box", () => ({ + default: ({ onSelect }: { onSelect?: (label: string) => void }) => ( +
+ +
+ ), +})); + +function Wrapper({ + children, + defaultValues = {}, +}: { + children: React.ReactNode; + defaultValues?: Record; +}) { + const form = useForm({ + defaultValues: { + companyName: "", + industry: "", + locationId: "", + roleName: "", + title: "", + ...defaultValues, + }, + }); + return {children}; +} + +describe("ExistingCompanyContent", () => { + test("renders Company name label", () => { + render( + + + , + ); + expect(screen.getByText(/Company name/)).toBeInTheDocument(); + }); + + test("renders I don't see my company checkbox", () => { + render( + + + , + ); + expect(screen.getByText("I don't see my company")).toBeInTheDocument(); + expect(screen.getAllByRole("checkbox").length).toBeGreaterThan(0); + }); + + test("renders ComboBox for company selection", () => { + render( + + + , + ); + expect(screen.getByTestId("combo-box")).toBeInTheDocument(); + }); + + test("shows Add Your Company section when checkbox is checked", () => { + render( + + + , + ); + const checkboxes = screen.getAllByRole("checkbox"); + const companyCheckbox = + checkboxes.find((el) => + el.closest("div")?.textContent?.includes("I don't see my company"), + ) ?? checkboxes[0]; + if (!companyCheckbox) throw new Error("company checkbox not found"); + fireEvent.click(companyCheckbox); + expect(screen.getByText("Add Your Company")).toBeInTheDocument(); + expect( + screen.getByText(/We'll verify this information/), + ).toBeInTheDocument(); + }); + + test("shows Company Name, Industry, Location, Your Role in Add Your Company section", () => { + render( + + + , + ); + const companyCheckbox = screen + .getAllByRole("checkbox") + .find((el) => + el.closest("div")?.textContent?.includes("I don't see my company"), + ); + if (!companyCheckbox) throw new Error("checkbox not found"); + fireEvent.click(companyCheckbox); + expect(screen.getByText(/Company Name/)).toBeInTheDocument(); + expect(screen.getByText(/Industry/)).toBeInTheDocument(); + expect(screen.getAllByText(/Location/).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/Your Role/).length).toBeGreaterThanOrEqual(1); + expect( + screen.getByRole("button", { name: "Create Company & Role" }), + ).toBeInTheDocument(); + }); + + test("shows Create Company & Role button in Add Your Company section", () => { + render( + + + , + ); + const companyCheckbox = screen + .getAllByRole("checkbox") + .find((el) => + el.closest("div")?.textContent?.includes("I don't see my company"), + ); + if (!companyCheckbox) throw new Error("checkbox not found"); + fireEvent.click(companyCheckbox); + const button = screen.getByRole("button", { + name: "Create Company & Role", + }); + expect(button).toBeInTheDocument(); + }); + + test("calls toast.error when Create Company & Role clicked with empty company name", () => { + mockToastError.mockClear(); + render( + + + , + ); + const companyCheckbox = screen + .getAllByRole("checkbox") + .find((el) => + el.closest("div")?.textContent?.includes("I don't see my company"), + ); + if (companyCheckbox) fireEvent.click(companyCheckbox); + const button = screen.getByRole("button", { + name: "Create Company & Role", + }); + fireEvent.click(button); + expect(mockToastError).toHaveBeenCalledWith( + "Company name must be at least 3 characters.", + ); + }); + + test("shows Adding a review for and CompanyCardPreview when company is selected", () => { + render( + + + , + ); + expect(screen.getByText("Adding a review for")).toBeInTheDocument(); + expect(screen.getByTestId("company-card-preview")).toBeInTheDocument(); + }); + + test("renders Your Role section and I don't see my role checkbox", () => { + render( + + + , + ); + expect(screen.getByText(/Your Role/)).toBeInTheDocument(); + expect(screen.getByText("I don't see my role")).toBeInTheDocument(); + }); + + test("shows Add Your Role section when I don't see my role is checked", () => { + render( + + + , + ); + const roleCheckbox = screen + .getAllByRole("checkbox") + .find((el) => + el.closest("div")?.textContent?.includes("I don't see my role"), + ); + if (roleCheckbox) fireEvent.click(roleCheckbox); + expect(screen.getByText("Add Your Role")).toBeInTheDocument(); + expect( + screen.getByText(/We'll verify this information before it appears/), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Create Role" }), + ).toBeInTheDocument(); + }); + + test("calls toast.error when Create Role clicked without selecting company", () => { + mockToastError.mockClear(); + render( + + + , + ); + const roleCheckbox = screen + .getAllByRole("checkbox") + .find((el) => + el.closest("div")?.textContent?.includes("I don't see my role"), + ); + if (roleCheckbox) fireEvent.click(roleCheckbox); + const createRoleButton = screen.getByRole("button", { + name: "Create Role", + }); + fireEvent.click(createRoleButton); + expect(mockToastError).toHaveBeenCalledWith( + "Please select a company first.", + ); + }); + + test("calls toast.error when Create Role clicked with short title", async () => { + mockToastError.mockClear(); + render( + + + , + ); + fireEvent.click(screen.getByTestId("select-company-c1")); + const roleCheckbox = screen + .getAllByRole("checkbox") + .find((el) => + el.closest("div")?.textContent?.includes("I don't see my role"), + ); + if (roleCheckbox) fireEvent.click(roleCheckbox); + const roleTitleInput = screen.getByPlaceholderText("Enter"); + fireEvent.change(roleTitleInput, { target: { value: "ab" } }); + const createRoleButton = screen.getByRole("button", { + name: "Create Role", + }); + fireEvent.click(createRoleButton); + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith( + "Please enter a valid role title (at least 5 characters).", + ); + }); + }); + + test("renders profileId when passed", () => { + render( + + + , + ); + expect(screen.getByText(/Company name/)).toBeInTheDocument(); + }); + + test("shows No roles available when company selected and roles empty", () => { + render( + + + , + ); + fireEvent.click(screen.getByTestId("select-company-c1")); + expect( + screen.getByText( + "No roles available for this company. Please add a role first.", + ), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/no-results.test.tsx b/apps/web/test/no-results.test.tsx new file mode 100644 index 00000000..204c6f82 --- /dev/null +++ b/apps/web/test/no-results.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import NoResults from "~/app/_components/no-results"; + +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), + usePathname: () => "/roles", +})); + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +describe("NoResults", () => { + test("renders no results message", () => { + render(); + expect(screen.getByText("No Results Found")).toBeInTheDocument(); + }); + + test("does not show Clear Filters when clearFunction is false", () => { + render(); + expect( + screen.queryByRole("button", { name: /clear filters/i }), + ).not.toBeInTheDocument(); + }); + + test("shows Clear Filters when clearFunction is true", () => { + render(); + expect( + screen.getByRole("button", { name: /clear filters/i }), + ).toBeInTheDocument(); + }); + + test("Clear Filters calls router.push with pathname", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /clear filters/i })); + expect(mockPush).toHaveBeenCalledWith("/roles"); + }); +}); diff --git a/apps/web/test/not-found.test.tsx b/apps/web/test/not-found.test.tsx new file mode 100644 index 00000000..aefcb5e4 --- /dev/null +++ b/apps/web/test/not-found.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import NotFound from "~/app/not-found"; + +vi.mock("~/app/_components/header/header-layout", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("~/app/_components/back-button", () => ({ + default: () => , +})); + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +describe("NotFound", () => { + test("renders 404 heading", () => { + render(); + expect(screen.getByText("404")).toBeInTheDocument(); + }); + + test("renders Page Not Found message", () => { + render(); + expect(screen.getByText("Page Not Found")).toBeInTheDocument(); + }); + + test("renders BackButton", () => { + render(); + expect( + screen.getByRole("button", { name: /go back/i }), + ).toBeInTheDocument(); + }); + + test("renders within HeaderLayout", () => { + render(); + expect(screen.getByTestId("header-layout")).toBeInTheDocument(); + }); + + test("renders 404 image on desktop", () => { + render(); + expect(screen.getByTestId("404-image")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/onboarding/browse-around-prompt.test.tsx b/apps/web/test/onboarding/browse-around-prompt.test.tsx new file mode 100644 index 00000000..63fd2a4c --- /dev/null +++ b/apps/web/test/onboarding/browse-around-prompt.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { BrowseAroundPrompt } from "~/app/_components/onboarding/post-onboarding/browse-around-prompt"; + +vi.mock("~/app/_components/onboarding/post-onboarding/welcome-dialog", () => ({ + WelcomeDialog: ({ + heading, + subheading, + buttonText, + }: { + heading: string; + subheading: string; + buttonText: string; + }) => ( +
+ {heading} + {subheading} + {buttonText} +
+ ), +})); + +describe("BrowseAroundPrompt", () => { + test("renders WelcomeDialog with welcome message for first name", () => { + render(); + expect(screen.getByTestId("welcome-dialog")).toBeInTheDocument(); + expect(screen.getByTestId("heading")).toHaveTextContent( + "Welcome to Cooper, Alex!", + ); + expect(screen.getByTestId("subheading")).toHaveTextContent( + "Feel free to browse job reviews and search for companies you may be interested in.", + ); + expect(screen.getByTestId("button-text")).toHaveTextContent("Got it"); + }); +}); diff --git a/apps/web/test/onboarding/constants.test.ts b/apps/web/test/onboarding/constants.test.ts new file mode 100644 index 00000000..c54136cf --- /dev/null +++ b/apps/web/test/onboarding/constants.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; +import { + industryOptions, + majors, + monthOptions, +} from "~/app/_components/onboarding/constants"; + +describe("onboarding constants", () => { + describe("monthOptions", () => { + test("has 12 months", () => { + expect(monthOptions).toHaveLength(12); + }); + + test("includes January and December", () => { + const labels = monthOptions.map((m) => m.label); + expect(labels).toContain("January"); + expect(labels).toContain("December"); + }); + + test("values are string numbers 1-12", () => { + expect(monthOptions[0]).toEqual({ value: "1", label: "January" }); + expect(monthOptions[11]).toEqual({ value: "12", label: "December" }); + }); + }); + + describe("industryOptions", () => { + test("has multiple industries", () => { + expect(industryOptions.length).toBeGreaterThan(10); + }); + + test("includes Technology and Healthcare", () => { + const values = industryOptions.map((i) => i.value); + expect(values).toContain("TECHNOLOGY"); + expect(values).toContain("HEALTHCARE"); + }); + + test("each option has value and label", () => { + industryOptions.forEach((opt) => { + expect(opt).toHaveProperty("value"); + expect(opt).toHaveProperty("label"); + expect(typeof opt.value).toBe("string"); + expect(typeof opt.label).toBe("string"); + }); + }); + }); + + describe("majors", () => { + test("has multiple majors", () => { + expect(majors.length).toBeGreaterThan(10); + }); + + test("is array of strings", () => { + majors.forEach((m) => { + expect(typeof m).toBe("string"); + expect(m.length).toBeGreaterThan(0); + }); + }); + }); +}); diff --git a/apps/web/test/onboarding/coop-prompt.test.tsx b/apps/web/test/onboarding/coop-prompt.test.tsx new file mode 100644 index 00000000..ef774a11 --- /dev/null +++ b/apps/web/test/onboarding/coop-prompt.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { CoopPrompt } from "~/app/_components/onboarding/post-onboarding/coop-prompt"; + +vi.mock("~/app/_components/onboarding/post-onboarding/welcome-dialog", () => ({ + WelcomeDialog: ({ + heading, + subheading, + buttonText, + }: { + heading: string; + subheading: string; + buttonText: string; + }) => ( +
+ {heading} + {subheading} + {buttonText} +
+ ), +})); + +describe("CoopPrompt", () => { + test("renders WelcomeDialog with coop review prompt", () => { + render(); + expect(screen.getByTestId("welcome-dialog")).toBeInTheDocument(); + expect(screen.getByTestId("heading")).toHaveTextContent( + "Welcome to Cooper, Jordan!", + ); + expect(screen.getByTestId("subheading")).toHaveTextContent( + "To get started, please leave a co-op review.", + ); + expect(screen.getByTestId("button-text")).toHaveTextContent( + "Take me there!", + ); + }); +}); diff --git a/apps/web/test/onboarding/dialog-no-profile.test.tsx b/apps/web/test/onboarding/dialog-no-profile.test.tsx new file mode 100644 index 00000000..ab9e3525 --- /dev/null +++ b/apps/web/test/onboarding/dialog-no-profile.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { OnboardingDialog } from "~/app/_components/onboarding/dialog"; + +vi.mock("~/trpc/react", () => ({ + api: { + profile: { + getCurrentUser: { + useQuery: () => ({ data: undefined, isPending: false }), + }, + }, + }, +})); + +vi.mock("~/app/_components/onboarding/onboarding-form", () => ({ + OnboardingForm: () =>
Form
, +})); + +describe("OnboardingDialog when no profile", () => { + test("renders OnboardingForm when session exists and no profile", () => { + render( + , + ); + expect(screen.getByTestId("onboarding-form")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/onboarding/dialog.test.tsx b/apps/web/test/onboarding/dialog.test.tsx new file mode 100644 index 00000000..8b301e14 --- /dev/null +++ b/apps/web/test/onboarding/dialog.test.tsx @@ -0,0 +1,36 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { OnboardingDialog } from "~/app/_components/onboarding/dialog"; + +vi.mock("~/trpc/react", () => ({ + api: { + profile: { + getCurrentUser: { + useQuery: () => ({ + data: { id: "p1" }, + isPending: false, + }), + }, + }, + }, +})); + +vi.mock("~/app/_components/onboarding/onboarding-form", () => ({ + OnboardingForm: () =>
Form
, +})); + +describe("OnboardingDialog", () => { + test("returns null when profile exists (shouldShowOnboarding is false)", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/apps/web/test/onboarding/onboarding-form.test.tsx b/apps/web/test/onboarding/onboarding-form.test.tsx new file mode 100644 index 00000000..92b2b330 --- /dev/null +++ b/apps/web/test/onboarding/onboarding-form.test.tsx @@ -0,0 +1,263 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { Dialog, DialogContent } from "@cooper/ui/dialog"; +import { OnboardingForm } from "~/app/_components/onboarding/onboarding-form"; + +function OnboardingDialogWrapper( + props: React.ComponentProps, +) { + return ( + + + + + + ); +} + +const mockCloseDialog = vi.fn(); +const mockMutate = vi.fn(); +let mockProfileIsSuccess = false; + +vi.mock("~/trpc/react", () => ({ + api: { + profile: { + create: { + useMutation: () => ({ + mutate: mockMutate, + isSuccess: mockProfileIsSuccess, + }), + }, + }, + }, +})); + +vi.mock("~/app/_components/combo-box", () => ({ + default: ({ + defaultLabel, + onSelect, + }: { + defaultLabel: string; + onSelect: (v: string) => void; + }) => ( +
+ {defaultLabel} + +
+ ), +})); + +vi.mock("~/app/_components/themed/onboarding/select", () => ({ + Select: ({ placeholder }: { placeholder?: string }) => ( + + ), +})); + +vi.mock( + "~/app/_components/onboarding/post-onboarding/browse-around-prompt", + () => ({ + BrowseAroundPrompt: ({ + firstName, + onClick, + }: { + firstName: string; + onClick: () => void; + }) => ( +
+ {firstName} + +
+ ), + }), +); + +vi.mock("~/app/_components/onboarding/post-onboarding/coop-prompt", () => ({ + CoopPrompt: ({ + firstName, + onClick, + }: { + firstName: string; + onClick: () => void; + }) => ( +
+ {firstName} + +
+ ), +})); + +const defaultSession = { + user: { + id: "user-1", + email: "jane@example.com", + name: "Jane Doe", + }, + expires: "", +} as never; + +describe("OnboardingForm", () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- vitest beforeEach callback with mocks + beforeEach(() => { + mockMutate.mockClear(); + mockCloseDialog.mockClear(); + }); + + test("renders First Name and Last Name fields", () => { + render( + , + ); + expect(screen.getByPlaceholderText("First")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Last")).toBeInTheDocument(); + }); + + test("renders Email field", () => { + render( + , + ); + expect( + screen.getByPlaceholderText("example@husky.neu.edu"), + ).toBeInTheDocument(); + }); + + test("renders Major ComboBox", () => { + render( + , + ); + expect(screen.getByTestId("combo-box")).toBeInTheDocument(); + expect(screen.getByText("Select major...")).toBeInTheDocument(); + }); + + test("renders Minor, Graduation Year, Graduation Month fields", () => { + render( + , + ); + expect(screen.getByPlaceholderText("Minor")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Year")).toBeInTheDocument(); + expect(screen.getByTestId("graduation-month")).toBeInTheDocument(); + }); + + test("renders co-op checkbox with label", () => { + render( + , + ); + expect( + screen.getByText(/I have completed a co-op or internship/), + ).toBeInTheDocument(); + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + }); + + test("renders Next submit button", () => { + render( + , + ); + expect(screen.getByRole("button", { name: "Finish" })).toBeInTheDocument(); + }); + + test("pre-fills firstName and lastName from session name", () => { + render( + , + ); + expect(screen.getByPlaceholderText("First")).toHaveValue("Jane"); + expect(screen.getByPlaceholderText("Last")).toHaveValue("Doe"); + }); + + test("pre-fills email from session", () => { + render( + , + ); + expect(screen.getByPlaceholderText("example@husky.neu.edu")).toHaveValue( + "jane@example.com", + ); + }); + + test("checking co-op checkbox sets cooped to true", () => { + render( + , + ); + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).not.toBeChecked(); + fireEvent.click(checkbox); + expect(checkbox).toBeChecked(); + }); + + test("unchecking co-op checkbox sets cooped to false", () => { + render( + , + ); + const checkbox = screen.getByRole("checkbox"); + fireEvent.click(checkbox); + expect(checkbox).toBeChecked(); + fireEvent.click(checkbox); + expect(checkbox).not.toBeChecked(); + }); +}); + +describe("OnboardingForm on success", () => { + test("renders BrowseAroundPrompt when profile.isSuccess and cooped is false", () => { + mockProfileIsSuccess = true; + render( + , + ); + expect(screen.getByTestId("browse-around-prompt")).toBeInTheDocument(); + expect(screen.getByTestId("browse-around-prompt")).toHaveTextContent( + "Jane", + ); + mockProfileIsSuccess = false; + }); +}); diff --git a/apps/web/test/onboarding/onboarding-wrapper.test.tsx b/apps/web/test/onboarding/onboarding-wrapper.test.tsx new file mode 100644 index 00000000..0c70014d --- /dev/null +++ b/apps/web/test/onboarding/onboarding-wrapper.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("@cooper/auth", () => ({ + auth: vi.fn().mockResolvedValue({ user: { id: "u1" }, session: {} }), +})); + +vi.mock("~/app/_components/onboarding/dialog", () => ({ + OnboardingDialog: ({ session }: { session: unknown }) => ( +
+ {session ? "Dialog" : "No session"} +
+ ), +})); + +describe("OnboardingWrapper", () => { + test("renders children and OnboardingDialog when session exists", async () => { + const OnboardingWrapper = ( + await import("~/app/_components/onboarding/onboarding-wrapper") + ).default; + const element = await OnboardingWrapper({ + children: Page, + }); + render(element); + expect(screen.getByTestId("child")).toHaveTextContent("Page"); + expect(screen.getByTestId("onboarding-dialog")).toBeInTheDocument(); + expect(screen.getByTestId("onboarding-dialog")).toHaveTextContent("Dialog"); + }); +}); diff --git a/apps/web/test/onboarding/welcome-dialog.test.tsx b/apps/web/test/onboarding/welcome-dialog.test.tsx new file mode 100644 index 00000000..7b784a4c --- /dev/null +++ b/apps/web/test/onboarding/welcome-dialog.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { WelcomeDialog } from "~/app/_components/onboarding/post-onboarding/welcome-dialog"; + +vi.mock("next/image", () => ({ + default: ({ alt, src }: { alt: string; src: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +describe("WelcomeDialog", () => { + const onClick = vi.fn(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- vitest beforeEach callback with mocks + beforeEach(() => { + onClick.mockClear(); + }); + + test("renders heading and subheading", () => { + render( + , + ); + expect(screen.getByText("Welcome!")).toBeInTheDocument(); + expect(screen.getByText("Get started.")).toBeInTheDocument(); + }); + + test("renders button with buttonText", () => { + render( + , + ); + const button = screen.getByRole("button", { name: /take me there/i }); + expect(button).toBeInTheDocument(); + }); + + test("calls onClick when button is clicked", () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /got it/i })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test("renders Cooper Logo image", () => { + render( + , + ); + expect( + screen.getByRole("img", { name: /cooper logo/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/profile-page.test.tsx b/apps/web/test/profile-page.test.tsx new file mode 100644 index 00000000..82992de0 --- /dev/null +++ b/apps/web/test/profile-page.test.tsx @@ -0,0 +1,112 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import Profile from "~/app/(pages)/(protected)/profile/page"; + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), + useSearchParams: vi.fn(() => new URLSearchParams("tab=saved-roles")), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) => {children}, +})); + +vi.mock("next/image", () => ({ + default: ({ alt, src }: { alt: string; src: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +const mockSession = { + user: { id: "u1", email: "test@neu.edu", image: "/svg/defaultProfile.svg" }, + session: {}, +}; +const mockProfile = { + id: "p1", + firstName: "Test", + lastName: "User", + graduationYear: 2025, +}; + +vi.mock("~/trpc/react", () => ({ + api: { + auth: { + getSession: { + useQuery: () => ({ + data: mockSession, + isLoading: false, + error: null, + }), + }, + }, + profile: { + getCurrentUser: { + useQuery: () => ({ + data: mockProfile, + isLoading: false, + error: null, + }), + }, + listFavoriteRoles: { + useQuery: () => ({ data: [], enabled: true }), + }, + listFavoriteCompanies: { + useQuery: () => ({ data: [], enabled: true }), + }, + }, + review: { + getByProfile: { + useQuery: () => ({ data: [], enabled: true }), + }, + }, + useQueries: () => [], + }, +})); + +vi.mock("~/app/_components/profile/profile-card-header", () => ({ + default: () =>
Profile Card
, +})); + +vi.mock("~/app/_components/profile/profile-tabs", () => ({ + default: () =>
Tabs
, +})); + +vi.mock("~/app/_components/profile/favorite-role-search", () => ({ + default: () =>
Favorite Roles
, +})); + +vi.mock("~/app/_components/profile/favorite-company-search", () => ({ + default: () => ( +
Favorite Companies
+ ), +})); + +vi.mock("~/app/_components/reviews/review-card", () => ({ + ReviewCard: () =>
Review
, +})); + +describe("Profile page", () => { + test("renders profile name and graduation year when session and profile exist", () => { + render(); + expect(screen.getByText("Test User")).toBeInTheDocument(); + expect(screen.getByText("Class of 2025")).toBeInTheDocument(); + }); + + test("renders ProfileCardHeader and ProfileTabs", () => { + render(); + expect(screen.getByTestId("profile-card-header")).toBeInTheDocument(); + expect(screen.getByTestId("profile-tabs")).toBeInTheDocument(); + }); + + test("renders FavoriteRoleSearch when tab is saved-roles", () => { + render(); + expect(screen.getByTestId("favorite-role-search")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/profile/favorite-company-search.test.tsx b/apps/web/test/profile/favorite-company-search.test.tsx new file mode 100644 index 00000000..8465ee13 --- /dev/null +++ b/apps/web/test/profile/favorite-company-search.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import FavoriteCompanySearch from "~/app/_components/profile/favorite-company-search"; + +vi.mock("~/app/_components/companies/company-card-preview", () => ({ + CompanyCardPreview: ({ companyObj }: { companyObj: { name: string } }) => ( +
{companyObj.name}
+ ), +})); + +describe("FavoriteCompanySearch", () => { + test("renders search input with placeholder", () => { + render(); + expect( + screen.getByPlaceholderText("Search for a saved company..."), + ).toBeInTheDocument(); + }); + + test("renders No saved companies found when list is empty", () => { + render(); + expect(screen.getByText("No saved companies found.")).toBeInTheDocument(); + }); + + test("renders company cards when companies are provided", () => { + const companies = [ + { id: "c1", name: "Acme Corp" }, + { id: "c2", name: "Beta Inc" }, + ] as never[]; + render(); + expect(screen.getByText("Acme Corp")).toBeInTheDocument(); + expect(screen.getByText("Beta Inc")).toBeInTheDocument(); + expect(screen.getAllByTestId("company-card")).toHaveLength(2); + }); + + test("filters companies by search prefix", () => { + const companies = [ + { id: "c1", name: "Acme Corp" }, + { id: "c2", name: "Beta Inc" }, + ] as never[]; + render(); + const input = screen.getByPlaceholderText("Search for a saved company..."); + fireEvent.change(input, { target: { value: "Ac" } }); + expect(screen.getByText("Acme Corp")).toBeInTheDocument(); + expect(screen.queryByText("Beta Inc")).not.toBeInTheDocument(); + }); + + test("shows no results message when filter matches nothing", () => { + const companies = [{ id: "c1", name: "Acme Corp" }] as never[]; + render(); + const input = screen.getByPlaceholderText("Search for a saved company..."); + fireEvent.change(input, { target: { value: "Z" } }); + expect(screen.getByText("No saved companies found.")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/profile/favorite-role-search.test.tsx b/apps/web/test/profile/favorite-role-search.test.tsx new file mode 100644 index 00000000..39f2aae9 --- /dev/null +++ b/apps/web/test/profile/favorite-role-search.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import FavoriteRoleSearch from "~/app/_components/profile/favorite-role-search"; + +vi.mock("~/app/_components/reviews/role-card-preview", () => ({ + RoleCardPreview: ({ roleObj }: { roleObj: { title: string } }) => ( +
{roleObj.title}
+ ), +})); + +vi.mock("@cooper/ui", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + Pagination: ({ + currentPage, + totalPages, + onPageChange, + }: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + }) => ( +
+ {currentPage} + {totalPages} + +
+ ), + }; +}); + +describe("FavoriteRoleSearch", () => { + test("renders search input with placeholder", () => { + render(); + expect( + screen.getByPlaceholderText("Search for a saved role..."), + ).toBeInTheDocument(); + }); + + test("renders No saved roles found when list is empty", () => { + render(); + expect(screen.getByText("No saved roles found.")).toBeInTheDocument(); + }); + + test("renders role cards when roles are provided", () => { + const roles = [ + { id: "r1", title: "Software Engineer" }, + { id: "r2", title: "Data Analyst" }, + ] as never[]; + render(); + expect(screen.getByText("Software Engineer")).toBeInTheDocument(); + expect(screen.getByText("Data Analyst")).toBeInTheDocument(); + expect(screen.getAllByTestId("role-card")).toHaveLength(2); + }); + + test("renders Pagination with totalPages from role count", () => { + const roles = Array.from({ length: 10 }, (_, i) => ({ + id: `r${i}`, + title: `Role ${i}`, + })) as never[]; + render(); + expect(screen.getByTestId("pagination")).toBeInTheDocument(); + expect(screen.getByTestId("total")).toHaveTextContent("2"); + }); + + test("filters roles by search prefix", () => { + const roles = [ + { id: "r1", title: "Engineer" }, + { id: "r2", title: "Designer" }, + ] as never[]; + render(); + const input = screen.getByPlaceholderText("Search for a saved role..."); + fireEvent.change(input, { target: { value: "Eng" } }); + expect(screen.getByText("Engineer")).toBeInTheDocument(); + expect(screen.queryByText("Designer")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/profile/profile-button-client.test.tsx b/apps/web/test/profile/profile-button-client.test.tsx new file mode 100644 index 00000000..d7d06ca3 --- /dev/null +++ b/apps/web/test/profile/profile-button-client.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import ProfileButtonClient from "~/app/_components/profile/profile-button-client"; + +vi.mock("next/image", () => ({ + default: ({ src, alt }: { src: string; alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) => {children}, +})); + +vi.mock("~/app/_components/auth/actions", () => ({ + handleSignOut: vi.fn(), +})); + +vi.mock("@cooper/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuSeparator: () =>
, +})); + +type SessionLike = Parameters[0]["session"]; + +describe("ProfileButtonClient", () => { + const mockSession: SessionLike = { + user: { + id: "u1", + email: "test@neu.edu", + name: "Test User", + image: "/custom-avatar.png", + }, + expires: "", + }; + + test("renders profile image with session user image", () => { + render(); + const img = screen.getByTestId("profile-image"); + expect(img).toHaveAttribute("src", "/custom-avatar.png"); + expect(img).toHaveAttribute("alt", "Profile"); + }); + + test("renders default image when session user image is null", () => { + const sessionNoImage: SessionLike = { + ...mockSession, + user: { ...mockSession.user, image: null }, + }; + render(); + const img = screen.getByTestId("profile-image"); + expect(img).toHaveAttribute("src", "/svg/defaultProfile.svg"); + }); + + test("renders Profile link in dropdown content", () => { + render(); + const profileLink = screen.getByRole("link", { name: "Profile" }); + expect(profileLink).toHaveAttribute("href", "/profile"); + }); + + test("renders Log Out button in dropdown content", () => { + render(); + expect( + screen.getByRole("button", { name: /log out/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/profile/profile-button.test.tsx b/apps/web/test/profile/profile-button.test.tsx new file mode 100644 index 00000000..2c0d8dd0 --- /dev/null +++ b/apps/web/test/profile/profile-button.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import ProfileButton from "~/app/_components/profile/profile-button"; + +vi.mock("next/image", () => ({ + default: ({ src, alt }: { src: string; alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) => {children}, +})); + +vi.mock("@cooper/auth", () => ({ + signOut: vi.fn(), +})); + +vi.mock("@cooper/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuSeparator: () =>
, +})); + +describe("ProfileButton", () => { + const mockSession = { + user: { + id: "u1", + email: "test@neu.edu", + name: "Test User", + image: null, + }, + session: {}, + } as never; + + test("renders profile image", () => { + render(); + const img = screen.getByTestId("profile-image"); + expect(img).toHaveAttribute("alt", "Logout"); + expect(img).toHaveAttribute("src", "/svg/defaultProfile.svg"); + }); + + test("renders Profile link in dropdown content", () => { + render(); + const profileLink = screen.getByRole("link", { name: "Profile" }); + expect(profileLink).toHaveAttribute("href", "/profile"); + }); + + test("renders Log Out button in dropdown content", () => { + render(); + expect( + screen.getByRole("button", { name: /log out/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/profile/profile-card-header.test.tsx b/apps/web/test/profile/profile-card-header.test.tsx new file mode 100644 index 00000000..089a6530 --- /dev/null +++ b/apps/web/test/profile/profile-card-header.test.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import ProfileCardHeader from "~/app/_components/profile/profile-card-header"; + +const mockMutate = vi.fn(); +vi.mock("~/trpc/react", () => ({ + api: { + useUtils: () => ({ + profile: { + getCurrentUser: { invalidate: vi.fn() }, + }, + }), + profile: { + updateNameAndMajor: { + useMutation: (opts: { onSuccess?: () => void }) => ({ + mutate: (vars: unknown) => { + mockMutate(vars); + opts.onSuccess?.(); + }, + isPending: false, + error: null, + }), + }, + }, + }, +})); + +const defaultProfile = { + id: "profile-1", + firstName: "Jane", + lastName: "Doe", + major: "Computer Science", + graduationYear: 2025, +}; + +describe("ProfileCardHeader", () => { + test("renders Account Information title", () => { + render( + , + ); + expect(screen.getByText("Account Information")).toBeInTheDocument(); + }); + + test("renders name, email, and major in view mode", () => { + render( + , + ); + expect(screen.getByText("Jane Doe")).toBeInTheDocument(); + expect(screen.getByText("jane@example.com")).toBeInTheDocument(); + expect(screen.getByText("Computer Science")).toBeInTheDocument(); + }); + + test("renders Edit button when not editing", () => { + render( + , + ); + expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(); + }); + + test("switches to edit mode when Edit clicked", () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + expect(screen.getByLabelText(/First name/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Last name/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Major/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + }); + + test("Cancel restores original values and exits edit mode", () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + const firstNameInput = screen.getByLabelText(/First name/); + fireEvent.change(firstNameInput, { target: { value: "Changed" } }); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.getByText("Jane Doe")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(); + }); + + test("Save calls mutation with trimmed values", () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(mockMutate).toHaveBeenCalledWith({ + id: "profile-1", + firstName: "Jane", + lastName: "Doe", + major: "Computer Science", + }); + }); + + test("Save button disabled when first or last name is empty", () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + fireEvent.change(screen.getByLabelText(/First name/), { + target: { value: "" }, + }); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + test("renders major label when profile.major is null", () => { + render( + , + ); + expect(screen.getByText("Major")).toBeInTheDocument(); + // Major value is not displayed when null (empty content) + expect(screen.getByText("Name")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/profile/profile-tabs.test.tsx b/apps/web/test/profile/profile-tabs.test.tsx new file mode 100644 index 00000000..4e163c1c --- /dev/null +++ b/apps/web/test/profile/profile-tabs.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import ProfileTabs from "~/app/_components/profile/profile-tabs"; + +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + usePathname: () => "/profile", + useRouter: () => ({ push: mockPush }), + useSearchParams: () => new URLSearchParams("tab=saved-roles"), +})); + +describe("ProfileTabs", () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- vitest beforeEach callback with mocks + beforeEach(() => { + mockPush.mockClear(); + }); + + test("renders Saved roles, Saved companies, My reviews tabs", () => { + render(); + expect( + screen.getByRole("button", { name: /saved roles/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /saved companies/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /my reviews \(5\)/i }), + ).toBeInTheDocument(); + }); + + test("renders numReviews in My reviews tab", () => { + render(); + expect( + screen.getByRole("button", { name: /my reviews \(12\)/i }), + ).toBeInTheDocument(); + }); + + test("has nav with aria-label Tabs", () => { + render(); + const nav = screen.getByRole("navigation", { name: /tabs/i }); + expect(nav).toBeInTheDocument(); + }); + + test("clicking tab calls router.push with tab param", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /saved companies/i })); + expect(mockPush).toHaveBeenCalledWith("/profile?tab=saved-companies"); + }); + + test("clicking My reviews calls router.push with my-reviews tab", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /my reviews \(3\)/i })); + expect(mockPush).toHaveBeenCalledWith("/profile?tab=my-reviews"); + }); +}); diff --git a/apps/web/test/protected-layout-redirect.test.tsx b/apps/web/test/protected-layout-redirect.test.tsx new file mode 100644 index 00000000..c26e54a2 --- /dev/null +++ b/apps/web/test/protected-layout-redirect.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, test, vi } from "vitest"; + +const mockRedirect = vi.fn(); +vi.mock("next/navigation", () => ({ + redirect: (path: string) => { + mockRedirect(path); + throw new Error("NEXT_REDIRECT"); + }, +})); + +vi.mock("@cooper/auth", () => ({ + auth: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@cooper/ui", () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(" "), + CustomToaster: () => null, +})); + +vi.mock("~/app/_components/header/header-layout", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +describe("ProtectedLayout redirect when no session", () => { + test("calls redirect(/) when session is null", async () => { + mockRedirect.mockClear(); + const ProtectedLayout = (await import("~/app/(pages)/(protected)/layout")) + .default; + await expect( + ProtectedLayout({ children:
Child
}), + ).rejects.toThrow("NEXT_REDIRECT"); + expect(mockRedirect).toHaveBeenCalledWith("/"); + }); +}); diff --git a/apps/web/test/protected-layout.test.tsx b/apps/web/test/protected-layout.test.tsx new file mode 100644 index 00000000..0e6eaa85 --- /dev/null +++ b/apps/web/test/protected-layout.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +const mockRedirect = vi.fn(); +vi.mock("next/navigation", () => ({ + redirect: (path: string) => { + mockRedirect(path); + throw new Error("NEXT_REDIRECT"); + }, +})); + +vi.mock("@cooper/auth", () => ({ + auth: vi.fn().mockResolvedValue({ + user: { id: "user-1", email: "test@neu.edu" }, + session: {}, + }), +})); + +vi.mock("@cooper/ui", () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(" "), + CustomToaster: () =>
Toaster
, +})); + +vi.mock("~/app/_components/header/header-layout", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +describe("ProtectedLayout", () => { + test("renders children and HeaderLayout when session exists", async () => { + const ProtectedLayout = (await import("~/app/(pages)/(protected)/layout")) + .default; + const element = await ProtectedLayout({ + children: Protected, + }); + render(element); + expect(screen.getByTestId("header-layout")).toBeInTheDocument(); + expect(screen.getByTestId("protected-child")).toHaveTextContent( + "Protected", + ); + expect(screen.getByTestId("custom-toaster")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/redirection-page.test.tsx b/apps/web/test/redirection-page.test.tsx new file mode 100644 index 00000000..e6f268c2 --- /dev/null +++ b/apps/web/test/redirection-page.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import ErrorPage from "~/app/(pages)/(dashboard)/redirection/page"; + +describe("Redirection / Error page", () => { + test("renders Authentication Error heading", () => { + render(); + expect( + screen.getByRole("heading", { name: /authentication error/i }), + ).toBeInTheDocument(); + }); + + test("renders husky.neu.edu message", () => { + render(); + expect( + screen.getByText(/you must log in with husky\.neu\.edu/i), + ).toBeInTheDocument(); + }); + + test("renders sign in hint", () => { + render(); + expect( + screen.getByText(/click the sign in button to try again/i), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/review-card-stars.test.tsx b/apps/web/test/review-card-stars.test.tsx new file mode 100644 index 00000000..3124e113 --- /dev/null +++ b/apps/web/test/review-card-stars.test.tsx @@ -0,0 +1,31 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { ReviewCardStars } from "~/app/_components/reviews/review-card-stars"; + +describe("ReviewCardStars", () => { + test("renders 5 full stars for 5", () => { + const { container } = render(); + const yellowStars = container.querySelectorAll('[fill="#FF9900"]'); + expect(yellowStars.length).toBeGreaterThanOrEqual(5); + }); + + test("renders 3 full stars for 3", () => { + const { container } = render(); + const yellowStars = container.querySelectorAll('[fill="#FF9900"]'); + expect(yellowStars.length).toBeGreaterThanOrEqual(3); + }); + + test("renders mixed stars for 3.5", () => { + const { container } = render(); + const yellowStars = container.querySelectorAll('[fill="#FF9900"]'); + const grayStars = container.querySelectorAll('[fill="#C1C1C1"]'); + expect(yellowStars.length).toBeGreaterThanOrEqual(3); + expect(grayStars.length).toBeGreaterThanOrEqual(1); + }); + + test("renders fractional bar for decimal", () => { + const { container } = render(); + const fractionalBar = container.querySelector(".overflow-hidden"); + expect(fractionalBar).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/review-form-page.test.tsx b/apps/web/test/review-form-page.test.tsx new file mode 100644 index 00000000..81f48ecf --- /dev/null +++ b/apps/web/test/review-form-page.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import ReviewForm from "~/app/(pages)/(protected)/review-form/page"; + +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), +})); + +const mockSession = { user: {} }; +const mockProfile = { id: "profile-1" }; +const mockReviewsData: { workTerm: string; workYear: number }[] = []; +vi.mock("~/trpc/react", () => ({ + api: { + auth: { + getSession: { + useQuery: () => ({ data: mockSession, isLoading: false }), + }, + }, + profile: { + getCurrentUser: { + useQuery: () => ({ data: mockProfile, isLoading: false }), + }, + }, + review: { + getByProfile: { + useQuery: () => ({ data: mockReviewsData }), + }, + create: { + useMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), + }, + }, + }, +})); + +vi.mock("~/app/_components/form/sections", () => ({ + BasicInfoSection: () => ( +
Basic info
+ ), + CompanyDetailsSection: () => ( +
Company details
+ ), + InterviewSection: () =>
Interview
, + ReviewSection: () =>
Review
, +})); + +vi.mock("~/app/_components/form/sections/pay-section", () => ({ + PaySection: () =>
Pay
, +})); + +describe("ReviewForm page", () => { + test("renders Basic information section", () => { + render(); + expect(screen.getByText("Basic information")).toBeInTheDocument(); + expect(screen.getByTestId("basic-info-section")).toBeInTheDocument(); + }); + + test("renders On the job, Pay, Interview, Review sections when can review", () => { + render(); + expect(screen.getByText("On the job")).toBeInTheDocument(); + expect(screen.getByTestId("pay-section")).toBeInTheDocument(); + expect(screen.getByTestId("interview-section")).toBeInTheDocument(); + expect(screen.getByText("Review and rate")).toBeInTheDocument(); + }); + + test("renders Submit review button", () => { + render(); + expect( + screen.getByRole("button", { name: "Submit review" }), + ).toBeInTheDocument(); + }); + + test.skip("shows too many reviews message when canReviewForTerm is false", () => { + // Requires mocking useForm.getValues (workTerm/workYear) to match review data; skip for now + mockReviewsData.length = 0; + mockReviewsData.push( + { workTerm: "SUMMER", workYear: 2024 }, + { workTerm: "SUMMER", workYear: 2024 }, + ); + render(); + expect( + screen.getByText("You already submitted too many reviews for this term"), + ).toBeInTheDocument(); + mockReviewsData.length = 0; + }); +}); diff --git a/apps/web/test/reviews/collapsable-info.test.tsx b/apps/web/test/reviews/collapsable-info.test.tsx new file mode 100644 index 00000000..fafbc925 --- /dev/null +++ b/apps/web/test/reviews/collapsable-info.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import CollapsableInfoCard from "~/app/_components/reviews/collapsable-info"; + +describe("CollapsableInfoCard", () => { + test("renders title", () => { + render( + +

Content

+
, + ); + expect(screen.getByText("Test Title")).toBeInTheDocument(); + }); + + test("renders children when expanded", () => { + render( + +

Child content

+
, + ); + expect(screen.getByText("Child content")).toBeInTheDocument(); + }); + + test("toggles content visibility when button is clicked", () => { + render( + +

Child content

+
, + ); + const button = screen.getByRole("button", { name: /section/i }); + expect(screen.getByText("Child content")).toBeInTheDocument(); + fireEvent.click(button); + // Content wrapper gets max-h-0 opacity-0 when collapsed + const content = screen.getByText("Child content"); + const contentWrapper = content.parentElement?.parentElement; + expect(contentWrapper).toHaveClass("max-h-0"); + fireEvent.click(button); + expect(contentWrapper).toHaveClass("h-fit"); + }); + + test("button has chevron that rotates when expanded", () => { + render( + +

Content

+
, + ); + const button = screen.getByRole("button"); + const svg = button.querySelector("svg"); + expect(svg).toHaveClass("rotate-180"); + }); +}); diff --git a/apps/web/test/reviews/delete-review-dialogue.test.tsx b/apps/web/test/reviews/delete-review-dialogue.test.tsx new file mode 100644 index 00000000..5fade874 --- /dev/null +++ b/apps/web/test/reviews/delete-review-dialogue.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { DeleteReviewDialog } from "~/app/_components/reviews/delete-review-dialogue"; + +vi.mock("~/trpc/react", () => ({ + api: { + review: { + delete: { + useMutation: (opts: { + onSuccess?: () => void; + onError?: () => void; + }) => ({ + mutate: vi.fn(() => { + opts.onSuccess?.(); + }), + isPending: false, + }), + }, + }, + }, +})); + +vi.mock("@cooper/ui/hooks/use-custom-toast", () => ({ + useCustomToast: () => ({ + toast: { success: vi.fn(), error: vi.fn() }, + }), +})); + +const reload = vi.fn(); +Object.defineProperty(window, "location", { + value: { reload }, + writable: true, +}); + +describe("DeleteReviewDialog", () => { + test("renders trigger button", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + }); + + test("opens dialog with title and description when trigger clicked", () => { + render(); + fireEvent.click(screen.getByRole("button")); + expect(screen.getAllByText("Delete Review").length).toBeGreaterThan(0); + expect( + screen.getByText(/Are you sure you want to delete this review/), + ).toBeInTheDocument(); + }); + + test("shows Delete Review submit button in dialog", () => { + render(); + fireEvent.click(screen.getByRole("button")); + expect( + screen.getAllByRole("button", { name: "Delete Review" }).length, + ).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/test/reviews/info-card.test.tsx b/apps/web/test/reviews/info-card.test.tsx new file mode 100644 index 00000000..5bf3d1f8 --- /dev/null +++ b/apps/web/test/reviews/info-card.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import InfoCard from "~/app/_components/reviews/info-card"; + +describe("InfoCard", () => { + test("renders title", () => { + render( + +

Description text

+
, + ); + expect(screen.getByText("Job Description")).toBeInTheDocument(); + }); + + test("renders children", () => { + render( + +

Child content

+
, + ); + expect(screen.getByText("Child content")).toBeInTheDocument(); + }); + + test("applies title in header and content in body", () => { + render( + + Body content + , + ); + expect(screen.getByText("About Company")).toBeInTheDocument(); + expect(screen.getByTestId("body")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/reviews/new-role-card.test.tsx b/apps/web/test/reviews/new-role-card.test.tsx new file mode 100644 index 00000000..a693be4a --- /dev/null +++ b/apps/web/test/reviews/new-role-card.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import NewRoleCard from "~/app/_components/reviews/new-role-card"; + +const mockUseQuery = vi.fn(() => ({ + data: null as { session: { user: unknown } } | null, +})); +vi.mock("~/trpc/react", () => ({ + api: { + auth: { + getSession: { + useQuery: () => mockUseQuery(), + }, + }, + }, +})); + +vi.mock("~/app/_components/reviews/new-role-dialogue", () => ({ + default: ({ disabled }: { disabled: boolean }) => ( + + ), +})); + +describe("NewRoleCard", () => { + test("renders sign in message when not authorized", () => { + mockUseQuery.mockReturnValue({ data: null }); + render(); + expect( + screen.getByText("Sign in to create a new role"), + ).toBeInTheDocument(); + }); + + test("renders Create New Role button disabled when not authorized", () => { + mockUseQuery.mockReturnValue({ data: null }); + render(); + const button = screen.getByRole("button", { name: "Create New Role" }); + expect(button).toBeDisabled(); + }); + + test("renders Don't see your role when authorized", () => { + mockUseQuery.mockReturnValue({ data: { session: { user: {} } } }); + render(); + expect(screen.getByText("Don't see your role?")).toBeInTheDocument(); + }); + + test("renders Create New Role button enabled when authorized", () => { + mockUseQuery.mockReturnValue({ data: { session: { user: {} } } }); + render(); + const button = screen.getByRole("button", { name: "Create New Role" }); + expect(button).not.toBeDisabled(); + }); +}); diff --git a/apps/web/test/reviews/new-role-dialogue.test.tsx b/apps/web/test/reviews/new-role-dialogue.test.tsx new file mode 100644 index 00000000..86e867b7 --- /dev/null +++ b/apps/web/test/reviews/new-role-dialogue.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import NewRoleDialog from "~/app/_components/reviews/new-role-dialogue"; + +vi.mock("~/trpc/react", () => ({ + api: { + company: { + getById: { + useQuery: () => ({ data: { name: "Acme Corp" } }), + }, + }, + profile: { + getCurrentUser: { + useQuery: () => ({ data: { id: "profile-1" } }), + }, + }, + role: { + getByCreatedBy: { + useQuery: () => ({ data: [] }), + }, + create: { + useMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), + }, + }, + }, +})); + +vi.mock("@cooper/ui/hooks/use-custom-toast", () => ({ + useCustomToast: () => ({ toast: { error: vi.fn(), success: vi.fn() } }), +})); + +vi.stubGlobal( + "setTimeout", + vi.fn((fn: () => void) => fn()), +); + +describe("NewRoleDialog", () => { + test("renders trigger button", () => { + render(); + expect( + screen.getByRole("button", { name: /Create New Role/i }), + ).toBeInTheDocument(); + }); + + test("trigger is disabled when disabled prop is true", () => { + render(); + expect( + screen.getByRole("button", { name: /Create New Role/i }), + ).toBeDisabled(); + }); + + test("opens dialog with title when trigger clicked", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /Create New Role/i })); + expect(screen.getByText("Create New Role")).toBeInTheDocument(); + expect( + screen.getByText(/Request a new role for Acme Corp/), + ).toBeInTheDocument(); + }); + + test("shows Role Name and Description fields", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /Create New Role/i })); + expect(screen.getByText("Role Name")).toBeInTheDocument(); + expect(screen.getByText("Description")).toBeInTheDocument(); + }); + + test("shows Submit button in dialog", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /Create New Role/i })); + expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/reviews/review-card.test.tsx b/apps/web/test/reviews/review-card.test.tsx new file mode 100644 index 00000000..575eabb9 --- /dev/null +++ b/apps/web/test/reviews/review-card.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { ReviewCard } from "~/app/_components/reviews/review-card"; + +vi.mock("next/image", () => ({ + default: ({ alt, src }: { alt: string; src: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +const mockGetById = vi.fn(() => ({ + data: null as { city: string; state: string; country: string } | null, +})); +const mockCurrentUser = vi.fn(() => ({ + data: null as { id: string } | null, +})); +vi.mock("~/trpc/react", () => ({ + api: { + profile: { + getCurrentUser: { + useQuery: () => mockCurrentUser(), + }, + }, + location: { + getById: { + useQuery: () => mockGetById(), + }, + }, + }, +})); + +vi.mock("~/app/_components/reviews/delete-review-dialogue", () => ({ + DeleteReviewDialog: () => Delete, +})); + +describe("ReviewCard", () => { + const reviewObj = { + id: "rev-1", + profileId: "profile-1", + locationId: "loc-1", + overallRating: 4.5, + workTerm: "SUMMER", + workYear: "2024", + textReview: "Great experience.", + workEnvironment: "HYBRID", + hourlyPay: 25, + reviewHeadline: null, + interviewReview: null, + } as never; + + test("renders overall rating", () => { + mockGetById.mockReturnValue({ data: null }); + render(); + expect(screen.getByText("4.5")).toBeInTheDocument(); + }); + + test("renders work term and year", () => { + mockGetById.mockReturnValue({ data: null }); + render(); + expect(screen.getByText(/Summer 2024/)).toBeInTheDocument(); + }); + + test("renders text review", () => { + mockGetById.mockReturnValue({ data: null }); + render(); + expect(screen.getAllByText("Great experience.").length).toBeGreaterThan(0); + }); + + test("renders job type and pay", () => { + mockGetById.mockReturnValue({ data: null }); + render(); + expect(screen.getByText(/Co-op/)).toBeInTheDocument(); + expect(screen.getByText(/\$25\/hr/)).toBeInTheDocument(); + }); + + test("renders location when location data is present", () => { + mockGetById.mockReturnValue({ + data: { + city: "Boston", + state: "MA", + country: "USA", + }, + }); + render(); + expect(screen.getByText(/Boston/)).toBeInTheDocument(); + }); + + test("does not show delete dialog when user is not author", () => { + mockGetById.mockReturnValue({ data: null }); + render(); + expect(screen.queryByTestId("delete-dialog")).not.toBeInTheDocument(); + }); + + test("shows delete dialog when current user is author", () => { + mockCurrentUser.mockReturnValue({ data: { id: "profile-1" } }); + mockGetById.mockReturnValue({ data: null }); + render(); + expect(screen.getAllByTestId("delete-dialog").length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/test/reviews/review-search-bar.test.tsx b/apps/web/test/reviews/review-search-bar.test.tsx new file mode 100644 index 00000000..ee634dae --- /dev/null +++ b/apps/web/test/reviews/review-search-bar.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import ReviewSearchBar from "~/app/_components/reviews/review-search-bar"; + +const noop = () => { + /* no-op for test */ +}; + +describe("ReviewSearchBar", () => { + test("renders input with placeholder", () => { + render(); + expect( + screen.getByPlaceholderText("Search reviews..."), + ).toBeInTheDocument(); + }); + + test("displays initial search term", () => { + render(); + const input = screen.getByPlaceholderText("Search reviews..."); + expect(input).toHaveValue("co-op"); + }); + + test("calls onSearchChange when user types", () => { + const onSearchChange = vi.fn(); + render(); + const input = screen.getByPlaceholderText("Search reviews..."); + fireEvent.change(input, { target: { value: "test" } }); + expect(onSearchChange).toHaveBeenCalledWith("test"); + }); + + test("applies className when provided", () => { + const { container } = render( + , + ); + const wrapper = container.firstChild; + expect(wrapper).toHaveClass("custom-class"); + }); +}); diff --git a/apps/web/test/reviews/role-card-preview.test.tsx b/apps/web/test/reviews/role-card-preview.test.tsx new file mode 100644 index 00000000..88ddb317 --- /dev/null +++ b/apps/web/test/reviews/role-card-preview.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { RoleCardPreview } from "~/app/_components/reviews/role-card-preview"; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + company: { + getById: { + useQuery: () => ({ data: { name: "Acme Corp" } }), + }, + }, + role: { + getById: { + useQuery: () => ({ data: { title: "Software Co-op" } }), + }, + getAverageById: { + useQuery: () => ({ data: { averageOverallRating: 4.2 } }), + }, + }, + review: { + getByRole: { + useQuery: () => ({ + data: [{ locationId: "loc-1" }], + isSuccess: true, + }), + }, + }, + location: { + getById: { + useQuery: () => ({ + data: { city: "Boston", state: "MA", country: "USA" }, + isSuccess: true, + }), + }, + }, + }, +})); + +vi.mock("~/app/_components/shared/favorite-button", () => ({ + FavoriteButton: () => , +})); + +describe("RoleCardPreview", () => { + const roleObj = { + id: "role-1", + companyId: "company-1", + title: "Software Co-op", + } as never; + + test("renders role title", () => { + render(); + expect(screen.getByText("Software Co-op")).toBeInTheDocument(); + }); + + test("renders company name", () => { + render(); + expect(screen.getByText("Acme Corp")).toBeInTheDocument(); + }); + + test("renders Co-op label", () => { + render(); + expect(screen.getByText("Co-op")).toBeInTheDocument(); + }); + + test("renders rating and review count when reviews exist", () => { + render(); + expect(screen.getByText("4.2")).toBeInTheDocument(); + expect(screen.getByText(/1\+ review/)).toBeInTheDocument(); + }); + + test("hides favorite button when showFavorite is false", () => { + render(); + expect( + screen.queryByRole("button", { name: /favorite/i }), + ).not.toBeInTheDocument(); + }); + + test("shows drag handle when showDragHandle is true", () => { + const { container } = render( + , + ); + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/reviews/role-info.test.tsx b/apps/web/test/reviews/role-info.test.tsx new file mode 100644 index 00000000..b83794cb --- /dev/null +++ b/apps/web/test/reviews/role-info.test.tsx @@ -0,0 +1,211 @@ +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { RoleInfo } from "~/app/_components/reviews/role-info"; +import { CompareProvider } from "~/app/_components/compare/compare-context"; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) => {children}, +})); + +vi.mock("~/trpc/react", () => ({ + api: { + review: { + getByRole: { + useQuery: () => ({ + data: [], + isSuccess: true, + }), + }, + getByCompany: { + useQuery: () => ({ data: [] }), + }, + getByProfile: { + useQuery: () => ({ data: [], isSuccess: true }), + }, + list: { + useQuery: () => ({ data: [] }), + }, + }, + location: { + getById: { + useQuery: () => ({ data: null, isSuccess: false }), + }, + }, + company: { + getById: { + useQuery: () => ({ + data: { + id: "company-1", + name: "Acme Corp", + description: "A great company", + }, + }), + }, + }, + role: { + getAverageById: { + useQuery: () => ({ + data: { + averageOverallRating: 4.2, + averageCultureRating: 4, + averageSupervisorRating: 4.5, + minPay: 20, + maxPay: 30, + overtimeNormal: 0.5, + pto: 0.8, + averageInterviewRating: 4, + averageInterviewDifficulty: 3, + federalHolidays: 1, + drugTest: 0, + freeLunch: 0.5, + freeMerch: 0.5, + travelBenefits: 0, + snackBar: 0.5, + }, + }), + }, + }, + profile: { + getCurrentUser: { + useQuery: () => ({ data: null }), + }, + }, + useQueries: () => [], + }, +})); + +vi.mock("~/app/_components/companies/company-popup", () => ({ + CompanyPopup: ({ + trigger, + company, + }: { + trigger: React.ReactNode; + company: { name: string }; + }) => ( +
+ {trigger} + {company.name} +
+ ), +})); + +vi.mock("~/app/_components/reviews/review-card", () => ({ + ReviewCard: () =>
Review
, +})); + +describe("RoleInfo", () => { + const roleObj = { + id: "role-1", + companyId: "company-1", + title: "Software Co-op", + description: "Build software.", + } as never; + + test("renders role title", () => { + render( + + + , + ); + expect(screen.getByText("Software Co-op")).toBeInTheDocument(); + }); + + test("renders company name", () => { + render( + + + , + ); + expect(screen.getAllByText("Acme Corp").length).toBeGreaterThan(0); + }); + + test("renders Job Description section", () => { + render( + + + , + ); + expect(screen.getByText("Job Description")).toBeInTheDocument(); + }); + + test("renders role description in Job Description", () => { + render( + + + , + ); + expect(screen.getByText("Build software.")).toBeInTheDocument(); + }); + + test("renders On the job section", () => { + render( + + + , + ); + expect(screen.getByText("On the job")).toBeInTheDocument(); + }); + + test("renders Pay section", () => { + render( + + + , + ); + expect(screen.getByText("Pay")).toBeInTheDocument(); + }); + + test("renders Interview section", () => { + render( + + + , + ); + expect(screen.getByText("Interview")).toBeInTheDocument(); + }); + + test("renders Reviews section", () => { + render( + + + , + ); + expect(screen.getByText("Reviews")).toBeInTheDocument(); + }); + + test("renders No reviews yet when no reviews", () => { + render( + + + , + ); + expect(screen.getByText("No reviews yet")).toBeInTheDocument(); + }); + + test("renders back button when onBack provided and calls onBack when clicked", () => { + const onBack = vi.fn(); + const { container } = render( + + + , + ); + const backSvg = container.querySelector('svg[viewBox="0 0 14 12"]'); + expect(backSvg).toBeInTheDocument(); + if (backSvg) fireEvent.click(backSvg); + expect(onBack).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/test/role-page-loading.test.tsx b/apps/web/test/role-page-loading.test.tsx new file mode 100644 index 00000000..ad712d25 --- /dev/null +++ b/apps/web/test/role-page-loading.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import Role from "~/app/(pages)/(dashboard)/(roles)/role/page"; + +vi.mock("next/navigation", () => ({ + useSearchParams: vi.fn(() => new URLSearchParams()), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + role: { + getById: { + useQuery: () => ({ + isPending: true, + isSuccess: false, + data: undefined, + }), + }, + }, + }, +})); + +vi.mock("~/app/_components/loading-results", () => ({ + default: () =>
Loading...
, +})); + +vi.mock("~/app/_components/no-results", () => ({ + default: () =>
No results
, +})); + +vi.mock("~/app/_components/reviews/role-info", () => ({ + RoleInfo: () =>
RoleInfo
, +})); + +describe("Role page loading and error states", () => { + test("renders LoadingResults when query is pending", () => { + render(); + expect(screen.getByTestId("loading-results")).toBeInTheDocument(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/role-page-no-results.test.tsx b/apps/web/test/role-page-no-results.test.tsx new file mode 100644 index 00000000..d583ced2 --- /dev/null +++ b/apps/web/test/role-page-no-results.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import Role from "~/app/(pages)/(dashboard)/(roles)/role/page"; + +vi.mock("next/navigation", () => ({ + useSearchParams: vi.fn(() => new URLSearchParams("id=missing")), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + role: { + getById: { + useQuery: () => ({ + isPending: false, + isSuccess: false, + data: undefined, + }), + }, + }, + }, +})); + +vi.mock("~/app/_components/loading-results", () => ({ + default: () =>
Loading...
, +})); + +vi.mock("~/app/_components/no-results", () => ({ + default: () =>
No results
, +})); + +vi.mock("~/app/_components/reviews/role-info", () => ({ + RoleInfo: () =>
RoleInfo
, +})); + +describe("Role page no results state", () => { + test("renders NoResults when query is not pending and not success", () => { + render(); + expect(screen.getByTestId("no-results")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/role-page.test.tsx b/apps/web/test/role-page.test.tsx new file mode 100644 index 00000000..dd701e15 --- /dev/null +++ b/apps/web/test/role-page.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import Role from "~/app/(pages)/(dashboard)/(roles)/role/page"; + +vi.mock("next/navigation", () => ({ + useSearchParams: vi.fn(() => new URLSearchParams("id=role-123")), +})); + +const mockRole = { + id: "role-123", + name: "Software Engineer", + companyId: "company-1", + companyName: "Test Co", + slug: "software-engineer", + companySlug: "test-co", +} as never; + +vi.mock("~/trpc/react", () => ({ + api: { + role: { + getById: { + useQuery: vi.fn((opts: { id: string }) => { + if (opts.id === "role-123") { + return { + isPending: false, + isSuccess: true, + data: mockRole, + }; + } + return { + isPending: false, + isSuccess: false, + data: undefined, + }; + }), + }, + }, + }, +})); + +vi.mock("~/app/_components/loading-results", () => ({ + default: () =>
Loading...
, +})); + +vi.mock("~/app/_components/no-results", () => ({ + default: () =>
No results
, +})); + +vi.mock("~/app/_components/reviews/role-info", () => ({ + RoleInfo: ({ roleObj }: { roleObj: unknown }) => ( +
+ {String((roleObj as { name: string }).name)} +
+ ), +})); + +describe("Role page", () => { + test("renders RoleInfo when role query succeeds", () => { + render(); + expect(screen.getByTestId("role-info")).toBeInTheDocument(); + expect(screen.getByText("Software Engineer")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/roles-dashboard-page.test.tsx b/apps/web/test/roles-dashboard-page.test.tsx new file mode 100644 index 00000000..c96b9f9f --- /dev/null +++ b/apps/web/test/roles-dashboard-page.test.tsx @@ -0,0 +1,132 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import Roles from "~/app/(pages)/(dashboard)/(roles)/page"; + +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), + useSearchParams: () => + new URLSearchParams({ company: "", role: "", search: "" }), +})); + +const mockListData = { + items: [ + { + id: "role-1", + type: "role", + title: "Software Engineer", + companyName: "Test Co", + slug: "software-engineer", + companySlug: "test-co", + } as never, + ], + totalCount: 1, + totalRolesCount: 1, + totalCompanyCount: 0, +}; + +vi.mock("~/trpc/react", () => ({ + api: { + company: { + getBySlug: { useQuery: () => ({ data: undefined, isSuccess: false }) }, + }, + role: { + getByCompanySlugAndRoleSlug: { + useQuery: () => ({ data: undefined, isSuccess: false }), + }, + }, + roleAndCompany: { + getPageNumber: { + useQuery: () => ({ + data: { found: false, page: 1 }, + isSuccess: false, + isError: false, + }), + }, + list: { + useQuery: () => ({ + data: mockListData, + isSuccess: true, + isPending: false, + }), + }, + }, + location: { + getByPrefix: { + useQuery: () => ({ data: [], isLoading: false }), + }, + }, + }, +})); + +vi.mock("~/app/_components/compare/compare-context", () => ({ + useCompare: () => ({ + isCompareMode: false, + comparedRoleIds: [], + enterCompareMode: vi.fn(), + addRoleId: vi.fn(), + }), +})); + +vi.mock("~/app/_components/loading-results", () => ({ + default: () =>
Loading
, +})); + +vi.mock("~/app/_components/no-results", () => ({ + default: () =>
No results
, +})); + +vi.mock("~/app/_components/search/search-filter", () => ({ + default: () =>
Search
, +})); + +vi.mock("~/app/_components/filters/dropdown-filters-bar", () => ({ + default: () =>
Filters
, +})); + +vi.mock("~/app/_components/companies/company-card-preview", () => ({ + CompanyCardPreview: () => ( +
Company
+ ), +})); + +vi.mock("~/app/_components/reviews/role-card-preview", () => ({ + RoleCardPreview: () =>
Role
, +})); + +vi.mock("~/app/_components/reviews/role-info", () => ({ + RoleInfo: () =>
Role info
, +})); + +vi.mock("~/app/_components/companies/company-info", () => ({ + default: () =>
Company info
, +})); + +vi.mock("~/app/_components/compare/compare-ui", () => ({ + CompareColumns: () =>
Compare
, + CompareControls: () =>
Controls
, +})); + +describe("Roles dashboard page", () => { + test("renders SearchFilter and DropdownFiltersBar", () => { + render(); + expect(screen.getByTestId("search-filter")).toBeInTheDocument(); + expect(screen.getByTestId("dropdown-filters-bar")).toBeInTheDocument(); + }); + + test("renders Sort By dropdown and type chips when list has data", () => { + render(); + expect(screen.getByText(/Sort By/)).toBeInTheDocument(); + }); + + test("renders role card when list has role items", () => { + render(); + expect(screen.getByTestId("role-card-preview")).toBeInTheDocument(); + }); + + test("renders RoleInfo when a role is selected", () => { + render(); + expect(screen.getByTestId("role-info")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/round-bar-graph.test.tsx b/apps/web/test/round-bar-graph.test.tsx new file mode 100644 index 00000000..4517ced2 --- /dev/null +++ b/apps/web/test/round-bar-graph.test.tsx @@ -0,0 +1,55 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import RoundBarGraph from "~/app/_components/reviews/round-bar-graph"; + +describe("RoundBarGraph", () => { + test("renders bar container", () => { + const { container } = render( + , + ); + const wrapper = container.querySelector(".relative.h-5"); + expect(wrapper).toBeInTheDocument(); + }); + + test("renders fill bar with correct width for range", () => { + const { container } = render( + , + ); + const fillBar = container.querySelector('[style*="width"]'); + expect(fillBar).toBeInTheDocument(); + expect((fillBar as HTMLElement).style.width).toBe("60%"); + }); + + test("caps fill at 100% when range exceeds max", () => { + const { container } = render( + , + ); + const fillBar = container.querySelector('[style*="width"]'); + expect((fillBar as HTMLElement).style.width).toBe("100%"); + }); + + test("renders industry avg markers when provided", () => { + const { container } = render( + , + ); + const dashedBorders = container.querySelectorAll(".border-dashed"); + expect(dashedBorders.length).toBe(2); + }); +}); diff --git a/apps/web/test/screen-size-indicator.test.tsx b/apps/web/test/screen-size-indicator.test.tsx new file mode 100644 index 00000000..a2a4645e --- /dev/null +++ b/apps/web/test/screen-size-indicator.test.tsx @@ -0,0 +1,107 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { ScreenSizeIndicator } from "~/app/_components/screen-size-indicator"; + +describe("ScreenSizeIndicator", () => { + test("renders Screen label", () => { + render(); + expect(screen.getByText(/Screen:/)).toBeInTheDocument(); + }); + + test("shows xs when no media matches", () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + render(); + expect(screen.getByText(/Screen: xs/)).toBeInTheDocument(); + }); + + test("shows sm when min-width 640px matches", () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query === "(min-width: 640px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + render(); + expect(screen.getByText(/Screen: sm/)).toBeInTheDocument(); + }); + + test("shows md when min-width 768px matches", () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query === "(min-width: 768px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + render(); + expect(screen.getByText(/Screen: md/)).toBeInTheDocument(); + }); + + test("shows lg when min-width 1024px matches", () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query === "(min-width: 1024px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + render(); + expect(screen.getByText(/Screen: lg/)).toBeInTheDocument(); + }); + + test("shows xl when min-width 1280px matches", () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query === "(min-width: 1280px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + render(); + expect(screen.getByText(/Screen: xl/)).toBeInTheDocument(); + }); + + test("shows 2xl when min-width 1536px matches", () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query === "(min-width: 1536px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + render(); + expect(screen.getByText(/Screen: 2xl/)).toBeInTheDocument(); + }); + + test("has fixed position and z-50 class", () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveClass("fixed"); + expect(wrapper).toHaveClass("z-50"); + }); +}); diff --git a/apps/web/test/search/review-search-bar.test.tsx b/apps/web/test/search/review-search-bar.test.tsx new file mode 100644 index 00000000..f3eebaf9 --- /dev/null +++ b/apps/web/test/search/review-search-bar.test.tsx @@ -0,0 +1,70 @@ +import { FormProvider, useForm } from "react-hook-form"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { ReviewSearchBar } from "~/app/_components/search/review-search-bar"; + +function Wrapper({ + children, + defaultCycle, + defaultTerm, +}: { + children: React.ReactNode; + defaultCycle?: "FALL" | "SPRING" | "SUMMER"; + defaultTerm?: "INPERSON" | "HYBRID" | "REMOTE"; +}) { + const form = useForm({ + defaultValues: { + searchText: "", + searchCycle: defaultCycle, + searchTerm: defaultTerm, + }, + }); + return {children}; +} + +describe("ReviewSearchBar (search)", () => { + test("renders search input with Search placeholder", () => { + render( + + + , + ); + expect(screen.getByPlaceholderText("Search")).toBeInTheDocument(); + }); + + test("renders Cycle placeholder", () => { + render( + + + , + ); + expect(screen.getByText("Cycle")).toBeInTheDocument(); + }); + + test("renders Work Term placeholder", () => { + render( + + + , + ); + expect(screen.getByText("Work Term")).toBeInTheDocument(); + }); + + test("renders submit button", () => { + render( + + + , + ); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + test("uses cycle and term as initial values when provided", () => { + render( + + + , + ); + expect(screen.getByPlaceholderText("Search")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/search/search-filter.test.tsx b/apps/web/test/search/search-filter.test.tsx new file mode 100644 index 00000000..208d9bfd --- /dev/null +++ b/apps/web/test/search/search-filter.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import SearchFilter from "~/app/_components/search/search-filter"; + +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), + usePathname: () => "/roles", +})); + +describe("SearchFilter", () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- vitest beforeEach callback with mocks + beforeEach(() => { + mockPush.mockClear(); + }); + + test("renders search input", () => { + render(); + expect( + screen.getByPlaceholderText("Search for a job, company, industry..."), + ).toBeInTheDocument(); + }); + + test("renders form with search input that accepts text", () => { + render(); + const input = screen.getByPlaceholderText( + "Search for a job, company, industry...", + ); + fireEvent.change(input, { target: { value: "co-op" } }); + expect(input).toHaveValue("co-op"); + }); + + test("applies className when provided", () => { + const { container } = render(); + const form = container.querySelector("form"); + expect(form).toHaveClass("custom-class"); + }); +}); diff --git a/apps/web/test/search/simple-search-bar.test.tsx b/apps/web/test/search/simple-search-bar.test.tsx new file mode 100644 index 00000000..96574f65 --- /dev/null +++ b/apps/web/test/search/simple-search-bar.test.tsx @@ -0,0 +1,42 @@ +import { FormProvider, useForm } from "react-hook-form"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { SimpleSearchBar } from "~/app/_components/search/simple-search-bar"; + +function Wrapper({ children }: { children: React.ReactNode }) { + const form = useForm({ + defaultValues: { searchText: "" }, + }); + return {children}; +} + +describe("SimpleSearchBar", () => { + test("renders search input with placeholder", () => { + render( + + + , + ); + expect( + screen.getByPlaceholderText("Search for a job, company, industry..."), + ).toBeInTheDocument(); + }); + + test("updates form value when user types", () => { + function TestWrapper() { + const form = useForm({ defaultValues: { searchText: "" } }); + return ( + + + + ); + } + render(); + const input = screen.getByPlaceholderText( + "Search for a job, company, industry...", + ); + fireEvent.change(input, { target: { value: "engineer" } }); + expect(input).toHaveValue("engineer"); + }); +}); diff --git a/apps/web/test/setup.ts b/apps/web/test/setup.ts new file mode 100644 index 00000000..86017ad9 --- /dev/null +++ b/apps/web/test/setup.ts @@ -0,0 +1,26 @@ +import "@testing-library/jest-dom/vitest"; + +// jsdom does not provide matchMedia +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + dispatchEvent: () => false, + }), +}); + +// jsdom does not provide ResizeObserver (required by cmdk/Command) +const noop = () => { + /* stub for jsdom */ +}; +class ResizeObserverMock { + observe = noop; + unobserve = noop; + disconnect = noop; +} +window.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + +// jsdom does not implement scrollIntoView (used by cmdk/Command) +Element.prototype.scrollIntoView = noop; diff --git a/apps/web/test/shared/favorite-button.test.tsx b/apps/web/test/shared/favorite-button.test.tsx new file mode 100644 index 00000000..f6a608fe --- /dev/null +++ b/apps/web/test/shared/favorite-button.test.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { FavoriteButton } from "~/app/_components/shared/favorite-button"; + +const mockToggle = vi.fn(); +let mockProfileId = "profile-1"; +let mockIsFavorited = false; +vi.mock("~/app/_components/shared/useFavoriteToggle", () => ({ + useFavoriteToggle: () => ({ + isFavorited: mockIsFavorited, + toggle: mockToggle, + isLoading: false, + profileId: mockProfileId, + }), +})); + +vi.mock("next/image", () => ({ + default: ({ + src, + alt, + onClick, + onMouseEnter, + onMouseLeave, + className, + }: { + src: string; + alt: string; + onClick: () => void; + onMouseEnter: () => void; + onMouseLeave: () => void; + className?: string; + }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +describe("FavoriteButton", () => { + beforeEach(() => { + mockToggle.mockClear(); + }); + + test("renders bookmark image when profileId is set", () => { + render(); + const img = screen.getByTestId("favorite-img"); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute("alt", "Bookmark icon"); + }); + + test("uses default bookmark src when not favorited", () => { + render(); + expect(screen.getByTestId("favorite-img")).toHaveAttribute( + "src", + "/svg/bookmark.svg", + ); + }); + + test("calls toggle when clicked", () => { + render(); + fireEvent.click(screen.getByTestId("favorite-img")); + expect(mockToggle).toHaveBeenCalledTimes(1); + }); + + test("shows filled bookmark src when favorited", () => { + mockIsFavorited = true; + render(); + expect(screen.getByTestId("favorite-img")).toHaveAttribute( + "src", + "/svg/filledBookmark.svg", + ); + mockIsFavorited = false; + }); + + test("shows hover src on mouse enter when not favorited", () => { + render(); + const img = screen.getByTestId("favorite-img"); + fireEvent.mouseEnter(img); + expect(img).toHaveAttribute("src", "/svg/hoverBookmark.svg"); + }); +}); + +describe("FavoriteButton when profileId is null", () => { + test("returns null when profileId is falsy", () => { + mockProfileId = ""; + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + mockProfileId = "profile-1"; + }); +}); diff --git a/apps/web/test/shared/useFavoriteToggle.test.ts b/apps/web/test/shared/useFavoriteToggle.test.ts new file mode 100644 index 00000000..188f7323 --- /dev/null +++ b/apps/web/test/shared/useFavoriteToggle.test.ts @@ -0,0 +1,167 @@ +import { renderHook, act } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { useFavoriteToggle } from "~/app/_components/shared/useFavoriteToggle"; + +const mockToast = { success: vi.fn(), error: vi.fn() }; +vi.mock("@cooper/ui/hooks/use-custom-toast", () => ({ + useCustomToast: () => ({ toast: mockToast }), +})); + +const mockInvalidate = vi.fn(); +const mockSetData = vi.fn(); +let mockFavoriteRolesData: { roleId: string; profileId: string }[] = []; +let mockFavoriteCompaniesData: { companyId: string; profileId: string }[] = []; +const mockGetData = vi.fn(() => []); +const mockCancel = vi.fn(); +const mockMutate = vi.fn(); + +vi.mock("~/trpc/react", () => ({ + api: { + useUtils: () => ({ + profile: { + listFavoriteRoles: { + invalidate: mockInvalidate, + setData: mockSetData, + getData: mockGetData, + cancel: mockCancel, + }, + listFavoriteCompanies: { + invalidate: mockInvalidate, + setData: mockSetData, + getData: mockGetData, + cancel: mockCancel, + }, + }, + }), + profile: { + getCurrentUser: { + useQuery: () => ({ data: { id: "profile-1" } }), + }, + listFavoriteRoles: { + useQuery: () => ({ + data: mockFavoriteRolesData, + isLoading: false, + }), + }, + listFavoriteCompanies: { + useQuery: () => ({ + data: mockFavoriteCompaniesData, + isLoading: false, + }), + }, + favoriteRole: { + useMutation: (opts: { + onSuccess?: () => void; + onSettled?: () => void; + }) => ({ + mutate: (vars: unknown) => { + mockMutate(vars); + opts.onSuccess?.(); + opts.onSettled?.(); + }, + }), + }, + unfavoriteRole: { + useMutation: (opts: { + onSuccess?: () => void; + onSettled?: () => void; + }) => ({ + mutate: (vars: unknown) => { + mockMutate(vars); + opts.onSuccess?.(); + opts.onSettled?.(); + }, + }), + }, + favoriteCompany: { + useMutation: (opts: { + onSuccess?: () => void; + onSettled?: () => void; + }) => ({ + mutate: (vars: unknown) => { + mockMutate(vars); + opts.onSuccess?.(); + opts.onSettled?.(); + }, + }), + }, + unfavoriteCompany: { + useMutation: (opts: { + onSuccess?: () => void; + onSettled?: () => void; + }) => ({ + mutate: (vars: unknown) => { + mockMutate(vars); + opts.onSuccess?.(); + opts.onSettled?.(); + }, + }), + }, + }, + }, +})); + +describe("useFavoriteToggle", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFavoriteRolesData = []; + mockFavoriteCompaniesData = []; + mockGetData.mockReturnValue([]); + }); + + test("returns isFavorited false when list is empty", () => { + const { result } = renderHook(() => useFavoriteToggle("role-1", "role")); + expect(result.current.isFavorited).toBe(false); + expect(result.current.profileId).toBe("profile-1"); + expect(result.current.isLoading).toBe(false); + }); + + test("returns isFavorited true when role is in list", () => { + mockFavoriteRolesData = [{ roleId: "role-1", profileId: "profile-1" }]; + const { result } = renderHook(() => useFavoriteToggle("role-1", "role")); + expect(result.current.isFavorited).toBe(true); + }); + + test("toggle calls favoriteRole.mutate when not favorited", () => { + const { result } = renderHook(() => useFavoriteToggle("role-1", "role")); + act(() => { + result.current.toggle(); + }); + expect(mockMutate).toHaveBeenCalledWith({ + profileId: "profile-1", + roleId: "role-1", + }); + expect(mockToast.success).toHaveBeenCalledWith("This role has been saved."); + }); + + test("toggle calls unfavoriteRole.mutate when favorited", () => { + mockFavoriteRolesData = [{ roleId: "role-1", profileId: "profile-1" }]; + const { result } = renderHook(() => useFavoriteToggle("role-1", "role")); + act(() => { + result.current.toggle(); + }); + expect(mockMutate).toHaveBeenCalledWith({ + profileId: "profile-1", + roleId: "role-1", + }); + expect(mockToast.success).toHaveBeenCalledWith( + "This role has been unsaved.", + ); + }); + + test("company type uses company mutations", () => { + const { result } = renderHook(() => + useFavoriteToggle("company-1", "company"), + ); + act(() => { + result.current.toggle(); + }); + expect(mockMutate).toHaveBeenCalledWith({ + profileId: "profile-1", + companyId: "company-1", + }); + expect(mockToast.success).toHaveBeenCalledWith( + "This company has been saved.", + ); + }); +}); diff --git a/apps/web/test/star-graph.test.tsx b/apps/web/test/star-graph.test.tsx new file mode 100644 index 00000000..bba06629 --- /dev/null +++ b/apps/web/test/star-graph.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import StarGraph from "~/app/_components/shared/star-graph"; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})); + +describe("StarGraph", () => { + const defaultRatings = [ + { stars: 5, percentage: 40 }, + { stars: 4, percentage: 30 }, + { stars: 3, percentage: 20 }, + { stars: 2, percentage: 10 }, + { stars: 1, percentage: 0 }, + ]; + + test("renders overall rating heading", () => { + render( + , + ); + expect(screen.getByText("Overall rating")).toBeInTheDocument(); + }); + + test("renders average rating", () => { + render( + , + ); + expect(screen.getByText("4.2")).toBeInTheDocument(); + }); + + test("renders review count singular", () => { + render( + , + ); + expect(screen.getByText("Based on 1 review")).toBeInTheDocument(); + }); + + test("renders review count plural", () => { + render( + , + ); + expect(screen.getByText("Based on 5 reviews")).toBeInTheDocument(); + }); + + test("renders Cooper average", () => { + render( + , + ); + expect(screen.getByText("Cooper average: 4")).toBeInTheDocument(); + }); + + test("renders star bars for each rating", () => { + render( + , + ); + expect(screen.getByText("5 stars")).toBeInTheDocument(); + expect(screen.getByText("4 stars")).toBeInTheDocument(); + expect(screen.getByText("3 stars")).toBeInTheDocument(); + expect(screen.getByText("2 stars")).toBeInTheDocument(); + expect(screen.getByText("1 stars")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/stubs/use-custom-toast.test.ts b/apps/web/test/stubs/use-custom-toast.test.ts new file mode 100644 index 00000000..9a1eb9b6 --- /dev/null +++ b/apps/web/test/stubs/use-custom-toast.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; +import { useCustomToast } from "./use-custom-toast"; + +describe("useCustomToast (stub)", () => { + test("returns an object with toast", () => { + const result = useCustomToast(); + expect(result).toHaveProperty("toast"); + expect(result.toast).toBeDefined(); + }); + + test("toast has success, error, and warning methods", () => { + const { toast } = useCustomToast(); + expect(typeof toast.success).toBe("function"); + expect(typeof toast.error).toBe("function"); + expect(typeof toast.warning).toBe("function"); + }); + + test("toast.success can be called without throwing", () => { + const { toast } = useCustomToast(); + expect(() => toast.success()).not.toThrow(); + }); + + test("toast.error can be called without throwing", () => { + const { toast } = useCustomToast(); + expect(() => toast.error()).not.toThrow(); + }); + + test("toast.warning can be called without throwing", () => { + const { toast } = useCustomToast(); + expect(() => toast.warning()).not.toThrow(); + }); + + test("toast methods accept arguments without throwing", () => { + const { toast } = useCustomToast(); + expect(() => toast.success()).not.toThrow(); + expect(() => toast.error()).not.toThrow(); + expect(() => toast.warning()).not.toThrow(); + }); +}); diff --git a/apps/web/test/stubs/use-custom-toast.ts b/apps/web/test/stubs/use-custom-toast.ts new file mode 100644 index 00000000..36060f6b --- /dev/null +++ b/apps/web/test/stubs/use-custom-toast.ts @@ -0,0 +1,12 @@ +export function useCustomToast() { + const noop = () => { + /* stub */ + }; + return { + toast: { + success: noop, + error: noop, + warning: noop, + }, + }; +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 15a3ca5b..d9840365 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "lib": ["es2022", "dom", "dom.iterable"], "jsx": "preserve", + "types": ["vitest/globals"], "baseUrl": ".", "paths": { "~/*": ["./src/*"] @@ -11,6 +12,5 @@ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", "module": "esnext" }, - "include": [".", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [".", ".next/types/**/*.ts"] } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 00000000..4a9f6f18 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,50 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./test/setup.ts"], + include: [ + "test/**/*.{test,spec}.{ts,tsx}", + "src/**/*.{test,spec}.{ts,tsx}", + ], + globals: true, + }, + resolve: { + dedupe: ["react", "react-dom"], + alias: { + "~": path.resolve(__dirname, "./src"), + "@cooper/ui/hooks/use-custom-toast": path.resolve( + __dirname, + "./test/stubs/use-custom-toast.ts", + ), + "node_modules/@cooper/ui/src/form": path.resolve( + __dirname, + "../../packages/ui/src/form.tsx", + ), + "node_modules/@cooper/ui/src/radio-group": path.resolve( + __dirname, + "../../packages/ui/src/radio-group.tsx", + ), + "node_modules/@cooper/ui/src/checkbox": path.resolve( + __dirname, + "../../packages/ui/src/checkbox.tsx", + ), + "node_modules/@cooper/ui/src/logo": path.resolve( + __dirname, + "../../packages/ui/src/logo.tsx", + ), + "node_modules/@cooper/ui/src/button": path.resolve( + __dirname, + "../../packages/ui/src/button.tsx", + ), + "node_modules/@cooper/ui/src/card": path.resolve( + __dirname, + "../../packages/ui/src/card.tsx", + ), + }, + }, +}); diff --git a/package.json b/package.json index 779df8d7..ae6400f4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test": "vitest", "test:ui": "vitest --ui", "test:run": "vitest run", + "test:coverage": "vitest run --coverage", "__DATABASE__________": "", "db:push": "turbo run -F @cooper/db push", "db:generate": "turbo run -F @cooper/db generate", @@ -39,11 +40,18 @@ "devDependencies": { "@cooper/prettier-config": "workspace:*", "@turbo/gen": "^2.1.1", + "@vitejs/plugin-react": "^5.1.2", + "@vitest/coverage-v8": "^2.1.9", "@vitest/ui": "2.1.2", "prettier": "catalog:", "turbo": "^2.1.1", "typescript": "catalog:", "vitest": "catalog:" }, - "prettier": "@cooper/prettier-config" + "prettier": "@cooper/prettier-config", + "pnpm": { + "patchedDependencies": { + "@vitejs/plugin-react": "patches/@vitejs__plugin-react.patch" + } + } } diff --git a/packages/api/eslint.config.js b/packages/api/eslint.config.js index d4937476..1e1d4da8 100644 --- a/packages/api/eslint.config.js +++ b/packages/api/eslint.config.js @@ -6,4 +6,16 @@ export default [ ignores: ["dist/**"], }, ...baseConfig, + { + files: ["tests/**/*.ts"], + rules: { + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-argument": "off", + }, + }, ]; diff --git a/packages/api/tests/auth.test.ts b/packages/api/tests/auth.test.ts new file mode 100644 index 00000000..dd176a22 --- /dev/null +++ b/packages/api/tests/auth.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test, vi } from "vitest"; +import type { Mock } from "vitest"; + +import type { Session } from "@cooper/auth"; +import { auth, invalidateSessionToken, validateToken } from "@cooper/auth"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; + +vi.mock("@cooper/db/client", () => ({ + db: {}, +})); + +vi.mock("@cooper/auth", () => ({ + auth: vi.fn(), + invalidateSessionToken: vi.fn(), + validateToken: vi.fn(), +})); + +describe("Auth Router", () => { + test("getSession returns session when logged in", async () => { + const session: Session = { user: { id: "1" }, expires: "1" }; + (auth as Mock).mockResolvedValue(session); + + const ctx = await createTRPCContext({ + session, + headers: new Headers(), + }); + const caller = createCallerFactory(appRouter)(ctx); + + const result = await caller.auth.getSession(); + expect(result).toEqual(session); + }); + + test("getSession returns null when not logged in", async () => { + (auth as Mock).mockResolvedValue(null); + + const ctx = await createTRPCContext({ + session: null, + headers: new Headers(), + }); + const caller = createCallerFactory(appRouter)(ctx); + + const result = await caller.auth.getSession(); + expect(result).toBeNull(); + }); + + test("getSecretMessage returns message when authenticated", async () => { + const session: Session = { user: { id: "1" }, expires: "1" }; + (auth as Mock).mockResolvedValue(session); + + const ctx = await createTRPCContext({ + session, + headers: new Headers(), + }); + const caller = createCallerFactory(appRouter)(ctx); + + const result = await caller.auth.getSecretMessage(); + expect(result).toBe("you can see this secret message!"); + }); + + test("signOut returns success false when no token", async () => { + const session: Session = { user: { id: "1" }, expires: "1" }; + (auth as Mock).mockResolvedValue(session); + + const ctx = await createTRPCContext({ + session, + headers: new Headers(), + }); + const caller = createCallerFactory(appRouter)(ctx); + + const result = await caller.auth.signOut(); + expect(result).toEqual({ success: false }); + expect(invalidateSessionToken as Mock).not.toHaveBeenCalled(); + }); + + test("signOut invalidates token and returns success when token present", async () => { + const session: Session = { user: { id: "1" }, expires: "1" }; + (validateToken as Mock).mockResolvedValue(session); + (invalidateSessionToken as Mock).mockResolvedValue(undefined); + + const ctx = await createTRPCContext({ + session, + headers: new Headers({ Authorization: "Bearer some-token" }), + }); + const caller = createCallerFactory(appRouter)(ctx); + + const result = await caller.auth.signOut(); + expect(result).toEqual({ success: true }); + expect(invalidateSessionToken as Mock).toHaveBeenCalledWith( + "Bearer some-token", + ); + }); +}); diff --git a/packages/api/tests/company.test.ts b/packages/api/tests/company.test.ts new file mode 100644 index 00000000..b13ae713 --- /dev/null +++ b/packages/api/tests/company.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { Mock } from "vitest"; + +import type { Session } from "@cooper/auth"; +import { auth } from "@cooper/auth"; +import type { CompanyType, ReviewType } from "@cooper/db/schema"; +import { eq } from "@cooper/db"; +import { db } from "@cooper/db/client"; +import { Company, Review } from "@cooper/db/schema"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { data } from "./mocks/review"; + +const chain: any = { + from: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), + where: vi.fn(() => chain), +}; + +const mockCompanyRows = [ + { + id: "c1", + name: "Acme", + slug: "acme", + description: "Desc", + industry: "TECHNOLOGY", + website: "https://acme.com", + createdAt: new Date(), + updatedAt: new Date(), + avg_rating: 4.5, + }, +]; + +vi.mock("@cooper/db/client", () => ({ + db: { + select: vi.fn(() => chain), + execute: vi.fn(), + query: { + Review: { + findMany: vi.fn(), + findFirst: vi.fn(), + }, + Company: { + findMany: vi.fn(), + findFirst: vi.fn(), + }, + Role: { + findMany: vi.fn(), + }, + }, + insert: vi.fn(), + }, +})); + +vi.mock("@cooper/auth", () => ({ + auth: vi.fn().mockResolvedValue({ + user: { id: "1" }, + expires: "1", + }), +})); + +describe("Company Router", async () => { + const session: Session = { + user: { + id: "1", + }, + expires: "1", + }; + + beforeEach(() => { + vi.restoreAllMocks(); + (auth as Mock).mockResolvedValue(session); + vi.mocked(db.query.Review.findMany).mockResolvedValue(data as ReviewType[]); + vi.mocked(db.execute).mockResolvedValue({ rows: mockCompanyRows } as never); + vi.mocked(db.query.Company.findMany).mockResolvedValue( + mockCompanyRows as unknown as CompanyType[], + ); + vi.mocked(db.insert).mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: "new-id" }]), + }), + } as never); + }); + + const ctx = await createTRPCContext({ + session, + headers: new Headers(), + }); + + const caller = createCallerFactory(appRouter)(ctx); + + test("list with sortBy rating uses execute and returns companies", async () => { + const result = await caller.company.list({ sortBy: "rating" }); + expect(db.execute).toHaveBeenCalled(); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }); + + test("list with sortBy newest uses findMany", async () => { + await caller.company.list({ sortBy: "newest" }); + expect(db.query.Company.findMany).toHaveBeenCalledWith({ + orderBy: expect.anything(), + where: expect.anything(), + }); + }); + + test("list with options industry and location uses execute when sortBy rating", async () => { + await caller.company.list({ + sortBy: "rating", + options: { industry: "TECHNOLOGY", location: "loc-1" }, + }); + expect(db.execute).toHaveBeenCalled(); + }); + + test("getBySlug returns company by slug", async () => { + vi.mocked(db.query.Company.findFirst).mockResolvedValue( + mockCompanyRows[0] as unknown as CompanyType, + ); + await caller.company.getBySlug({ slug: "acme" }); + expect(db.query.Company.findFirst).toHaveBeenCalledWith({ + where: eq(Company.slug, "acme"), + }); + }); + + test("create inserts company with unique slug", async () => { + vi.mocked(db.query.Company.findMany).mockResolvedValue([]); + const result = await caller.company.create({ + name: "NewCo", + description: "A new company", + industry: "TECHNOLOGY", + }); + expect(db.insert).toHaveBeenCalledWith(Company); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }); + + test("createWithRole creates company and role and returns roleId", async () => { + const insertCompanyReturn = { + returning: vi.fn().mockResolvedValue([{ id: "company-1" }]), + }; + const insertRoleReturn = { + returning: vi.fn().mockResolvedValue([{ id: "role-1" }]), + }; + vi.mocked(db.insert) + .mockReturnValueOnce({ + values: vi.fn().mockReturnValue(insertCompanyReturn), + } as never) + .mockReturnValueOnce({ + values: vi.fn().mockReturnValue(insertRoleReturn), + } as never); + vi.mocked(db.query.Company.findMany).mockResolvedValue([]); + vi.mocked(db.query.Role.findMany).mockResolvedValue([]); + + const result = await caller.company.createWithRole({ + companyName: "Clean Co", + description: "A clean company description here", + industry: "TECHNOLOGY", + roleTitle: "Engineer", + roleDescription: "You build things and write code here", + createdBy: "user-1", + }); + expect(result).toBe("role-1"); + expect(db.insert).toHaveBeenCalledTimes(2); + }); + + test("createWithRole throws on profane company name", async () => { + await expect( + caller.company.createWithRole({ + companyName: "shit", + description: "A clean company description here", + industry: "TECHNOLOGY", + roleTitle: "Engineer", + roleDescription: "You build things and write code here", + createdBy: "user-1", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + test("getById endpoint returns company by id", async () => { + const companyId = "123"; + await caller.company.getById({ id: companyId }); + + expect(db.query.Company.findFirst).toHaveBeenCalledWith({ + where: eq(Company.id, "123"), + }); + }); + + // test("getLocationsById endpoint returns locations by company id", async () => { + // const companyId = "123"; + // await caller.company.getLocationsById({ id: companyId }); + + // expect(db.query.Company.findFirst).toHaveBeenCalledWith({ + // where: eq(Company.id, "123"), + // }); + // }) + + test("getAverageById endpoint returns average ratings by company id", async () => { + const companyId = "123"; + await caller.company.getAverageById({ companyId }); + + expect(db.query.Review.findMany).toHaveBeenCalledWith({ + where: eq(Review.companyId, companyId), + }); + }); +}); diff --git a/packages/api/tests/companytoLocation.test.ts b/packages/api/tests/companytoLocation.test.ts new file mode 100644 index 00000000..0de323d9 --- /dev/null +++ b/packages/api/tests/companytoLocation.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { Mock } from "vitest"; + +import type { Session } from "@cooper/auth"; +import { auth } from "@cooper/auth"; +import { db } from "@cooper/db/client"; +import { CompaniesToLocations } from "@cooper/db/schema"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; + +const mockLocationsByCompany = [ + { + companies_to_locations: { companyId: "c1", locationId: "loc1" }, + location: { id: "loc1", city: "Boston", state: "MA", country: "USA" }, + }, +]; + +vi.mock("@cooper/db/client", () => ({ + db: { + insert: vi.fn(() => ({ + values: vi.fn(() => Promise.resolve()), + })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + leftJoin: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve(mockLocationsByCompany)), + })), + })), + })), + }, +})); + +vi.mock("@cooper/auth", () => ({ + auth: vi.fn(), +})); + +describe("CompanyToLocation Router", () => { + beforeEach(() => { + vi.restoreAllMocks(); + const chain = { + from: vi.fn(function (this: unknown) { + return { + leftJoin: vi.fn(function (this: unknown) { + return { + where: vi.fn().mockResolvedValue(mockLocationsByCompany), + }; + }), + }; + }), + }; + vi.mocked(db.select).mockReturnValue(chain as never); + vi.mocked(db.insert).mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + } as never); + }); + + const session: Session = { user: { id: "1" }, expires: "1" }; + + const getCaller = async () => { + (auth as Mock).mockResolvedValue(session); + const ctx = await createTRPCContext({ + session, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); + }; + + test("create inserts company-location relation", async () => { + const caller = await getCaller(); + const values = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.insert).mockReturnValue({ values } as never); + + await caller.companyToLocation.create({ + companyId: "c1", + locationId: "loc1", + }); + expect(db.insert).toHaveBeenCalledWith(CompaniesToLocations); + expect(values).toHaveBeenCalledWith({ + companyId: "c1", + locationId: "loc1", + }); + }); + + test("getLocationsByCompanyId returns locations for company", async () => { + const caller = await getCaller(); + const result = await caller.companyToLocation.getLocationsByCompanyId({ + companyId: "c1", + }); + expect(db.select).toHaveBeenCalled(); + expect(result).toEqual(mockLocationsByCompany); + }); +}); diff --git a/packages/api/tests/location.test.ts b/packages/api/tests/location.test.ts new file mode 100644 index 00000000..d9dd4e2d --- /dev/null +++ b/packages/api/tests/location.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { Mock } from "vitest"; + +import type { Session } from "@cooper/auth"; +import { auth } from "@cooper/auth"; +import { asc, eq } from "@cooper/db"; +import { db } from "@cooper/db/client"; +import { Location } from "@cooper/db/schema"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; + +const mockLocations = [ + { id: "loc-1", city: "Boston", state: "MA", country: "USA" }, + { id: "loc-2", city: "Cambridge", state: "MA", country: "USA" }, +]; + +vi.mock("@cooper/db/client", () => ({ + db: { + query: { + Location: { + findMany: vi.fn(), + findFirst: vi.fn(), + }, + }, + insert: vi.fn(() => ({ + values: vi.fn(() => Promise.resolve({ returning: [] })), + })), + }, +})); + +vi.mock("@cooper/auth", () => ({ + auth: vi.fn(), +})); + +describe("Location Router", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(db.query.Location.findMany).mockResolvedValue(mockLocations); + vi.mocked(db.query.Location.findFirst).mockResolvedValue(mockLocations[0]); + }); + + const session: Session = { user: { id: "1" }, expires: "1" }; + + const getCaller = async () => { + (auth as Mock).mockResolvedValue(session); + const ctx = await createTRPCContext({ + session, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); + }; + + test("list returns locations ordered by city", async () => { + const caller = await getCaller(); + await caller.location.list(); + expect(db.query.Location.findMany).toHaveBeenCalledWith({ + orderBy: asc(Location.city), + }); + }); + + test("getById returns location by id", async () => { + const caller = await getCaller(); + await caller.location.getById({ id: "loc-1" }); + expect(db.query.Location.findFirst).toHaveBeenCalledWith({ + where: eq(Location.id, "loc-1"), + }); + }); + + test("getByPrefix returns locations matching city prefix", async () => { + const caller = await getCaller(); + await caller.location.getByPrefix({ prefix: "Bos" }); + expect(db.query.Location.findMany).toHaveBeenCalledWith({ + where: expect.any(Function), + orderBy: asc(Location.city), + }); + }); + + test("create inserts location", async () => { + const caller = await getCaller(); + const insertChain = { + values: vi.fn().mockResolvedValue({ returning: [{ id: "new-loc" }] }), + }; + vi.mocked(db.insert).mockReturnValue(insertChain as never); + + await caller.location.create({ + city: "Boston", + state: "MA", + country: "USA", + }); + expect(db.insert).toHaveBeenCalledWith(Location); + expect(insertChain.values).toHaveBeenCalledWith({ + city: "Boston", + state: "MA", + country: "USA", + }); + }); +}); diff --git a/packages/api/tests/profile.test.ts b/packages/api/tests/profile.test.ts new file mode 100644 index 00000000..f342e423 --- /dev/null +++ b/packages/api/tests/profile.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { Mock } from "vitest"; + +import type { Session } from "@cooper/auth"; +import { auth } from "@cooper/auth"; +import { and, desc, eq } from "@cooper/db"; +import { db } from "@cooper/db/client"; +import { + Profile, + ProfilesToCompanies, + ProfilesToRoles, + ProfilesToReviews, +} from "@cooper/db/schema"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; + +const mockProfile = { + id: "profile-1", + firstName: "Jane", + lastName: "Doe", + major: "CS", + minor: null, + graduationYear: 2025, + graduationMonth: 5, + createdAt: new Date(), + updatedAt: new Date(), + userId: "user-1", +}; + +vi.mock("@cooper/db/client", () => ({ + db: { + query: { + Profile: { + findMany: vi.fn(), + findFirst: vi.fn(), + }, + }, + insert: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + execute: vi.fn(), + select: vi.fn(), + }, +})); + +vi.mock("@cooper/auth", () => ({ + auth: vi.fn().mockResolvedValue({ + user: { id: "user-1" }, + expires: "1", + }), +})); + +describe("Profile Router", () => { + beforeEach(() => { + vi.restoreAllMocks(); + (auth as Mock).mockResolvedValue({ + user: { id: "user-1" }, + expires: "1", + }); + vi.mocked(db.query.Profile.findMany).mockResolvedValue([mockProfile]); + vi.mocked(db.query.Profile.findFirst).mockResolvedValue(mockProfile); + vi.mocked(db.insert).mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + } as never); + vi.mocked(db.delete).mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + } as never); + vi.mocked(db.update).mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + } as never); + vi.mocked(db.execute).mockResolvedValue({ + rows: [{ profileId: "p1", companyId: "c1" }], + } as never); + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ profileId: "p1", reviewId: "r1" }]), + }), + } as never); + }); + + const session: Session = { user: { id: "user-1" }, expires: "1" }; + + const getCaller = async () => { + const ctx = await createTRPCContext({ + session, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); + }; + + test("list returns profiles", async () => { + const caller = await getCaller(); + await caller.profile.list(); + expect(db.query.Profile.findMany).toHaveBeenCalledWith({ + orderBy: desc(Profile.id), + }); + }); + + test("getById returns profile by id", async () => { + const caller = await getCaller(); + await caller.profile.getById({ id: "profile-1" }); + expect(db.query.Profile.findFirst).toHaveBeenCalledWith({ + where: eq(Profile.id, "profile-1"), + }); + }); + + test("getCurrentUser returns profile for session user", async () => { + const caller = await getCaller(); + await caller.profile.getCurrentUser(); + expect(db.query.Profile.findFirst).toHaveBeenCalledWith({ + where: eq(Profile.userId, "user-1"), + }); + }); + + test("create inserts profile", async () => { + const caller = await getCaller(); + const values = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.insert).mockReturnValue({ values } as never); + const year = new Date().getFullYear(); + await caller.profile.create({ + firstName: "Jane", + lastName: "Doe", + major: "CS", + graduationYear: year, + graduationMonth: 5, + userId: "user-1", + }); + expect(db.insert).toHaveBeenCalledWith(Profile); + expect(values).toHaveBeenCalled(); + }); + + test("delete deletes profile", async () => { + const caller = await getCaller(); + const where = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.delete).mockReturnValue({ where } as never); + await caller.profile.delete("profile-1"); + expect(db.delete).toHaveBeenCalledWith(Profile); + expect(where).toHaveBeenCalledWith(eq(Profile.id, "profile-1")); + }); + + test("updateNameAndMajor updates profile", async () => { + const caller = await getCaller(); + const where = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.update).mockReturnValue({ + set: vi.fn().mockReturnValue({ where }), + } as never); + await caller.profile.updateNameAndMajor({ + id: "profile-1", + firstName: "Jane", + lastName: "Doe", + major: "CS", + }); + expect(db.update).toHaveBeenCalledWith(Profile); + expect(where).toHaveBeenCalledWith(eq(Profile.id, "profile-1")); + }); + + test("favoriteCompany inserts profile-company", async () => { + const caller = await getCaller(); + const values = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.insert).mockReturnValue({ values } as never); + await caller.profile.favoriteCompany({ + profileId: "profile-1", + companyId: "company-1", + }); + expect(db.insert).toHaveBeenCalledWith(ProfilesToCompanies); + expect(values).toHaveBeenCalledWith({ + profileId: "profile-1", + companyId: "company-1", + }); + }); + + test("unfavoriteCompany deletes relation", async () => { + const caller = await getCaller(); + const where = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.delete).mockReturnValue({ where } as never); + await caller.profile.unfavoriteCompany({ + profileId: "profile-1", + companyId: "company-1", + }); + expect(db.delete).toHaveBeenCalledWith(ProfilesToCompanies); + expect(where).toHaveBeenCalledWith( + and( + eq(ProfilesToCompanies.profileId, "profile-1"), + eq(ProfilesToCompanies.companyId, "company-1"), + ), + ); + }); + + test("favoriteRole inserts profile-role", async () => { + const caller = await getCaller(); + const values = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.insert).mockReturnValue({ values } as never); + await caller.profile.favoriteRole({ + profileId: "profile-1", + roleId: "role-1", + }); + expect(db.insert).toHaveBeenCalledWith(ProfilesToRoles); + }); + + test("unfavoriteRole deletes relation", async () => { + const caller = await getCaller(); + const where = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.delete).mockReturnValue({ where } as never); + await caller.profile.unfavoriteRole({ + profileId: "profile-1", + roleId: "role-1", + }); + expect(db.delete).toHaveBeenCalledWith(ProfilesToRoles); + }); + + test("favoriteReview inserts profile-review", async () => { + const caller = await getCaller(); + const values = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.insert).mockReturnValue({ values } as never); + await caller.profile.favoriteReview({ + profileId: "profile-1", + reviewId: "review-1", + }); + expect(db.insert).toHaveBeenCalledWith(ProfilesToReviews); + }); + + test("unfavoriteReview deletes relation", async () => { + const caller = await getCaller(); + const where = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.delete).mockReturnValue({ where } as never); + await caller.profile.unfavoriteReview({ + profileId: "profile-1", + reviewId: "review-1", + }); + expect(db.delete).toHaveBeenCalledWith(ProfilesToReviews); + }); + + test("listFavoriteCompanies uses execute", async () => { + const caller = await getCaller(); + const result = await caller.profile.listFavoriteCompanies({ + profileId: "profile-1", + }); + expect(db.execute).toHaveBeenCalled(); + expect(result).toEqual([{ profileId: "p1", companyId: "c1" }]); + }); + + test("listFavoriteRoles uses execute", async () => { + const caller = await getCaller(); + vi.mocked(db.execute).mockResolvedValue({ + rows: [{ profileId: "p1", roleId: "r1" }], + } as never); + const result = await caller.profile.listFavoriteRoles({ + profileId: "profile-1", + }); + expect(db.execute).toHaveBeenCalled(); + expect(result).toEqual([{ profileId: "p1", roleId: "r1" }]); + }); + + test("listFavoriteReviews uses select", async () => { + const caller = await getCaller(); + const result = await caller.profile.listFavoriteReviews({ + profileId: "profile-1", + }); + expect(db.select).toHaveBeenCalled(); + expect(result).toEqual([{ profileId: "p1", reviewId: "r1" }]); + }); +}); diff --git a/packages/api/tests/review.test.ts b/packages/api/tests/review.test.ts index 5c3f9a36..4c2379ee 100644 --- a/packages/api/tests/review.test.ts +++ b/packages/api/tests/review.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/unbound-method */ import { beforeEach, describe, expect, test, vi } from "vitest"; import type { Session } from "@cooper/auth"; @@ -21,12 +19,20 @@ vi.mock("@cooper/db/client", () => ({ Company: { findMany: vi.fn(), }, + CompaniesToLocations: { + findFirst: vi.fn(), + }, }, + insert: vi.fn(), + delete: vi.fn(), }, })); vi.mock("@cooper/auth", () => ({ - auth: vi.fn(), + auth: vi.fn().mockResolvedValue({ + user: { id: "1" }, + expires: "1", + }), })); describe("Review Router", async () => { @@ -103,6 +109,33 @@ describe("Review Router", async () => { }); }); + test("getByRole endpoint returns reviews by role", async () => { + const roleId = "role-123"; + await caller.review.getByRole({ id: roleId }); + + expect(db.query.Review.findMany).toHaveBeenCalledWith({ + where: eq(Review.roleId, "role-123"), + }); + }); + + test("getByCompany endpoint returns reviews by company", async () => { + const companyId = "company-123"; + await caller.review.getByCompany({ id: companyId }); + + expect(db.query.Review.findMany).toHaveBeenCalledWith({ + where: eq(Review.companyId, "company-123"), + }); + }); + + test("getByProfile endpoint returns reviews by profile", async () => { + const profileId = "profile-123"; + await caller.review.getByProfile({ id: profileId }); + + expect(db.query.Review.findMany).toHaveBeenCalledWith({ + where: eq(Review.profileId, "profile-123"), + }); + }); + test("get average by industry", async () => { vi.resetAllMocks(); const mockHeaders = new Headers(); @@ -260,4 +293,96 @@ describe("Review Router", async () => { maxPay: 25, }); }); + + const validCreateInput = { + profileId: "user-1", + roleId: "r1", + companyId: "c1", + workTerm: "SPRING" as const, + workYear: 2024, + overallRating: 4, + cultureRating: 4, + supervisorRating: 4, + interviewRating: 4, + interviewDifficulty: 3, + textReview: "Good review text here", + workEnvironment: "REMOTE" as const, + drugTest: false, + overtimeNormal: true, + pto: true, + federalHolidays: true, + freeLunch: true, + travelBenefits: false, + freeMerch: true, + snackBar: false, + }; + + test("list with search uses Fuse and returns filtered reviews", async () => { + const reviewsWithHeadline = [ + { + ...data[0], + reviewHeadline: "Great experience", + textReview: "Good", + location: "Boston", + }, + { + ...data[1], + reviewHeadline: "Average", + textReview: "Okay", + location: "NYC", + }, + ]; + vi.mocked(db.query.Review.findMany).mockResolvedValue( + reviewsWithHeadline as unknown as ReviewType[], + ); + const result = await caller.review.list({ search: "Great" }); + expect(db.query.Review.findMany).toHaveBeenCalled(); + expect(Array.isArray(result)).toBe(true); + }); + + test("create throws when profileId falsy", async () => { + await expect( + caller.review.create({ ...validCreateInput, profileId: "" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + test("create throws when text contains profanity", async () => { + vi.mocked(db.query.Review.findMany).mockResolvedValue([]); + await expect( + caller.review.create({ ...validCreateInput, textReview: "This is shit" }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + test("create throws when user has 5 or more reviews", async () => { + vi.mocked(db.query.Review.findMany).mockResolvedValue( + Array(5).fill(data[0]) as unknown as ReviewType[], + ); + vi.mocked(db.insert).mockReturnValue({ + values: vi.fn().mockResolvedValue([{ id: "new" }]), + } as never); + await expect(caller.review.create(validCreateInput)).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + }); + }); + + test("create inserts review when valid", async () => { + vi.mocked(db.query.Review.findMany).mockResolvedValue([]); + vi.mocked(db.query.CompaniesToLocations.findFirst).mockResolvedValue( + undefined, + ); + vi.mocked(db.insert).mockReturnValue({ + values: vi.fn().mockResolvedValue([{ id: "new-review" }]), + } as never); + const result = await caller.review.create(validCreateInput); + expect(db.insert).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + test("delete deletes review by id", async () => { + const where = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.delete).mockReturnValue({ where } as never); + await caller.review.delete("review-123"); + expect(db.delete).toHaveBeenCalledWith(Review); + expect(where).toHaveBeenCalledWith(eq(Review.id, "review-123")); + }); }); diff --git a/packages/api/tests/role.test.ts b/packages/api/tests/role.test.ts new file mode 100644 index 00000000..a38256c7 --- /dev/null +++ b/packages/api/tests/role.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { Mock } from "vitest"; + +import type { Session } from "@cooper/auth"; +import { auth } from "@cooper/auth"; +import { eq } from "@cooper/db"; +import { db } from "@cooper/db/client"; +import { Role } from "@cooper/db/schema"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; + +const mockRole = { + id: "role-1", + title: "Engineer", + description: "Build things", + companyId: "company-1", + slug: "engineer", + jobType: "CO-OP" as const, + createdAt: new Date(), + updatedAt: null as Date | null, + createdBy: "user-1", +}; + +const mockCompany = { + id: "company-1", + name: "Acme", + slug: "acme", + description: "Desc", + industry: "TECHNOLOGY", + website: "https://acme.com", + createdAt: new Date(), + updatedAt: new Date(), +}; + +vi.mock("@cooper/db/client", () => ({ + db: { + query: { + Role: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + Company: { + findFirst: vi.fn(), + }, + Review: { + findMany: vi.fn(), + }, + }, + execute: vi.fn(), + insert: vi.fn(), + }, +})); + +vi.mock("@cooper/auth", () => ({ + auth: vi.fn().mockResolvedValue({ + user: { id: "user-1" }, + expires: "1", + }), +})); + +describe("Role Router", () => { + beforeEach(() => { + vi.restoreAllMocks(); + (auth as Mock).mockResolvedValue({ + user: { id: "user-1" }, + expires: "1", + }); + vi.mocked(db.query.Role.findFirst).mockResolvedValue(mockRole); + vi.mocked(db.query.Role.findMany).mockResolvedValue([mockRole]); + vi.mocked(db.query.Company.findFirst).mockResolvedValue(mockCompany); + vi.mocked(db.query.Review.findMany).mockResolvedValue([]); + vi.mocked(db.execute).mockResolvedValue({ rows: [] } as never); + vi.mocked(db.insert).mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([mockRole]), + }), + } as never); + }); + + const session: Session = { user: { id: "user-1" }, expires: "1" }; + + const getCaller = async () => { + const ctx = await createTRPCContext({ + session, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); + }; + + test("getById returns role by id", async () => { + const caller = await getCaller(); + await caller.role.getById({ id: "role-1" }); + expect(db.query.Role.findFirst).toHaveBeenCalledWith({ + where: eq(Role.id, "role-1"), + }); + }); + + test("getByCompanySlugAndRoleSlug returns role with company info", async () => { + const caller = await getCaller(); + vi.mocked(db.query.Company.findFirst).mockResolvedValue(mockCompany); + vi.mocked(db.query.Role.findFirst).mockResolvedValue(mockRole); + const result = await caller.role.getByCompanySlugAndRoleSlug({ + companySlug: "acme", + roleSlug: "engineer", + }); + expect(result).toEqual({ + ...mockRole, + companyName: "Acme", + companySlug: "acme", + }); + }); + + test("getByCompanySlugAndRoleSlug returns null when company not found", async () => { + const caller = await getCaller(); + vi.mocked(db.query.Company.findFirst).mockResolvedValue(undefined); + const result = await caller.role.getByCompanySlugAndRoleSlug({ + companySlug: "nonexistent", + roleSlug: "engineer", + }); + expect(result).toBeNull(); + }); + + test("getManyByIds returns roles", async () => { + const caller = await getCaller(); + await caller.role.getManyByIds({ ids: ["role-1", "role-2"] }); + expect(db.query.Role.findMany).toHaveBeenCalledWith({ + where: expect.any(Function), + }); + }); + + test("getByCompany with onlyWithReviews uses execute", async () => { + const caller = await getCaller(); + vi.mocked(db.execute).mockResolvedValue({ + rows: [{ role_id: "role-1" }], + } as never); + vi.mocked(db.query.Role.findMany).mockResolvedValue([mockRole]); + await caller.role.getByCompany({ + companyId: "company-1", + onlyWithReviews: true, + }); + expect(db.execute).toHaveBeenCalled(); + }); + + test("getByCompany without onlyWithReviews uses findMany", async () => { + const caller = await getCaller(); + await caller.role.getByCompany({ companyId: "company-1" }); + expect(db.query.Role.findMany).toHaveBeenCalledWith({ + where: eq(Role.companyId, "company-1"), + }); + }); + + test("create inserts role with slug", async () => { + const caller = await getCaller(); + vi.mocked(db.query.Role.findMany).mockResolvedValue([]); + const result = await caller.role.create({ + title: "Engineer", + description: "Build things", + companyId: "company-1", + createdBy: "user-1", + }); + expect(db.insert).toHaveBeenCalledWith(Role); + expect(result).toBeDefined(); + }); + + test("create throws on profane title", async () => { + const caller = await getCaller(); + await expect( + caller.role.create({ + title: "shit", + description: "Build things", + companyId: "company-1", + createdBy: "user-1", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + test("getByCreatedBy returns roles", async () => { + const caller = await getCaller(); + await caller.role.getByCreatedBy({ createdBy: "user-1" }); + expect(db.query.Role.findMany).toHaveBeenCalledWith({ + where: eq(Role.createdBy, "user-1"), + }); + }); + + test("getAverageById returns averages", async () => { + const caller = await getCaller(); + vi.mocked(db.query.Review.findMany).mockResolvedValue([ + { + id: "r1", + roleId: "role-1", + overallRating: 4, + hourlyPay: "25", + interviewDifficulty: 3, + cultureRating: 4, + supervisorRating: 4, + interviewRating: 4, + federalHolidays: true, + drugTest: false, + freeLunch: true, + freeMerch: true, + travelBenefits: false, + snackBar: false, + overtimeNormal: true, + pto: true, + workTerm: "SPRING", + workYear: 2024, + createdAt: new Date(), + updatedAt: new Date(), + workEnvironment: "REMOTE", + interviewReview: "", + reviewHeadline: "", + textReview: "", + locationId: null, + otherBenefits: null, + companyId: "c1", + profileId: "p1", + }, + ] as never); + const result = await caller.role.getAverageById({ roleId: "role-1" }); + expect(result).toHaveProperty("averageOverallRating", 4); + expect(result).toHaveProperty("averageHourlyPay"); + }); +}); diff --git a/packages/api/tests/roleAndCompany.test.ts b/packages/api/tests/roleAndCompany.test.ts new file mode 100644 index 00000000..95e6285a --- /dev/null +++ b/packages/api/tests/roleAndCompany.test.ts @@ -0,0 +1,242 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { Mock } from "vitest"; + +import { auth } from "@cooper/auth"; +import { db } from "@cooper/db/client"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; + +const mockRole = { + id: "role-1", + title: "Engineer", + description: "Build", + companyId: "company-1", + slug: "engineer", + createdAt: new Date(), + createdBy: "user-1", +}; + +const mockCompany = { + id: "company-1", + name: "Acme", + slug: "acme", + description: "Desc", + industry: "TECHNOLOGY", + website: "https://acme.com", + createdAt: new Date(), + updatedAt: new Date(), +}; + +vi.mock("@cooper/db/client", () => ({ + db: { + query: { + Role: { + findMany: vi.fn(), + }, + Company: { + findMany: vi.fn(), + }, + CompaniesToLocations: { + findMany: vi.fn(), + }, + }, + execute: vi.fn(), + }, +})); + +vi.mock("@cooper/auth", () => ({ + auth: vi.fn().mockResolvedValue(null), +})); + +describe("RoleAndCompany Router", () => { + beforeEach(() => { + vi.restoreAllMocks(); + (auth as Mock).mockResolvedValue(null); + vi.mocked(db.execute).mockResolvedValue({ rows: [] } as never); + vi.mocked(db.query.Role.findMany).mockResolvedValue([]); + vi.mocked(db.query.Company.findMany).mockResolvedValue([]); + vi.mocked(db.query.CompaniesToLocations.findMany).mockResolvedValue([]); + }); + + const getCaller = async () => { + const ctx = await createTRPCContext({ + session: null, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); + }; + + test("list with sortBy rating uses execute for roles and companies", async () => { + vi.mocked(db.execute) + .mockResolvedValueOnce({ + rows: [{ ...mockRole, avg_rating: 4.5 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ ...mockCompany, avg_rating: 4 }], + } as never) + .mockResolvedValueOnce({ rows: [] } as never) + .mockResolvedValueOnce({ rows: [] } as never); + const caller = await getCaller(); + const result = await caller.roleAndCompany.list({ + sortBy: "rating", + limit: 10, + offset: 0, + }); + expect(db.execute).toHaveBeenCalled(); + expect(result).toHaveProperty("items"); + expect(result).toHaveProperty("totalCount"); + expect(result).toHaveProperty("totalRolesCount"); + expect(result).toHaveProperty("totalCompanyCount"); + }); + + test("list with sortBy newest uses findMany for roles and companies", async () => { + vi.mocked(db.execute) + .mockResolvedValueOnce({ rows: [{ role_id: "role-1" }] } as never) + .mockResolvedValueOnce({ rows: [{ company_id: "company-1" }] } as never); + vi.mocked(db.query.Role.findMany).mockResolvedValue([mockRole as never]); + vi.mocked(db.query.Company.findMany).mockResolvedValue([ + mockCompany as never, + ]); + vi.mocked(db.execute) + .mockResolvedValueOnce({ rows: [{ role_id: "role-1" }] } as never) + .mockResolvedValueOnce({ rows: [{ company_id: "company-1" }] } as never) + .mockResolvedValueOnce({ + rows: [{ id: "role-1", avg_hourly_pay: 25 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ id: "role-1", avg_rating: 4 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ id: "company-1", avg_hourly_pay: 25 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ id: "company-1", avg_rating: 4 }], + } as never); + const caller = await getCaller(); + const result = await caller.roleAndCompany.list({ + sortBy: "newest", + limit: 10, + offset: 0, + }); + expect(db.query.Role.findMany).toHaveBeenCalled(); + expect(db.query.Company.findMany).toHaveBeenCalled(); + expect(result.items).toBeDefined(); + }); + + test("list with type roles returns only roles", async () => { + vi.mocked(db.execute) + .mockResolvedValueOnce({ rows: [{ role_id: "role-1" }] } as never) + .mockResolvedValueOnce({ rows: [{ company_id: "company-1" }] } as never); + vi.mocked(db.query.Role.findMany).mockResolvedValue([ + { ...mockRole, companyId: "company-1" } as never, + ]); + vi.mocked(db.query.Company.findMany).mockResolvedValue([ + mockCompany as never, + ]); + vi.mocked(db.execute) + .mockResolvedValueOnce({ + rows: [{ id: "role-1", avg_hourly_pay: 25 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ id: "role-1", avg_rating: 4 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ id: "company-1", avg_hourly_pay: 25 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ id: "company-1", avg_rating: 4 }], + } as never); + const caller = await getCaller(); + const result = await caller.roleAndCompany.list({ + type: "roles", + limit: 10, + offset: 0, + }); + expect(result.items.every((i) => i.type === "role")).toBe(true); + }); + + test("list with filters applies industry and location", async () => { + vi.mocked(db.execute) + .mockResolvedValueOnce({ rows: [{ role_id: "role-1" }] } as never) + .mockResolvedValueOnce({ rows: [{ company_id: "company-1" }] } as never); + vi.mocked(db.query.Role.findMany).mockResolvedValue([ + { ...mockRole, companyId: "company-1" } as never, + ]); + vi.mocked(db.query.Company.findMany).mockResolvedValue([ + mockCompany as never, + ]); + vi.mocked(db.query.CompaniesToLocations.findMany).mockResolvedValue([ + { companyId: "company-1", locationId: "loc-1" }, + ] as never); + vi.mocked(db.execute) + .mockResolvedValueOnce({ + rows: [{ id: "role-1", avg_hourly_pay: 25 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ id: "role-1", avg_rating: 4 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ id: "company-1", avg_hourly_pay: 25 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ id: "company-1", avg_rating: 4 }], + } as never); + const caller = await getCaller(); + const result = await caller.roleAndCompany.list({ + filters: { + industries: ["TECHNOLOGY"], + locations: ["loc-1"], + }, + limit: 10, + offset: 0, + }); + expect(result).toHaveProperty("items"); + }); + + test("getPageNumber returns page for item", async () => { + vi.mocked(db.execute) + .mockResolvedValueOnce({ rows: [{ role_id: "role-1" }] } as never) + .mockResolvedValueOnce({ rows: [{ company_id: "company-1" }] } as never); + vi.mocked(db.query.Role.findMany).mockResolvedValue([ + { ...mockRole, companyId: "company-1" } as never, + ]); + vi.mocked(db.query.Company.findMany).mockResolvedValue([ + mockCompany as never, + ]); + vi.mocked(db.execute) + .mockResolvedValueOnce({ + rows: [{ id: "role-1", avg_hourly_pay: 25 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ id: "role-1", avg_rating: 4 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ id: "company-1", avg_hourly_pay: 25 }], + } as never) + .mockResolvedValueOnce({ + rows: [{ id: "company-1", avg_rating: 4 }], + } as never); + const caller = await getCaller(); + const result = await caller.roleAndCompany.getPageNumber({ + itemId: "role-1", + itemType: "role", + limit: 10, + }); + expect(result).toHaveProperty("page"); + expect(result).toHaveProperty("found"); + }); + + test("getPageNumber returns found false when item not in list", async () => { + vi.mocked(db.execute).mockResolvedValue({ rows: [] } as never); + vi.mocked(db.query.Role.findMany).mockResolvedValue([]); + vi.mocked(db.query.Company.findMany).mockResolvedValue([]); + const caller = await getCaller(); + const result = await caller.roleAndCompany.getPageNumber({ + itemId: "nonexistent", + itemType: "role", + limit: 10, + }); + expect(result).toEqual({ page: 1, found: false }); + }); +}); diff --git a/packages/api/tests/utils/fuzzyHelper.test.ts b/packages/api/tests/utils/fuzzyHelper.test.ts new file mode 100644 index 00000000..801cce23 --- /dev/null +++ b/packages/api/tests/utils/fuzzyHelper.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; +import { performFuseSearch } from "../../src/utils/fuzzyHelper"; + +describe("performFuseSearch", () => { + const items = [ + { id: "1", name: "Alice", role: "Engineer" }, + { id: "2", name: "Bob", role: "Designer" }, + { id: "3", name: "Charlie", role: "Engineer" }, + ]; + + test("returns all elements when searchQuery is undefined", () => { + const result = performFuseSearch(items, ["name", "role"], undefined); + expect(result).toEqual(items); + expect(result).toHaveLength(3); + }); + + test("returns all elements when searchQuery is empty string", () => { + const result = performFuseSearch(items, ["name", "role"], ""); + expect(result).toEqual(items); + }); + + test("returns empty array when no match", () => { + const result = performFuseSearch(items, ["name", "role"], "ZZZ"); + expect(result).toEqual([]); + }); + + test("searches across multiple keys", () => { + const result = performFuseSearch(items, ["name", "role"], "Designer"); + expect(result).toHaveLength(1); + const first = result[0]; + if (!first) throw new Error("expected one result"); + expect(first.name).toBe("Bob"); + }); +}); diff --git a/packages/api/tests/utils/slugHelpers.test.ts b/packages/api/tests/utils/slugHelpers.test.ts new file mode 100644 index 00000000..6f5e04b4 --- /dev/null +++ b/packages/api/tests/utils/slugHelpers.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; +import { createSlug, generateUniqueSlug } from "../../src/utils/slugHelpers"; + +describe("createSlug", () => { + test("converts to lowercase", () => { + expect(createSlug("Hello World")).toBe("hello-world"); + }); + + test("replaces spaces with hyphens", () => { + expect(createSlug("foo bar baz")).toBe("foo-bar-baz"); + }); + + test("removes special characters", () => { + expect(createSlug("Test & Company!")).toBe("test-company"); + }); + + test("collapses multiple hyphens", () => { + expect(createSlug("foo---bar")).toBe("foo-bar"); + }); + + test("handles empty string", () => { + expect(createSlug("")).toBe(""); + }); + + test("preserves numbers", () => { + expect(createSlug("Company 123")).toBe("company-123"); + }); +}); + +describe("generateUniqueSlug", () => { + test("returns base slug when not in existing slugs", () => { + expect(generateUniqueSlug("hello", [])).toBe("hello"); + expect(generateUniqueSlug("hello", ["other"])).toBe("hello"); + }); + + test("appends -2 when base slug exists", () => { + expect(generateUniqueSlug("hello", ["hello"])).toBe("hello-2"); + }); + + test("increments counter when multiple collisions", () => { + expect(generateUniqueSlug("hello", ["hello", "hello-2"])).toBe("hello-3"); + }); + + test("finds first available slot", () => { + expect(generateUniqueSlug("test", ["test", "test-2", "test-3"])).toBe( + "test-4", + ); + }); +}); diff --git a/packages/db/eslint.config.js b/packages/db/eslint.config.js index ce1b8046..ff5bfbd1 100644 --- a/packages/db/eslint.config.js +++ b/packages/db/eslint.config.js @@ -1,4 +1,8 @@ import baseConfig, { restrictEnvAccess } from "@cooper/eslint-config/base"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** @type {import('typescript-eslint').Config} */ export default [ @@ -6,5 +10,15 @@ export default [ ignores: ["dist/**"], }, ...baseConfig, + { + files: ["tests/**/*.ts"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: __dirname, + allowDefaultProject: ["tests/**"], + }, + }, + }, ...restrictEnvAccess, ]; diff --git a/packages/db/package.json b/packages/db/package.json index 8c1a2763..0d662a78 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -29,6 +29,8 @@ "push": "pnpm with-env drizzle-kit push", "studio": "pnpm with-env drizzle-kit studio", "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "test": "vitest", + "test:run": "vitest run", "with-env": "dotenv -e ../../.env --" }, "dependencies": { @@ -47,7 +49,8 @@ "drizzle-kit": "^0.22.8", "eslint": "catalog:", "prettier": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" }, "prettier": "@cooper/prettier-config" } diff --git a/packages/db/tests/schema/companyRequest.test.ts b/packages/db/tests/schema/companyRequest.test.ts new file mode 100644 index 00000000..6de8277f --- /dev/null +++ b/packages/db/tests/schema/companyRequest.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; + +import { Industry, RequestStatus } from "../../src/schema/misc"; +import { CreateCompanyRequestSchema } from "../../src/schema/companyRequest"; + +describe("CreateCompanyRequestSchema", () => { + const validInput = { + companyName: "Acme Corp", + companyDescription: "A great company", + industry: Industry.TECHNOLOGY, + website: "https://acme.example.com", + locationId: "loc-123", + roleTitle: "Software Engineer", + roleDescription: "Build things", + status: RequestStatus.PENDING, + }; + + test("parses valid input with all fields", () => { + const result = CreateCompanyRequestSchema.safeParse(validInput); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validInput); + } + }); + + test("parses valid input with optional fields omitted", () => { + const minimal = { + companyName: "Minimal Co", + industry: Industry.HEALTHCARE, + locationId: "loc-456", + roleTitle: "Analyst", + roleDescription: "Analyze data", + status: RequestStatus.PENDING, + }; + const result = CreateCompanyRequestSchema.safeParse(minimal); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.companyName).toBe("Minimal Co"); + expect(result.data.industry).toBe(Industry.HEALTHCARE); + expect(result.data.companyDescription).toBeUndefined(); + expect(result.data.website).toBeUndefined(); + } + }); + + test("rejects missing required companyName", () => { + const { companyName: _, ...without } = validInput; + const result = CreateCompanyRequestSchema.safeParse(without); + expect(result.success).toBe(false); + }); + + test("rejects missing required industry", () => { + const { industry: _, ...without } = validInput; + const result = CreateCompanyRequestSchema.safeParse(without); + expect(result.success).toBe(false); + }); + + test("rejects missing required roleTitle", () => { + const { roleTitle: _, ...without } = validInput; + const result = CreateCompanyRequestSchema.safeParse(without); + expect(result.success).toBe(false); + }); + + test("rejects invalid industry enum value", () => { + const result = CreateCompanyRequestSchema.safeParse({ + ...validInput, + industry: "INVALID_INDUSTRY", + }); + expect(result.success).toBe(false); + }); + + test("rejects invalid status enum value", () => { + const result = CreateCompanyRequestSchema.safeParse({ + ...validInput, + status: "INVALID_STATUS", + }); + expect(result.success).toBe(false); + }); + + test("accepts all RequestStatus values", () => { + for (const status of Object.values(RequestStatus)) { + const result = CreateCompanyRequestSchema.safeParse({ + ...validInput, + status, + }); + expect(result.success, `Expected status ${status} to be valid`).toBe( + true, + ); + } + }); + + test("accepts all Industry values", () => { + for (const industry of Object.values(Industry)) { + const result = CreateCompanyRequestSchema.safeParse({ + ...validInput, + industry, + }); + expect(result.success, `Expected industry ${industry} to be valid`).toBe( + true, + ); + } + }); + + test("strips id and createdAt if provided", () => { + const withExtra = { + ...validInput, + id: "some-uuid", + createdAt: new Date(), + }; + const result = CreateCompanyRequestSchema.safeParse(withExtra); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty("id"); + expect(result.data).not.toHaveProperty("createdAt"); + } + }); +}); diff --git a/packages/db/tests/schema/roleRequest.test.ts b/packages/db/tests/schema/roleRequest.test.ts new file mode 100644 index 00000000..df41963e --- /dev/null +++ b/packages/db/tests/schema/roleRequest.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "vitest"; + +import { RequestStatus } from "../../src/schema/misc"; +import { CreateCompanyRequestSchema } from "../../src/schema/roleRequest"; + +describe("CreateCompanyRequestSchema (role request)", () => { + const validInput = { + roleTitle: "Software Engineer", + roleDescription: "Build and ship features", + companyId: "company-uuid-123", + status: RequestStatus.PENDING, + }; + + test("parses valid input with all fields", () => { + const result = CreateCompanyRequestSchema.safeParse(validInput); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validInput); + } + }); + + test("rejects missing required roleTitle", () => { + const { roleTitle: _, ...without } = validInput; + const result = CreateCompanyRequestSchema.safeParse(without); + expect(result.success).toBe(false); + }); + + test("rejects missing required companyId", () => { + const { companyId: _, ...without } = validInput; + const result = CreateCompanyRequestSchema.safeParse(without); + expect(result.success).toBe(false); + }); + + test("rejects invalid status enum value", () => { + const result = CreateCompanyRequestSchema.safeParse({ + ...validInput, + status: "INVALID_STATUS", + }); + expect(result.success).toBe(false); + }); + + test("accepts all RequestStatus values", () => { + for (const status of Object.values(RequestStatus)) { + const result = CreateCompanyRequestSchema.safeParse({ + ...validInput, + status, + }); + expect(result.success, `Expected status ${status} to be valid`).toBe( + true, + ); + } + }); + + test("strips id and createdAt if provided", () => { + const withExtra = { + ...validInput, + id: "some-uuid", + createdAt: new Date(), + }; + const result = CreateCompanyRequestSchema.safeParse(withExtra); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty("id"); + expect(result.data).not.toHaveProperty("createdAt"); + } + }); +}); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index 95a29d02..5e058214 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -5,6 +5,6 @@ "outDir": "dist", "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, - "include": ["src"], + "include": ["src/**/*.ts", "tests/**/*.ts"], "exclude": ["node_modules"] } diff --git a/packages/db/vitest.config.ts b/packages/db/vitest.config.ts new file mode 100644 index 00000000..e3c445cb --- /dev/null +++ b/packages/db/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: [ + "tests/**/*.{test,spec}.{ts,tsx}", + "src/**/*.{test,spec}.{ts,tsx}", + ], + }, +}); diff --git a/packages/ui/eslint.config.js b/packages/ui/eslint.config.js index 1537c297..ce0938eb 100644 --- a/packages/ui/eslint.config.js +++ b/packages/ui/eslint.config.js @@ -8,4 +8,12 @@ export default [ }, ...baseConfig, ...reactConfig, + { + files: ["**/*.ts", "**/*.tsx"], + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + }, + }, + }, ]; diff --git a/packages/ui/package.json b/packages/ui/package.json index f1c076bf..7e90407b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -16,7 +16,9 @@ "format": "prettier --check . --ignore-path ../../.gitignore", "lint": "eslint", "typecheck": "tsc --noEmit --emitDeclarationOnly false", - "ui-add": "pnpm dlx shadcn@latest add && prettier src --write --list-different" + "ui-add": "pnpm dlx shadcn@latest add && prettier src --write --list-different", + "test": "vitest", + "test:run": "vitest run" }, "dependencies": { "@hookform/resolvers": "^3.9.0", @@ -43,12 +45,19 @@ "@cooper/prettier-config": "workspace:*", "@cooper/tailwind-config": "workspace:*", "@cooper/tsconfig": "workspace:*", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", "@types/react": "catalog:react18", + "@vitejs/plugin-react": "^5.1.2", "eslint": "catalog:", + "jsdom": "^25.0.1", "prettier": "catalog:", "react": "catalog:react18", + "react-dom": "catalog:react18", "tailwindcss": "^3.4.4", "typescript": "catalog:", + "vite": "^5.4.8", + "vitest": "catalog:", "zod": "catalog:" }, "peerDependencies": { diff --git a/packages/ui/src/autocomplete.test.tsx b/packages/ui/src/autocomplete.test.tsx new file mode 100644 index 00000000..241ef5b4 --- /dev/null +++ b/packages/ui/src/autocomplete.test.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import Autocomplete from "./autocomplete"; + +const defaultOptions = [ + { value: "a", label: "Apple" }, + { value: "b", label: "Banana" }, + { value: "c", label: "Cherry" }, +]; + +describe("Autocomplete", () => { + test("renders input with default placeholder", () => { + const onChange = vi.fn(); + render(); + expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument(); + }); + + test("renders input with custom placeholder", () => { + const onChange = vi.fn(); + render( + , + ); + expect(screen.getByPlaceholderText("Type to search")).toBeInTheDocument(); + }); + + test("shows search icon when empty", () => { + const onChange = vi.fn(); + render(); + const input = screen.getByPlaceholderText("Search..."); + expect(input).toHaveValue(""); + expect(document.querySelector("svg")).toBeInTheDocument(); + }); + + test("calls onSearchChange when typing", () => { + const onChange = vi.fn(); + const onSearchChange = vi.fn(); + render( + , + ); + const input = screen.getByPlaceholderText("Search..."); + fireEvent.change(input, { target: { value: "App" } }); + expect(onSearchChange).toHaveBeenCalledWith("App"); + }); + + test("opens dropdown on focus and shows options", () => { + const onChange = vi.fn(); + render(); + const input = screen.getByPlaceholderText("Search..."); + fireEvent.focus(input); + expect(screen.getByText("Apple")).toBeInTheDocument(); + expect(screen.getByText("Banana")).toBeInTheDocument(); + expect(screen.getByText("Cherry")).toBeInTheDocument(); + }); + + test("filters options by search", () => { + const onChange = vi.fn(); + render(); + const input = screen.getByPlaceholderText("Search..."); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "Ban" } }); + expect(screen.getByText("Banana")).toBeInTheDocument(); + expect(screen.queryByText("Apple")).not.toBeInTheDocument(); + expect(screen.queryByText("Cherry")).not.toBeInTheDocument(); + }); + + test("shows no results when no match", () => { + const onChange = vi.fn(); + render(); + const input = screen.getByPlaceholderText("Search..."); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "xyz" } }); + expect(screen.getByText("No results found.")).toBeInTheDocument(); + }); + + test("calls onChange when option toggled", () => { + const onChange = vi.fn(); + render(); + const input = screen.getByPlaceholderText("Search..."); + fireEvent.focus(input); + fireEvent.click(screen.getByText("Apple")); + expect(onChange).toHaveBeenCalledWith(["a"]); + }); + + test("displays selected value badges", () => { + const onChange = vi.fn(); + render( + , + ); + expect(screen.getByText("Apple")).toBeInTheDocument(); + expect(screen.getByText("Banana")).toBeInTheDocument(); + }); + + test("calls onChange when removing a selected item via badge button", () => { + const onChange = vi.fn(); + render( + , + ); + const appleBadge = screen.getByText("Apple").closest("span"); + const removeButton = appleBadge?.querySelector("button"); + if (removeButton) fireEvent.click(removeButton); + expect(onChange).toHaveBeenCalledWith(["b"]); + }); + + test("clear all button clears search and calls onChange with empty array", () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + const inputWrapper = container.querySelector( + ".relative.w-full > .relative", + ); + const clearButton = inputWrapper?.querySelector("button"); + if (clearButton) fireEvent.click(clearButton); + expect(onChange).toHaveBeenCalledWith([]); + }); + + test("clicking overlay closes dropdown and clears search", () => { + const onChange = vi.fn(); + render(); + const input = screen.getByPlaceholderText("Search..."); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "App" } }); + expect(screen.getByText("Apple")).toBeInTheDocument(); + const overlay = document.querySelector(".fixed.inset-0"); + if (overlay) fireEvent.click(overlay); + expect(screen.queryByText("Apple")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/chip.test.tsx b/packages/ui/src/chip.test.tsx new file mode 100644 index 00000000..7c8d537d --- /dev/null +++ b/packages/ui/src/chip.test.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { Chip } from "./chip"; + +describe("Chip", () => { + test("renders label", () => { + render(); + expect( + screen.getByRole("button", { name: "Test chip" }), + ).toBeInTheDocument(); + }); + + test("calls onClick when clicked", () => { + const onClick = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Click me" })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test("applies selected styles when selected is true", () => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button).toHaveClass("bg-cooper-cream-300"); + }); + + test("applies default styles when selected is false", () => { + const { container } = render( + , + ); + const button = container.querySelector("button"); + expect(button).toHaveClass("bg-white"); + }); +}); diff --git a/packages/ui/src/command.test.tsx b/packages/ui/src/command.test.tsx new file mode 100644 index 00000000..7a8442b2 --- /dev/null +++ b/packages/ui/src/command.test.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandSeparator, + CommandShortcut, +} from "./command"; + +describe("Command", () => { + test("Command renders children", () => { + render( + + + + No results. + + , + ); + expect(screen.getByPlaceholderText("Search")).toBeInTheDocument(); + expect(screen.getByText("No results.")).toBeInTheDocument(); + }); + + test("Command applies custom className", () => { + const { container } = render( + + + , + ); + const command = container.querySelector("[cmdk-root]"); + expect(command).toHaveClass("custom-command"); + }); +}); + +describe("CommandDialog", () => { + test("CommandDialog renders with open state", () => { + render( + + + + + Item one + + + , + ); + expect(screen.getByPlaceholderText("Search")).toBeInTheDocument(); + expect(screen.getByText("Item one")).toBeInTheDocument(); + }); +}); + +describe("CommandGroup", () => { + test("CommandGroup renders heading and items", () => { + render( + + + + Apple + Banana + + + , + ); + expect(screen.getByText("Fruits")).toBeInTheDocument(); + expect(screen.getByText("Apple")).toBeInTheDocument(); + expect(screen.getByText("Banana")).toBeInTheDocument(); + }); +}); + +describe("CommandItem", () => { + test("CommandItem is selectable", () => { + render( + + + Select me + + , + ); + const item = screen.getByText("Select me"); + fireEvent.click(item); + expect(item).toHaveAttribute("data-selected", "true"); + }); +}); + +describe("CommandSeparator", () => { + test("CommandSeparator renders between groups", () => { + const { container } = render( + + + + A + + + + B + + + , + ); + const separator = container.querySelector("[cmdk-separator]"); + expect(separator).toBeInTheDocument(); + }); +}); + +describe("CommandShortcut", () => { + test("CommandShortcut renders shortcut text", () => { + render( + + + + Save + ⌘S + + + , + ); + expect(screen.getByText("⌘S")).toBeInTheDocument(); + expect(screen.getByText("Save")).toBeInTheDocument(); + }); + + test("CommandShortcut applies custom className", () => { + const { container } = render( + + + + Copy + ⌘C + + + , + ); + const shortcut = container.querySelector(".shortcut-class"); + expect(shortcut).toBeInTheDocument(); + expect(shortcut).toHaveTextContent("⌘C"); + }); +}); diff --git a/packages/ui/src/custom-toaster.test.tsx b/packages/ui/src/custom-toaster.test.tsx new file mode 100644 index 00000000..5ecd1e0f --- /dev/null +++ b/packages/ui/src/custom-toaster.test.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { CustomToaster } from "./custom-toaster"; + +const mockToasts: { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: React.ReactNode; + variant?: "default" | "destructive"; + className?: string; +}[] = []; + +vi.mock("./hooks/use-toast", () => ({ + useToast: () => ({ toasts: mockToasts }), +})); + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => {alt}, +})); + +describe("CustomToaster", () => { + beforeEach(() => { + mockToasts.length = 0; + }); + + test("renders nothing when toasts array is empty", () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + expect(screen.queryByRole("listitem")).not.toBeInTheDocument(); + }); + + test("renders SuccessToast when className includes toast-success", () => { + mockToasts.push({ + id: "1", + title: "Done", + description: "Saved", + className: "toast-success", + }); + render(); + expect(screen.getByText("Saved")).toBeInTheDocument(); + }); + + test("renders ErrorToast when className includes toast-error", () => { + mockToasts.push({ + id: "2", + description: "Something went wrong", + className: "toast-error", + }); + render(); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); + + test("renders ErrorToast when variant is destructive", () => { + mockToasts.push({ + id: "3", + description: "Error message", + variant: "destructive", + }); + render(); + expect(screen.getByText("Error message")).toBeInTheDocument(); + }); + + test("renders SuccessToast for default variant", () => { + mockToasts.push({ + id: "4", + description: "Default toast", + variant: "default", + }); + render(); + expect(screen.getByText("Default toast")).toBeInTheDocument(); + }); + + test("renders action when provided", () => { + mockToasts.push({ + id: "5", + description: "With action", + className: "toast-error", + action: , + }); + render(); + expect(screen.getByRole("button", { name: "Undo" })).toBeInTheDocument(); + }); + + test("getVariantFromClassName returns warning for toast-warning", () => { + mockToasts.push({ + id: "6", + description: "Warning", + className: "toast-warning", + }); + render(); + expect(screen.getByText("Warning")).toBeInTheDocument(); + }); + + test("getVariantFromClassName returns info for toast-info", () => { + mockToasts.push({ + id: "7", + description: "Info", + className: "toast-info", + }); + render(); + expect(screen.getByText("Info")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/dialog.test.tsx b/packages/ui/src/dialog.test.tsx new file mode 100644 index 00000000..be43ff6d --- /dev/null +++ b/packages/ui/src/dialog.test.tsx @@ -0,0 +1,100 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, + DialogClose, +} from "./dialog"; + +describe("Dialog", () => { + test("DialogContent renders with DialogHeader, DialogTitle, DialogDescription", () => { + render( + + + + Dialog title + Dialog description text + + + , + ); + expect( + screen.getByRole("heading", { name: "Dialog title" }), + ).toBeInTheDocument(); + expect(screen.getByText("Dialog description text")).toBeInTheDocument(); + }); + + test("DialogFooter renders children", () => { + render( + + + + + + + + , + ); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + }); + + test("DialogHeader applies custom className", () => { + render( + + + + Title + + + , + ); + expect(screen.getByRole("heading", { name: "Title" })).toBeInTheDocument(); + const header = document.querySelector("[class*='custom-header']"); + expect(header).toBeTruthy(); + }); + + test("Dialog opens when trigger is clicked", () => { + render( + + + + + + Content + + , + ); + expect( + screen.queryByRole("heading", { name: "Content" }), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + expect( + screen.getByRole("heading", { name: "Content" }), + ).toBeInTheDocument(); + }); + + test("DialogClose button is present and clickable", () => { + render( + + + Title + + + + + , + ); + expect(screen.getByRole("heading", { name: "Title" })).toBeInTheDocument(); + const closeBtn = screen.getByRole("button", { name: "Close" }); + expect(closeBtn).toBeInTheDocument(); + fireEvent.click(closeBtn); + }); +}); diff --git a/packages/ui/src/dialog.tsx b/packages/ui/src/dialog.tsx index 7429dc41..ef9eb396 100644 --- a/packages/ui/src/dialog.tsx +++ b/packages/ui/src/dialog.tsx @@ -20,7 +20,7 @@ const DialogOverlay = React.forwardRef< { + test("renders menu content when open", () => { + render( + + + + + + Item 1 + + , + ); + expect(screen.getByText("Item 1")).toBeInTheDocument(); + }); + + test("DropdownMenuItem renders with inset class", () => { + render( + + + + + + Inset item + + , + ); + const item = screen.getByRole("menuitem", { name: "Inset item" }); + expect(item).toBeInTheDocument(); + expect(item).toHaveClass("pl-8"); + }); + + test("DropdownMenuLabel renders with inset", () => { + render( + + + + + + Label + + , + ); + expect(screen.getByText("Label")).toBeInTheDocument(); + }); + + test("DropdownMenuSeparator renders between items", () => { + render( + + + + + + A + + B + + , + ); + expect(screen.getByText("A")).toBeInTheDocument(); + expect(screen.getByText("B")).toBeInTheDocument(); + }); + + test("DropdownMenuShortcut renders shortcut text", () => { + render( + + + + + + + Copy + ⌘C + + + , + ); + expect(screen.getByText("⌘C")).toBeInTheDocument(); + expect(screen.getByText("Copy")).toBeInTheDocument(); + }); + + test("DropdownMenuCheckboxItem renders with checked state", () => { + render( + + + + + + Checked + + , + ); + expect(screen.getByText("Checked")).toBeInTheDocument(); + const item = screen.getByRole("menuitemcheckbox", { name: "Checked" }); + expect(item).toHaveAttribute("data-state", "checked"); + }); + + test("DropdownMenuRadioItem renders inside RadioGroup", () => { + render( + + + + + + + Option A + Option B + + + , + ); + expect(screen.getByText("Option A")).toBeInTheDocument(); + expect(screen.getByText("Option B")).toBeInTheDocument(); + }); + + test("DropdownMenuSubTrigger renders with ChevronRight", () => { + render( + + + + + + + Submenu + + Sub item + + + + , + ); + expect(screen.getByText("Submenu")).toBeInTheDocument(); + }); + + test("DropdownMenuSubTrigger with inset applies pl-8", () => { + render( + + + + + + + Inset sub + + + + , + ); + const subTrigger = screen.getByText("Inset sub"); + expect(subTrigger).toBeInTheDocument(); + expect(subTrigger).toHaveClass("pl-8"); + }); + + test("DropdownMenuShortcut applies custom className", () => { + render( + + + + + + + Save + + ⌘S + + + + , + ); + const shortcut = document.querySelector(".shortcut-custom"); + expect(shortcut).toBeInTheDocument(); + }); + + test("DropdownMenuGroup groups items", () => { + render( + + + + + + + Group 1 item + + + Group 2 item + + + , + ); + expect(screen.getByText("Group 1 item")).toBeInTheDocument(); + expect(screen.getByText("Group 2 item")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/error-toast.test.tsx b/packages/ui/src/error-toast.test.tsx new file mode 100644 index 00000000..2439caba --- /dev/null +++ b/packages/ui/src/error-toast.test.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { ErrorToast } from "./error-toast"; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => {alt}, +})); + +function wrapWithProvider(ui: React.ReactElement) { + return ( + + + {ui} + + ); +} + +describe("ErrorToast", () => { + test("renders with description", () => { + render(wrapWithProvider()); + expect(screen.getByText("Error message")).toBeInTheDocument(); + }); + + test("renders X icon", () => { + render(wrapWithProvider()); + expect(screen.getByAltText("X icon")).toBeInTheDocument(); + }); + + test("renders action when provided", () => { + render( + wrapWithProvider( + Retry} + />, + ), + ); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + }); + + test("renders without description when description is undefined", () => { + render(wrapWithProvider()); + expect(screen.getByAltText("X icon")).toBeInTheDocument(); + expect(screen.queryByText("Error message")).not.toBeInTheDocument(); + }); + + test("applies custom className", () => { + const { container } = render( + wrapWithProvider( + , + ), + ); + const root = container.querySelector("[data-state=open]"); + expect(root).toHaveClass("custom-error-toast"); + }); +}); diff --git a/packages/ui/src/form.test.tsx b/packages/ui/src/form.test.tsx new file mode 100644 index 00000000..70c74c7b --- /dev/null +++ b/packages/ui/src/form.test.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, +} from "./form"; +import { Input } from "./input"; + +const schema = z.object({ + username: z.string().min(1, "Username is required"), + bio: z.string().optional(), +}); + +type FormValues = z.infer; + +function FormWrapper({ defaultValues }: { defaultValues?: FormValues }) { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaultValues ?? { username: "", bio: "" }, + }); + return ( +
+ { + /* noop */ + })} + > + ( + + Username + + + + Your display name + + + )} + /> + ( + + Bio + + + + Optional bio + + + )} + /> + + + + ); +} + +describe("Form", () => { + test("FormDescription renders with id from useFormField", () => { + render(); + expect(screen.getByText("Your display name")).toBeInTheDocument(); + const desc = screen.getByText("Your display name"); + expect(desc).toHaveAttribute("id"); + }); + + test("FormLabel links to form item id", () => { + render(); + const label = screen.getByText("Username"); + expect(label).toHaveAttribute("for"); + }); + + test("FormControl has aria-invalid when field has error", async () => { + render(); + const submit = screen.getByRole("button", { name: "Submit" }); + submit.click(); + const input = await screen.findByPlaceholderText("Enter username"); + expect(input).toHaveAttribute("aria-invalid", "true"); + }); + + test("FormMessage shows error when field has error", async () => { + render(); + const submit = screen.getByRole("button", { name: "Submit" }); + submit.click(); + expect(await screen.findByText("Username is required")).toBeInTheDocument(); + }); + + test("FormMessage shows children when no error", () => { + render(); + expect(screen.getByText("Your display name")).toBeInTheDocument(); + }); + + test("FormMessage renders nothing when no error and no children", () => { + function FormWithMessageOnly() { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { username: "filled", bio: "" }, + }); + return ( +
+ ( + + Bio + + + + + + )} + /> + + ); + } + const { container } = render(); + expect(screen.getByLabelText("Bio")).toBeInTheDocument(); + const messageEls = container.querySelectorAll( + "p[id$='-form-item-message']", + ); + expect(messageEls.length).toBe(0); + }); +}); diff --git a/packages/ui/src/hooks/use-custom-toast.test.ts b/packages/ui/src/hooks/use-custom-toast.test.ts new file mode 100644 index 00000000..87724ec6 --- /dev/null +++ b/packages/ui/src/hooks/use-custom-toast.test.ts @@ -0,0 +1,68 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { useCustomToast } from "./use-custom-toast"; + +const mockToast = vi.fn(); +vi.mock("./use-toast", () => ({ + useToast: () => ({ + toast: mockToast, + dismiss: vi.fn(), + toasts: [], + }), +})); + +describe("useCustomToast", () => { + test("returns toast object with success, error, warning, info, custom", () => { + const { result } = renderHook(() => useCustomToast()); + expect(result.current.toast).toBeDefined(); + expect(typeof result.current.toast.success).toBe("function"); + expect(typeof result.current.toast.error).toBe("function"); + expect(typeof result.current.toast.warning).toBe("function"); + expect(typeof result.current.toast.info).toBe("function"); + expect(typeof result.current.toast.custom).toBe("function"); + }); + + test("success calls base toast with description and toast-success class", () => { + mockToast.mockClear(); + const { result } = renderHook(() => useCustomToast()); + result.current.toast.success("Done!"); + expect(mockToast).toHaveBeenCalledWith({ + description: "Done!", + className: "toast-success", + variant: "default", + }); + }); + + test("error calls base toast with description and toast-error class", () => { + mockToast.mockClear(); + const { result } = renderHook(() => useCustomToast()); + result.current.toast.error("Something went wrong"); + expect(mockToast).toHaveBeenCalledWith({ + description: "Something went wrong", + className: "toast-error", + variant: "destructive", + }); + }); + + test("warning calls base toast with toast-warning class", () => { + mockToast.mockClear(); + const { result } = renderHook(() => useCustomToast()); + result.current.toast.warning("Warning"); + expect(mockToast).toHaveBeenCalledWith({ + description: "Warning", + className: "toast-warning", + variant: "default", + }); + }); + + test("info calls base toast with toast-info class", () => { + mockToast.mockClear(); + const { result } = renderHook(() => useCustomToast()); + result.current.toast.info("Info"); + expect(mockToast).toHaveBeenCalledWith({ + description: "Info", + className: "toast-info", + variant: "default", + }); + }); +}); diff --git a/packages/ui/src/hooks/use-toast.test.ts b/packages/ui/src/hooks/use-toast.test.ts new file mode 100644 index 00000000..6bf64b91 --- /dev/null +++ b/packages/ui/src/hooks/use-toast.test.ts @@ -0,0 +1,102 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { reducer, useToast, toast } from "./use-toast"; + +describe("use-toast reducer", () => { + test("ADD_TOAST adds toast to state", () => { + const state = { toasts: [] }; + const action = { + type: "ADD_TOAST" as const, + toast: { + id: "1", + description: "Test", + open: true, + }, + }; + const next = reducer(state, action); + expect(next.toasts).toHaveLength(1); + expect(next.toasts[0]?.id).toBe("1"); + expect(next.toasts[0]?.description).toBe("Test"); + }); + + test("ADD_TOAST respects TOAST_LIMIT of 1", () => { + const state = { + toasts: [{ id: "1", open: true }], + }; + const action = { + type: "ADD_TOAST" as const, + toast: { id: "2", open: true }, + }; + const next = reducer(state, action); + expect(next.toasts).toHaveLength(1); + expect(next.toasts[0]?.id).toBe("2"); + }); + + test("UPDATE_TOAST updates existing toast", () => { + const state = { + toasts: [{ id: "1", description: "Old", open: true }], + }; + const action = { + type: "UPDATE_TOAST" as const, + toast: { id: "1", description: "New" }, + }; + const next = reducer(state, action); + expect(next.toasts[0]?.description).toBe("New"); + }); + + test("DISMISS_TOAST sets open to false", () => { + const state = { + toasts: [{ id: "1", open: true }], + }; + const action = { + type: "DISMISS_TOAST" as const, + toastId: "1", + }; + const next = reducer(state, action); + expect(next.toasts[0]?.open).toBe(false); + }); + + test("REMOVE_TOAST removes toast by id", () => { + const state = { + toasts: [ + { id: "1", open: true }, + { id: "2", open: true }, + ], + }; + const action = { + type: "REMOVE_TOAST" as const, + toastId: "2", + }; + const next = reducer(state, action); + expect(next.toasts).toHaveLength(1); + expect(next.toasts[0]?.id).toBe("1"); + }); + + test("REMOVE_TOAST with undefined clears all", () => { + const state = { + toasts: [{ id: "1", open: true }], + }; + const action = { + type: "REMOVE_TOAST" as const, + toastId: undefined, + }; + const next = reducer(state, action); + expect(next.toasts).toHaveLength(0); + }); +}); + +describe("useToast", () => { + test("returns toast function and dismiss", () => { + const { result } = renderHook(() => useToast()); + expect(typeof result.current.toast).toBe("function"); + expect(typeof result.current.dismiss).toBe("function"); + expect(Array.isArray(result.current.toasts)).toBe(true); + }); + + test("toast() adds a toast and returns id, dismiss, update", () => { + const result = toast({ description: "Hello" }); + expect(result.id).toBeDefined(); + expect(typeof result.dismiss).toBe("function"); + expect(typeof result.update).toBe("function"); + }); +}); diff --git a/packages/ui/src/icons.test.tsx b/packages/ui/src/icons.test.tsx new file mode 100644 index 00000000..eea31f19 --- /dev/null +++ b/packages/ui/src/icons.test.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { CheckIcon } from "./icons"; + +describe("icons", () => { + test("CheckIcon is exported and renders as svg", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + }); + + test("CheckIcon accepts className", () => { + const { container } = render(); + const svg = container.querySelector("svg.icon-class"); + expect(svg).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/pagination.test.tsx b/packages/ui/src/pagination.test.tsx new file mode 100644 index 00000000..2278424d --- /dev/null +++ b/packages/ui/src/pagination.test.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { Pagination } from "./pagination"; + +describe("Pagination", () => { + test("returns null when totalPages is 1", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + test("returns null when totalPages is 0", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + test("renders page indicator", () => { + render( + , + ); + expect(screen.getByText("Page 2 of 5")).toBeInTheDocument(); + }); + + test("renders previous and next buttons", () => { + render( + , + ); + expect( + screen.getByRole("button", { name: "Previous page" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Next page" }), + ).toBeInTheDocument(); + }); + + test("calls onPageChange with previous page when previous clicked", () => { + const onPageChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Previous page" })); + expect(onPageChange).toHaveBeenCalledWith(1); + }); + + test("calls onPageChange with next page when next clicked", () => { + const onPageChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Next page" })); + expect(onPageChange).toHaveBeenCalledWith(3); + }); + + test("previous button is disabled on first page", () => { + render( + , + ); + expect( + screen.getByRole("button", { name: "Previous page" }), + ).toBeDisabled(); + }); + + test("next button is disabled on last page", () => { + render( + , + ); + expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled(); + }); + + test("does not call onPageChange when previous clicked on first page", () => { + const onPageChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Previous page" })); + expect(onPageChange).not.toHaveBeenCalled(); + }); + + test("does not call onPageChange when next clicked on last page", () => { + const onPageChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Next page" })); + expect(onPageChange).not.toHaveBeenCalled(); + }); + + test("applies className to container", () => { + const { container } = render( + , + ); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveClass("custom-class"); + }); +}); diff --git a/packages/ui/src/popover.test.tsx b/packages/ui/src/popover.test.tsx new file mode 100644 index 00000000..32c6c1e0 --- /dev/null +++ b/packages/ui/src/popover.test.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { Popover, PopoverTrigger, PopoverContent } from "./popover"; + +describe("Popover", () => { + test("renders trigger and hides content by default", () => { + render( + + + + + Popover content + , + ); + expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument(); + expect(screen.queryByText("Popover content")).not.toBeInTheDocument(); + }); + + test("shows content when trigger is clicked", () => { + render( + + + + + Popover content + , + ); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + expect(screen.getByText("Popover content")).toBeInTheDocument(); + }); + + test("renders content with custom className", () => { + render( + + + + + Content + , + ); + const content = screen.getByText("Content"); + expect(content.closest("[data-state=open]")).toHaveClass("custom-popover"); + }); +}); diff --git a/packages/ui/src/radio-group.test.tsx b/packages/ui/src/radio-group.test.tsx new file mode 100644 index 00000000..49f1e53d --- /dev/null +++ b/packages/ui/src/radio-group.test.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { RadioGroup, RadioGroupItem } from "./radio-group"; + +describe("RadioGroup", () => { + test("renders radio group with items", () => { + render( + + + + , + ); + const radios = screen.getAllByRole("radio"); + expect(radios).toHaveLength(2); + expect(radios[0]).toHaveAttribute("value", "a"); + expect(radios[1]).toHaveAttribute("value", "b"); + }); + + test("applies className to group", () => { + const { container } = render( + + + , + ); + const group = container.firstChild as HTMLElement; + expect(group).toHaveClass("custom-group"); + }); + + test("RadioGroupItem applies className", () => { + render( + + + , + ); + const radio = screen.getByRole("radio"); + expect(radio.closest(".custom-item")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/select.test.tsx b/packages/ui/src/select.test.tsx new file mode 100644 index 00000000..8fcff3da --- /dev/null +++ b/packages/ui/src/select.test.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "./select"; + +describe("Select", () => { + test("renders trigger with placeholder", () => { + render( + , + ); + expect(screen.getByText("Choose one")).toBeInTheDocument(); + }); + + test("opens content when trigger clicked and shows items", () => { + render( + , + ); + fireEvent.click(screen.getByRole("combobox")); + expect(screen.getByText("Option A")).toBeInTheDocument(); + expect(screen.getByText("Option B")).toBeInTheDocument(); + }); + + test("SelectLabel renders label text", () => { + render( + , + ); + fireEvent.click(screen.getByRole("combobox")); + expect(screen.getByText("Group label")).toBeInTheDocument(); + expect(screen.getByText("Option A")).toBeInTheDocument(); + }); + + test("displays selected value", () => { + render( + , + ); + expect(screen.getByText("Option B")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/success-toast.test.tsx b/packages/ui/src/success-toast.test.tsx new file mode 100644 index 00000000..13335c41 --- /dev/null +++ b/packages/ui/src/success-toast.test.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { ToastProvider, ToastViewport } from "./toast"; +import { SuccessToast } from "./success-toast"; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => ( + {alt} + ), +})); + +function wrapWithProvider(ui: React.ReactElement) { + return ( + + + {ui} + + ); +} + +describe("SuccessToast", () => { + test("renders with description", () => { + render(wrapWithProvider()); + expect(screen.getByText("Success message")).toBeInTheDocument(); + }); + + test("renders check icon", () => { + render(wrapWithProvider()); + expect(screen.getByAltText("Check icon")).toBeInTheDocument(); + }); + + test("renders action when provided", () => { + render( + wrapWithProvider( + Undo} + />, + ), + ); + expect(screen.getByRole("button", { name: "Undo" })).toBeInTheDocument(); + }); + + test("applies custom className", () => { + const { container } = render( + wrapWithProvider( + , + ), + ); + const root = container.querySelector("[data-state=open]"); + expect(root).not.toBeNull(); + if (root) { + expect(root).toHaveClass("custom-toast"); + } + }); +}); diff --git a/packages/ui/src/test/setup.ts b/packages/ui/src/test/setup.ts new file mode 100644 index 00000000..c9f6816f --- /dev/null +++ b/packages/ui/src/test/setup.ts @@ -0,0 +1,26 @@ +import "@testing-library/jest-dom/vitest"; +import * as React from "react"; + +// Ensure React is available globally for components that use React.forwardRef etc. +(globalThis as unknown as { React: typeof React }).React = React; + +if (typeof ResizeObserver === "undefined") { + global.ResizeObserver = class ResizeObserver { + observe() { + /* stub for jsdom */ + } + unobserve() { + /* stub for jsdom */ + } + disconnect() { + /* stub for jsdom */ + } + }; +} + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- may be missing in some jsdom versions +if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => { + /* stub for jsdom */ + }; +} diff --git a/packages/ui/src/toast.test.tsx b/packages/ui/src/toast.test.tsx new file mode 100644 index 00000000..1567c649 --- /dev/null +++ b/packages/ui/src/toast.test.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} from "./toast"; + +describe("Toast", () => { + test("ToastViewport renders", () => { + const { container } = render( + + + , + ); + const viewport = container.querySelector("ol"); + expect(viewport).toBeInTheDocument(); + }); + + test("Toast renders with title and description", () => { + render( + + + + Title + Description text + + , + ); + expect(screen.getByText("Title")).toBeInTheDocument(); + expect(screen.getByText("Description text")).toBeInTheDocument(); + }); + + test("Toast applies variant class", () => { + render( + + + + Error + + , + ); + const toastEl = screen.getByText("Error").closest("[data-state=open]"); + expect(toastEl).toHaveClass("destructive"); + }); + + test("ToastClose renders close icon", () => { + const { container } = render( + + + + Title + + + , + ); + const closeButton = container.querySelector("button[toast-close]"); + expect(closeButton).toBeInTheDocument(); + }); + + test("ToastAction renders action button", () => { + render( + + + + Title + Undo + + , + ); + expect(screen.getByRole("button", { name: "Undo" })).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 9a137e4a..ae49e399 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -3,8 +3,9 @@ "compilerOptions": { "lib": ["dom", "dom.iterable", "ES2022"], "jsx": "preserve", - "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "types": ["vitest/globals"] }, - "include": ["*.ts", "src"], + "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules"] } diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts new file mode 100644 index 00000000..6457f1dc --- /dev/null +++ b/packages/ui/vitest.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + globals: true, + }, + resolve: { + alias: [ + { + find: "@cooper/ui/button", + replacement: path.resolve(__dirname, "./src/button.tsx"), + }, + { + find: "@cooper/ui/dialog", + replacement: path.resolve(__dirname, "./src/dialog.tsx"), + }, + { + find: "@cooper/ui/label", + replacement: path.resolve(__dirname, "./src/label.tsx"), + }, + { + find: "@cooper/ui", + replacement: path.resolve(__dirname, "./src/index.ts"), + }, + ], + }, +}); diff --git a/patches/@vitejs__plugin-react.patch b/patches/@vitejs__plugin-react.patch new file mode 100644 index 00000000..926639da --- /dev/null +++ b/patches/@vitejs__plugin-react.patch @@ -0,0 +1,11 @@ +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 354263780356839bde3a89e76030b8fb2926013f..ecf5bb1139ded519e79d7681b60c9e3ec0d97640 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -61,4 +61,4 @@ declare namespace viteReact { + } + declare function viteReactForCjs(this: unknown, options: Options): Plugin[]; + //#endregion +-export { BabelOptions, Options, ReactBabelOptions, ViteReactPluginApi, viteReact as default, viteReactForCjs as "module.exports" }; +\ No newline at end of file ++export { BabelOptions, Options, ReactBabelOptions, ViteReactPluginApi, viteReact as default, viteReactForCjs }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb7e39f6..5fbeaa6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,11 @@ catalogs: specifier: 18.3.1 version: 18.3.1 +patchedDependencies: + '@vitejs/plugin-react': + hash: 2fcdumvrwrobl644jxyf4vdbia + path: patches/@vitejs__plugin-react.patch + importers: .: @@ -45,6 +50,12 @@ importers: '@turbo/gen': specifier: ^2.1.1 version: 2.1.1(@types/node@20.16.5)(typescript@5.5.4) + '@vitejs/plugin-react': + specifier: ^5.1.2 + version: 5.1.2(patch_hash=2fcdumvrwrobl644jxyf4vdbia)(vite@5.4.8(@types/node@20.16.5)(terser@5.31.6)) + '@vitest/coverage-v8': + specifier: ^2.1.9 + version: 2.1.9(vitest@2.1.9(@types/node@20.16.5)(@vitest/ui@2.1.2)(jsdom@25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4))(terser@5.31.6)) '@vitest/ui': specifier: 2.1.2 version: 2.1.2(vitest@2.1.9) @@ -59,7 +70,7 @@ importers: version: 5.5.4 vitest: specifier: 'catalog:' - version: 2.1.9(@types/node@20.16.5)(@vitest/ui@2.1.2)(terser@5.31.6) + version: 2.1.9(@types/node@20.16.5)(@vitest/ui@2.1.2)(jsdom@25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4))(terser@5.31.6) apps/auth-proxy: dependencies: @@ -90,7 +101,7 @@ importers: version: 1.15.5 nitropack: specifier: ^2.9.7 - version: 2.9.7(webpack-sources@3.2.3) + version: 2.9.7(magicast@0.3.5)(webpack-sources@3.2.3) prettier: specifier: 'catalog:' version: 3.3.3 @@ -102,10 +113,10 @@ importers: dependencies: '@docusaurus/core': specifier: 3.5.2 - version: 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + version: 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/preset-classic': specifier: 3.5.2 - version: 3.5.2(@algolia/client-search@4.24.0)(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.0)(typescript@5.5.4) + version: 3.5.2(@algolia/client-search@4.24.0)(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.0)(typescript@5.5.4)(utf-8-validate@6.0.4) '@mdx-js/react': specifier: ^3.0.0 version: 3.0.1(@types/react@18.3.3)(react@18.3.1) @@ -219,6 +230,12 @@ importers: '@cooper/tsconfig': specifier: workspace:* version: link:../../tooling/typescript + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.0.1 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/node': specifier: ^20.14.15 version: 20.16.5 @@ -240,6 +257,9 @@ importers: jiti: specifier: ^1.21.6 version: 1.21.6 + jsdom: + specifier: ^25.0.1 + version: 25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4) prettier: specifier: 'catalog:' version: 3.3.3 @@ -249,6 +269,9 @@ importers: typescript: specifier: 'catalog:' version: 5.5.4 + vitest: + specifier: 'catalog:' + version: 2.1.9(@types/node@20.16.5)(@vitest/ui@2.1.2)(jsdom@25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4))(terser@5.31.6) packages/api: dependencies: @@ -390,6 +413,9 @@ importers: typescript: specifier: 'catalog:' version: 5.5.4 + vitest: + specifier: 'catalog:' + version: 2.1.9(@types/node@20.16.5)(@vitest/ui@2.1.2)(jsdom@25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4))(terser@5.31.6) packages/scraper: dependencies: @@ -485,24 +511,45 @@ importers: '@cooper/tsconfig': specifier: workspace:* version: link:../../tooling/typescript + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.0.1 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: catalog:react18 version: 18.3.3 + '@vitejs/plugin-react': + specifier: ^5.1.2 + version: 5.1.2(patch_hash=2fcdumvrwrobl644jxyf4vdbia)(vite@5.4.8(@types/node@20.16.5)(terser@5.31.6)) eslint: specifier: 'catalog:' version: 9.7.0 + jsdom: + specifier: ^25.0.1 + version: 25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4) prettier: specifier: 'catalog:' version: 3.3.3 react: specifier: catalog:react18 version: 18.3.1 + react-dom: + specifier: catalog:react18 + version: 18.3.1(react@18.3.1) tailwindcss: specifier: ^3.4.4 version: 3.4.6(ts-node@10.9.2(@types/node@20.16.5)(typescript@5.5.4)) typescript: specifier: 'catalog:' version: 5.5.4 + vite: + specifier: ^5.4.8 + version: 5.4.8(@types/node@20.16.5)(terser@5.31.6) + vitest: + specifier: 'catalog:' + version: 2.1.9(@types/node@20.16.5)(@vitest/ui@2.1.2)(jsdom@25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4))(terser@5.31.6) zod: specifier: 'catalog:' version: 3.23.8 @@ -628,6 +675,9 @@ importers: packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@algolia/autocomplete-core@1.9.3': resolution: {integrity: sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==} @@ -704,6 +754,9 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@auth/core@0.32.0': resolution: {integrity: sha512-3+ssTScBd+1fd0/fscAyQN1tSygXzuhysuVVzB942ggU4mdfiTbv36P0ccVnExKWYJKvu3E2r3/zxXCCAmTOrg==} peerDependencies: @@ -739,6 +792,10 @@ packages: resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.24.9': resolution: {integrity: sha512-e701mcfApCJqMMueQI0Fb68Amflj83+dvAvHawoBpAz+GDjCIyGHzNwnefjsWJ3xiYAqqiQFoWbspGYBdb2/ng==} engines: {node: '>=6.9.0'} @@ -747,10 +804,18 @@ packages: resolution: {integrity: sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + '@babel/core@7.24.9': resolution: {integrity: sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==} engines: {node: '>=6.9.0'} + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.24.10': resolution: {integrity: sha512-o9HBZL1G2129luEUlG1hB4N/nlYNWHnpwlND9eOMclRqqu1YDy2sSYVCFUZwl8I1Gxh+QSRrP2vD7EpUmFVXxg==} engines: {node: '>=6.9.0'} @@ -759,6 +824,10 @@ packages: resolution: {integrity: sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.0': + resolution: {integrity: sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.24.7': resolution: {integrity: sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==} engines: {node: '>=6.9.0'} @@ -775,6 +844,10 @@ packages: resolution: {integrity: sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.25.4': resolution: {integrity: sha512-ro/bFs3/84MDgDmMwbcHgDa8/E6J3QKNTk4xJJnVeFtGE+tL0K26E3pNxhYz2b67fJpt7Aphw5XcploKXuCvCQ==} engines: {node: '>=6.9.0'} @@ -800,6 +873,10 @@ packages: resolution: {integrity: sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + '@babel/helper-hoist-variables@7.24.7': resolution: {integrity: sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==} engines: {node: '>=6.9.0'} @@ -812,6 +889,10 @@ packages: resolution: {integrity: sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.24.9': resolution: {integrity: sha512-oYbh+rtFKj/HwBQkFlUzvcybzklmVdVV3UU+mN7n2t/q3yGHbuVdNxyFvSBO1tfvjyArpHNcWMAzsSPdyI46hw==} engines: {node: '>=6.9.0'} @@ -824,6 +905,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-optimise-call-expression@7.24.7': resolution: {integrity: sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==} engines: {node: '>=6.9.0'} @@ -832,6 +919,10 @@ packages: resolution: {integrity: sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-remap-async-to-generator@7.25.0': resolution: {integrity: sha512-NhavI2eWEIz/H9dbrG0TuOicDhNexze43i5z7lEqwYm0WEZVTwnPpA0EafUTP7+6/W79HWIP2cTe3Z5NiSTVpw==} engines: {node: '>=6.9.0'} @@ -860,14 +951,26 @@ packages: resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.24.7': resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.24.8': resolution: {integrity: sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + '@babel/helper-wrap-function@7.25.0': resolution: {integrity: sha512-s6Q1ebqutSiZnEjaofc/UKDyC4SbzV5n5SrA2Gq8UawLycr3i04f1dX4OzoQVnexm6aOCh37SQNYlJ/8Ku+PMQ==} engines: {node: '>=6.9.0'} @@ -876,6 +979,10 @@ packages: resolution: {integrity: sha512-gV2265Nkcz7weJJfvDoAEVzC1e2OTDpkGbEsebse8koXUJUXPsCMi7sRo/+SPMuMZ9MtUPnGwITTnQnU5YjyaQ==} engines: {node: '>=6.9.0'} + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + '@babel/highlight@7.24.7': resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} engines: {node: '>=6.9.0'} @@ -885,8 +992,13 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.25.6': - resolution: {integrity: sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==} + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} hasBin: true @@ -1280,6 +1392,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx@7.25.2': resolution: {integrity: sha512-KQsqEAVBpU82NM/B/N9j9WOdphom1SZH3R+2V7INrQUH+V9EBFwZsEJl8eBIVeQE62FxJCc70jzEZwqU7RcVqA==} engines: {node: '>=6.9.0'} @@ -1412,6 +1536,10 @@ packages: resolution: {integrity: sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.24.8': resolution: {integrity: sha512-t0P1xxAPzEDcEPmjprAQq19NWum4K0EQPjMwZQZbHt+GiZqvjCHjj755Weq1YRPVzBI+3zSfvScfpnuIecVFJQ==} engines: {node: '>=6.9.0'} @@ -1420,6 +1548,10 @@ packages: resolution: {integrity: sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.24.9': resolution: {integrity: sha512-xm8XrMKz0IlUdocVbYJe0Z9xEgidU7msskG8BbhnTPK/HZ2z/7FP7ykqPgrUH+C+r414mNfNWam1f2vqOjqjYQ==} engines: {node: '>=6.9.0'} @@ -1428,6 +1560,17 @@ packages: resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@cloudflare/kv-asset-handler@0.3.4': resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} engines: {node: '>=16.13'} @@ -1440,6 +1583,34 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -2254,6 +2425,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2262,10 +2437,16 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -2286,6 +2467,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} @@ -3098,6 +3282,9 @@ packages: '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + '@rolldown/pluginutils@1.0.0-beta.53': + resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + '@rollup/plugin-alias@5.1.0': resolution: {integrity: sha512-lpA3RZ9PdIG7qqhEfv79tBffNaoDuukFDrmhLqg9ifv99u/ehn+lOg30x2zmhf8AQqQUZaMk/B9fZraQ6/acDQ==} engines: {node: '>=14.0.0'} @@ -3395,6 +3582,29 @@ packages: peerDependencies: react: ^18.0.0 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -3446,6 +3656,21 @@ packages: '@types/acorn@4.0.6': resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} @@ -3692,6 +3917,21 @@ packages: resolution: {integrity: sha512-WiI2g3+ce2g1u1gP41MoDj2DsMuQQ+us7vHobysRixKECGaLHpfTI7DuVZmHU087ozRAGr3GocSyqmWLLo+fig==} engines: {node: '>=14.6'} + '@vitejs/plugin-react@5.1.2': + resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + peerDependencies: + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -3897,6 +4137,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} @@ -3943,6 +4187,9 @@ packages: aria-query@5.1.3: resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-buffer-byte-length@1.0.1: resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} engines: {node: '>= 0.4'} @@ -4003,6 +4250,9 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + at-least-node@1.0.0: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} @@ -4102,6 +4352,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} @@ -4160,6 +4414,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -4216,6 +4475,10 @@ packages: resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==} engines: {node: '>=14.16'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + call-bind@1.0.7: resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} engines: {node: '>= 0.4'} @@ -4248,6 +4511,9 @@ packages: caniuse-lite@1.0.30001655: resolution: {integrity: sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==} + caniuse-lite@1.0.30001766: + resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -4422,6 +4688,10 @@ packages: resolution: {integrity: sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ==} engines: {node: '>=10'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -4665,6 +4935,9 @@ packages: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -4698,6 +4971,10 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -4708,6 +4985,10 @@ packages: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-view-buffer@1.0.1: resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} engines: {node: '>= 0.4'} @@ -4783,6 +5064,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.0.2: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} @@ -4847,6 +5131,10 @@ packages: resolution: {integrity: sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==} engines: {node: '>=10'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} @@ -4929,6 +5217,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-converter@0.2.0: resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} @@ -5084,6 +5378,10 @@ packages: drizzle-orm: '>=0.23.13' zod: '*' + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -5099,6 +5397,9 @@ packages: electron-to-chromium@1.5.13: resolution: {integrity: sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==} + electron-to-chromium@1.5.283: + resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -5148,6 +5449,10 @@ packages: resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} engines: {node: '>= 0.4'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} @@ -5166,10 +5471,18 @@ packages: resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} engines: {node: '>= 0.4'} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + es-set-tostringtag@2.0.3: resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + es-shim-unscopables@1.0.2: resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} @@ -5206,6 +5519,10 @@ packages: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-goat@4.0.0: resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==} engines: {node: '>=12'} @@ -5560,6 +5877,10 @@ packages: resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} engines: {node: '>= 14.17'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -5641,6 +5962,10 @@ packages: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -5651,6 +5976,10 @@ packages: get-port-please@3.1.2: resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@5.2.0: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} @@ -5748,6 +6077,10 @@ packages: gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + got@12.6.1: resolution: {integrity: sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==} engines: {node: '>=14.16'} @@ -5810,6 +6143,10 @@ packages: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} @@ -5868,6 +6205,10 @@ packages: hpack.js@2.1.6: resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-entities@2.5.2: resolution: {integrity: sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==} @@ -5955,10 +6296,6 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} - https-proxy-agent@7.0.5: - resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} - engines: {node: '>= 14'} - https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -5978,6 +6315,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + icss-utils@5.1.0: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} @@ -6242,6 +6583,9 @@ packages: resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -6349,6 +6693,22 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.2: resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} @@ -6394,6 +6754,15 @@ packages: jsbn@1.1.0: resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsesc@0.5.0: resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} hasBin: true @@ -6403,6 +6772,11 @@ packages: engines: {node: '>=4'} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -6598,16 +6972,27 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -6618,6 +7003,10 @@ packages: markdown-table@3.0.3: resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-directive@3.0.0: resolution: {integrity: sha512-JUpYOqKI4mM3sZcNxmF/ox04XYFFkNwr0CFlrQIkCwbvH0xzMCqkMqAde9wRd80VAhaUrwFwKm2nxretdT1h7Q==} @@ -6878,6 +7267,10 @@ packages: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + mini-css-extract-plugin@2.9.1: resolution: {integrity: sha512-+Vyi+GCCOHnrJ2VPS+6aPoXN2k2jgUzDRhTFLjjTBn23qyXJXkjUWQgTL+mXpF5/A8ixLdCc6kWsoeOjKGejKQ==} engines: {node: '>= 12.13.0'} @@ -7071,6 +7464,9 @@ packages: node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -7106,6 +7502,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + nypm@0.3.11: resolution: {integrity: sha512-E5GqaAYSnbb6n1qZyik2wjPDZON43FqOJO59+3OkWrnmQtjggrMOVnsyzfjxp/tS6nlYJBA4zRA5jSM2YaadMg==} engines: {node: ^14.16.0 || >=16.10.0} @@ -7400,6 +7799,9 @@ packages: picocolors@1.1.0: resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -7798,6 +8200,10 @@ packages: pretty-error@4.0.0: resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} @@ -7960,6 +8366,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-json-view-lite@1.5.0: resolution: {integrity: sha512-nWqA1E4jKPklL2jvHWs6s+7Na0qNgw9HCP6xehdQJeg6nPBTFZgGwyko9Q0oj+jQWKTTVRS30u0toM5wiuL3iw==} engines: {node: '>=14'} @@ -7973,6 +8382,10 @@ packages: react-loadable: '*' webpack: '>=4.41.1 || 5.x' + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.6: resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==} engines: {node: '>=10'} @@ -8081,6 +8494,10 @@ packages: resolution: {integrity: sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==} engines: {node: '>=6.0.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -8243,6 +8660,12 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + rtl-detect@1.1.2: resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==} @@ -8285,6 +8708,10 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -8618,6 +9045,10 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -8694,6 +9125,9 @@ packages: swap-case@1.1.2: resolution: {integrity: sha512-BAmWG6/bx8syfc6qXPprof3Mn5vQgf5dwdUNJhsNqU9WdPt5P+ES/wQ5bxfijy8zwZgZZHslC3iAsxsuQMCzJQ==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + system-architecture@0.1.0: resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} engines: {node: '>=18'} @@ -8750,6 +9184,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + text-decoder@1.1.1: resolution: {integrity: sha512-8zll7REEv4GDD3x4/0pW+ppIxSNs7H1J10IKFZsuOMscumCdM2a+toDGLPA3T+1+fLBql4zbt5z83GEQGGV5VA==} @@ -8806,6 +9244,13 @@ packages: title-case@2.1.1: resolution: {integrity: sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q==} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -8826,9 +9271,17 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -9117,6 +9570,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + update-check@1.5.4: resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} @@ -9275,6 +9734,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + watchpack@2.4.2: resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} engines: {node: '>=10.13.0'} @@ -9291,6 +9754,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-bundle-analyzer@4.10.2: resolution: {integrity: sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==} engines: {node: '>= 10.13.0'} @@ -9350,6 +9817,19 @@ packages: resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} engines: {node: '>=0.8.0'} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -9461,6 +9941,13 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -9518,6 +10005,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + '@algolia/autocomplete-core@1.9.3(@algolia/client-search@4.24.0)(algoliasearch@4.24.0)(search-insights@2.17.0)': dependencies: '@algolia/autocomplete-plugin-algolia-insights': 1.9.3(@algolia/client-search@4.24.0)(algoliasearch@4.24.0)(search-insights@2.17.0) @@ -9629,7 +10118,15 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 '@auth/core@0.32.0': dependencies: @@ -9662,12 +10159,20 @@ snapshots: '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 - picocolors: 1.0.1 + picocolors: 1.1.0 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 '@babel/compat-data@7.24.9': {} '@babel/compat-data@7.25.4': {} + '@babel/compat-data@7.29.0': {} + '@babel/core@7.24.9': dependencies: '@ampproject/remapping': 2.3.0 @@ -9688,6 +10193,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.0 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/generator@7.24.10': dependencies: '@babel/types': 7.24.9 @@ -9697,19 +10222,27 @@ snapshots: '@babel/generator@7.25.6': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.28.6 '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 2.5.2 + '@babel/generator@7.29.0': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.24.7': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.28.6 '@babel/helper-builder-binary-assignment-operator-visitor@7.24.7': dependencies: '@babel/traverse': 7.24.8 - '@babel/types': 7.24.9 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color @@ -9729,6 +10262,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.25.4(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 @@ -9769,6 +10310,8 @@ snapshots: '@babel/template': 7.24.7 '@babel/types': 7.24.9 + '@babel/helper-globals@7.28.0': {} + '@babel/helper-hoist-variables@7.24.7': dependencies: '@babel/types': 7.24.9 @@ -9776,7 +10319,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.24.8': dependencies: '@babel/traverse': 7.25.6 - '@babel/types': 7.24.9 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color @@ -9787,6 +10330,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.24.9(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 @@ -9808,12 +10358,23 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-optimise-call-expression@7.24.7': dependencies: - '@babel/types': 7.24.9 + '@babel/types': 7.28.6 '@babel/helper-plugin-utils@7.24.8': {} + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-remap-async-to-generator@7.25.0(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 @@ -9835,14 +10396,14 @@ snapshots: '@babel/helper-simple-access@7.24.7': dependencies: '@babel/traverse': 7.24.8 - '@babel/types': 7.24.9 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.24.7': dependencies: '@babel/traverse': 7.24.8 - '@babel/types': 7.24.9 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color @@ -9852,15 +10413,21 @@ snapshots: '@babel/helper-string-parser@7.24.8': {} + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.24.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.24.8': {} + '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-wrap-function@7.25.0': dependencies: '@babel/template': 7.25.0 '@babel/traverse': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color @@ -9869,20 +10436,29 @@ snapshots: '@babel/template': 7.24.7 '@babel/types': 7.24.9 + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@babel/highlight@7.24.7': dependencies: '@babel/helper-validator-identifier': 7.24.7 chalk: 2.4.2 js-tokens: 4.0.0 - picocolors: 1.1.0 + picocolors: 1.1.1 '@babel/parser@7.24.8': dependencies: '@babel/types': 7.24.9 - '@babel/parser@7.25.6': + '@babel/parser@7.28.6': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.28.6 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.3(@babel/core@7.24.9)': dependencies: @@ -10308,6 +10884,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 @@ -10549,8 +11135,14 @@ snapshots: '@babel/template@7.25.0': dependencies: '@babel/code-frame': 7.24.7 - '@babel/parser': 7.25.6 - '@babel/types': 7.25.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@babel/traverse@7.24.8': dependencies: @@ -10571,14 +11163,26 @@ snapshots: dependencies: '@babel/code-frame': 7.24.7 '@babel/generator': 7.25.6 - '@babel/parser': 7.25.6 + '@babel/parser': 7.28.6 '@babel/template': 7.25.0 - '@babel/types': 7.25.6 + '@babel/types': 7.28.6 debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.0 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + '@babel/types@7.24.9': dependencies: '@babel/helper-string-parser': 7.24.8 @@ -10591,6 +11195,18 @@ snapshots: '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@0.2.3': {} + '@cloudflare/kv-asset-handler@0.3.4': dependencies: mime: 3.0.0 @@ -10602,6 +11218,26 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@discoveryjs/json-ext@0.5.7': {} '@docsearch/css@3.6.1': {} @@ -10620,7 +11256,7 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@docusaurus/core@3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/core@3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4)': dependencies: '@babel/core': 7.24.9 '@babel/generator': 7.24.10 @@ -10689,8 +11325,8 @@ snapshots: update-notifier: 6.0.2 url-loader: 4.1.1(file-loader@6.2.0(webpack@5.94.0))(webpack@5.94.0) webpack: 5.94.0 - webpack-bundle-analyzer: 4.10.2(bufferutil@4.0.8) - webpack-dev-server: 4.15.2(bufferutil@4.0.8)(webpack@5.94.0) + webpack-bundle-analyzer: 4.10.2(bufferutil@4.0.8)(utf-8-validate@6.0.4) + webpack-dev-server: 4.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(webpack@5.94.0) webpack-merge: 5.10.0 webpackbar: 5.0.2(webpack@5.94.0) transitivePeerDependencies: @@ -10779,13 +11415,13 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/plugin-content-blog@3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-content-blog@3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4)': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/logger': 3.5.2 '@docusaurus/mdx-loader': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4))(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) '@docusaurus/types': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.5.4) '@docusaurus/utils-common': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) @@ -10821,13 +11457,13 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4)': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/logger': 3.5.2 '@docusaurus/mdx-loader': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) '@docusaurus/module-type-aliases': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4))(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) '@docusaurus/types': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.5.4) '@docusaurus/utils-common': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) @@ -10861,9 +11497,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-content-pages@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-content-pages@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4)': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/mdx-loader': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) '@docusaurus/types': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.5.4) @@ -10892,9 +11528,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-debug@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-debug@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4)': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/types': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.5.4) fs-extra: 11.2.0 @@ -10921,9 +11557,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-google-analytics@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-google-analytics@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4)': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/types': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.5.4) react: 18.3.1 @@ -10948,9 +11584,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-google-gtag@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-google-gtag@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4)': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/types': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.5.4) '@types/gtag.js': 0.0.12 @@ -10976,9 +11612,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-google-tag-manager@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-google-tag-manager@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4)': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/types': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.5.4) react: 18.3.1 @@ -11003,9 +11639,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-sitemap@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-sitemap@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4)': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/logger': 3.5.2 '@docusaurus/types': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.5.4) @@ -11035,20 +11671,20 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/preset-classic@3.5.2(@algolia/client-search@4.24.0)(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.0)(typescript@5.5.4)': - dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-content-blog': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-content-pages': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-debug': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-google-analytics': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-google-gtag': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-google-tag-manager': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-sitemap': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/theme-classic': 3.5.2(@types/react@18.3.3)(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/theme-search-algolia': 3.5.2(@algolia/client-search@4.24.0)(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.0)(typescript@5.5.4) + '@docusaurus/preset-classic@3.5.2(@algolia/client-search@4.24.0)(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.0)(typescript@5.5.4)(utf-8-validate@6.0.4)': + dependencies: + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/plugin-content-blog': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/plugin-content-pages': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/plugin-debug': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/plugin-google-analytics': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/plugin-google-gtag': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/plugin-google-tag-manager': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/plugin-sitemap': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/theme-classic': 3.5.2(@types/react@18.3.3)(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4))(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/theme-search-algolia': 3.5.2(@algolia/client-search@4.24.0)(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.0)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/types': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -11079,15 +11715,15 @@ snapshots: '@types/react': 18.3.3 react: 18.3.1 - '@docusaurus/theme-classic@3.5.2(@types/react@18.3.3)(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/theme-classic@3.5.2(@types/react@18.3.3)(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4)': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/mdx-loader': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) '@docusaurus/module-type-aliases': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-blog': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-content-pages': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/plugin-content-blog': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/plugin-content-pages': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4))(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) '@docusaurus/theme-translations': 3.5.2 '@docusaurus/types': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.5.4) @@ -11127,11 +11763,11 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/theme-common@3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/theme-common@3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4))(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': dependencies: '@docusaurus/mdx-loader': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) '@docusaurus/module-type-aliases': 3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.5.4) '@docusaurus/utils-common': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@types/history': 4.7.11 @@ -11153,13 +11789,13 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-search-algolia@3.5.2(@algolia/client-search@4.24.0)(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.0)(typescript@5.5.4)': + '@docusaurus/theme-search-algolia@3.5.2(@algolia/client-search@4.24.0)(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.0)(typescript@5.5.4)(utf-8-validate@6.0.4)': dependencies: '@docsearch/react': 3.6.1(@algolia/client-search@4.24.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.0) - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) '@docusaurus/logger': 3.5.2 - '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4) + '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))(bufferutil@4.0.8)(eslint@9.7.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.4))(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) '@docusaurus/theme-translations': 3.5.2 '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.5.4) '@docusaurus/utils-validation': 3.5.2(@docusaurus/types@3.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.5.4) @@ -11652,6 +12288,8 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@istanbuljs/schema@0.1.3': {} + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.8 @@ -11665,12 +12303,22 @@ snapshots: '@types/yargs': 17.0.33 chalk: 4.1.2 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/set-array@1.2.1': {} @@ -11678,7 +12326,7 @@ snapshots: '@jridgewell/source-map@0.3.6': dependencies: '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/sourcemap-codec@1.5.0': {} @@ -11689,10 +12337,15 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@leichtgewicht/ip-codec@2.0.5': {} @@ -12484,6 +13137,8 @@ snapshots: '@radix-ui/rect@1.1.0': {} + '@rolldown/pluginutils@1.0.0-beta.53': {} + '@rollup/plugin-alias@5.1.0(rollup@4.21.2)': dependencies: slash: 4.0.0 @@ -12681,7 +13336,7 @@ snapshots: '@svgr/hast-util-to-babel-ast@8.0.0': dependencies: - '@babel/types': 7.24.9 + '@babel/types': 7.28.6 entities: 4.5.0 '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.5.4))': @@ -12748,6 +13403,36 @@ snapshots: '@tanstack/query-core': 5.51.9 react: 18.3.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.25.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.1.3 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.4 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@tootallnate/quickjs-emscripten@0.23.0': {} '@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.5.4))(typescript@5.5.4)': @@ -12817,6 +13502,29 @@ snapshots: dependencies: '@types/estree': 1.0.5 + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.6 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.6 + '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 @@ -13131,6 +13839,36 @@ snapshots: utf-8-validate: 6.0.4 ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) + '@vitejs/plugin-react@5.1.2(patch_hash=2fcdumvrwrobl644jxyf4vdbia)(vite@5.4.8(@types/node@20.16.5)(terser@5.31.6))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.53 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 5.4.8(@types/node@20.16.5)(terser@5.31.6) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@20.16.5)(@vitest/ui@2.1.2)(jsdom@25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4))(terser@5.31.6))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.1 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@20.16.5)(@vitest/ui@2.1.2)(jsdom@25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4))(terser@5.31.6) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -13178,7 +13916,7 @@ snapshots: sirv: 2.0.4 tinyglobby: 0.2.9 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@20.16.5)(@vitest/ui@2.1.2)(terser@5.31.6) + vitest: 2.1.9(@types/node@20.16.5)(@vitest/ui@2.1.2)(jsdom@25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4))(terser@5.31.6) '@vitest/utils@2.1.2': dependencies: @@ -13392,6 +14130,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.1: {} any-promise@1.3.0: {} @@ -13446,6 +14186,10 @@ snapshots: dependencies: deep-equal: 2.2.3 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-buffer-byte-length@1.0.1: dependencies: call-bind: 1.0.7 @@ -13529,6 +14273,8 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + at-least-node@1.0.0: {} autoprefixer@10.4.20(postcss@8.4.39): @@ -13628,6 +14374,8 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.9.19: {} + basic-ftp@5.0.5: {} batch@0.6.1: {} @@ -13719,6 +14467,14 @@ snapshots: node-releases: 2.0.18 update-browserslist-db: 1.1.0(browserslist@4.23.3) + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001766 + electron-to-chromium: 1.5.283 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-crc32@0.2.13: {} buffer-crc32@1.0.0: {} @@ -13749,7 +14505,7 @@ snapshots: bytes@3.1.2: {} - c12@1.11.2: + c12@1.11.2(magicast@0.3.5): dependencies: chokidar: 3.6.0 confbox: 0.1.7 @@ -13763,6 +14519,8 @@ snapshots: perfect-debounce: 1.0.0 pkg-types: 1.2.0 rc9: 2.1.2 + optionalDependencies: + magicast: 0.3.5 cac@6.7.14: {} @@ -13778,6 +14536,11 @@ snapshots: normalize-url: 8.0.1 responselike: 3.0.0 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + call-bind@1.0.7: dependencies: es-define-property: 1.0.0 @@ -13813,6 +14576,8 @@ snapshots: caniuse-lite@1.0.30001655: {} + caniuse-lite@1.0.30001766: {} + ccount@2.0.1: {} chai@5.3.3: @@ -13820,7 +14585,7 @@ snapshots: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 - loupe: 3.1.1 + loupe: 3.2.1 pathval: 2.0.0 chalk@2.4.2: @@ -14007,6 +14772,10 @@ snapshots: combine-promises@1.2.0: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@10.0.1: {} @@ -14231,6 +15000,8 @@ snapshots: css-what@6.1.0: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} cssnano-preset-advanced@6.1.2(postcss@8.4.39): @@ -14292,12 +15063,22 @@ snapshots: dependencies: css-tree: 2.2.1 + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.1.3: {} damerau-levenshtein@1.0.8: {} data-uri-to-buffer@6.0.2: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + data-view-buffer@1.0.1: dependencies: call-bind: 1.0.7 @@ -14342,6 +15123,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.0.2: dependencies: character-entities: 2.0.2 @@ -14433,6 +15216,8 @@ snapshots: rimraf: 3.0.2 slash: 3.0.0 + delayed-stream@1.0.0: {} + delegates@1.0.0: {} denque@2.1.0: {} @@ -14495,6 +15280,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-converter@0.2.0: dependencies: utila: 0.4.0 @@ -14584,6 +15373,12 @@ snapshots: drizzle-orm: 0.31.4(@neondatabase/serverless@0.9.4)(@types/pg@8.11.6)(@types/react@18.3.3)(@vercel/postgres@0.9.0)(react@18.3.1) zod: 3.23.8 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + duplexer@0.1.2: {} eastasianwidth@0.2.0: {} @@ -14594,6 +15389,8 @@ snapshots: electron-to-chromium@1.5.13: {} + electron-to-chromium@1.5.283: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -14678,6 +15475,8 @@ snapshots: dependencies: get-intrinsic: 1.2.4 + es-define-property@1.0.1: {} + es-errors@1.3.0: {} es-get-iterator@1.1.3: @@ -14715,12 +15514,23 @@ snapshots: dependencies: es-errors: 1.3.0 + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + es-set-tostringtag@2.0.3: dependencies: get-intrinsic: 1.2.4 has-tostringtag: 1.0.2 hasown: 2.0.2 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + es-shim-unscopables@1.0.2: dependencies: hasown: 2.0.2 @@ -14843,6 +15653,8 @@ snapshots: escalade@3.1.2: {} + escalade@3.2.0: {} + escape-goat@4.0.0: {} escape-html@1.0.3: {} @@ -15316,6 +16128,14 @@ snapshots: form-data-encoder@2.1.4: {} + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + format@0.2.2: {} forwarded@0.2.0: {} @@ -15397,12 +16217,30 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.2 + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} get-own-enumerable-property-symbols@3.0.2: {} get-port-please@3.1.2: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-stream@5.2.0: dependencies: pump: 3.0.2 @@ -15543,6 +16381,8 @@ snapshots: dependencies: get-intrinsic: 1.2.4 + gopd@1.2.0: {} + got@12.6.1: dependencies: '@sindresorhus/is': 5.6.0 @@ -15620,6 +16460,8 @@ snapshots: has-symbols@1.0.3: {} + has-symbols@1.1.0: {} + has-tostringtag@1.0.2: dependencies: has-symbols: 1.0.3 @@ -15755,6 +16597,10 @@ snapshots: readable-stream: 2.3.8 wbuf: 1.7.3 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-entities@2.5.2: {} html-escaper@2.0.2: {} @@ -15830,7 +16676,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: - agent-base: 7.1.1 + agent-base: 7.1.3 debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -15869,13 +16715,6 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.5: - dependencies: - agent-base: 7.1.1 - debug: 4.4.0 - transitivePeerDependencies: - - supports-color - https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 @@ -15893,6 +16732,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + icss-utils@5.1.0(postcss@8.4.39): dependencies: postcss: 8.4.39 @@ -16137,6 +16980,8 @@ snapshots: dependencies: isobject: 3.0.1 + is-potential-custom-element-name@1.0.1: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.5 @@ -16223,6 +17068,27 @@ snapshots: isobject@3.0.1: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.0 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.2: dependencies: define-properties: 1.2.1 @@ -16286,10 +17152,40 @@ snapshots: jsbn@1.1.0: {} + jsdom@25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4): + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.1.2 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.1(bufferutil@4.0.8)(utf-8-validate@6.0.4) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@0.5.0: {} jsesc@2.5.2: {} + jsesc@3.1.0: {} + json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} @@ -16343,7 +17239,7 @@ snapshots: launch-editor@2.8.1: dependencies: - picocolors: 1.0.1 + picocolors: 1.1.0 shell-quote: 1.8.1 lazystream@1.0.1: @@ -16379,7 +17275,7 @@ snapshots: mlly: 1.7.1 node-forge: 1.3.1 pathe: 1.1.2 - std-env: 3.7.0 + std-env: 3.10.0 ufo: 1.6.3 untun: 0.1.3 uqr: 0.1.2 @@ -16477,6 +17373,8 @@ snapshots: dependencies: react: 18.3.1 + lz-string@1.5.0: {} + magic-string@0.30.11: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -16485,16 +17383,28 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + source-map-js: 1.2.1 + make-dir@3.1.0: dependencies: semver: 6.3.1 + make-dir@4.0.0: + dependencies: + semver: 7.7.1 + make-error@1.3.6: {} markdown-extensions@2.0.0: {} markdown-table@3.0.3: {} + math-intrinsics@1.1.0: {} + mdast-util-directive@3.0.0: dependencies: '@types/mdast': 4.0.4 @@ -17029,6 +17939,8 @@ snapshots: mimic-response@4.0.0: {} + min-indent@1.0.1: {} + mini-css-extract-plugin@2.9.1(webpack@5.94.0): dependencies: schema-utils: 4.2.0 @@ -17148,7 +18060,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - nitropack@2.9.7(webpack-sources@3.2.3): + nitropack@2.9.7(magicast@0.3.5)(webpack-sources@3.2.3): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 '@netlify/functions': 2.8.1 @@ -17163,7 +18075,7 @@ snapshots: '@types/http-proxy': 1.17.15 '@vercel/nft': 0.26.5 archiver: 7.0.1 - c12: 1.11.2 + c12: 1.11.2(magicast@0.3.5) chalk: 5.3.0 chokidar: 3.6.0 citty: 0.1.6 @@ -17284,6 +18196,8 @@ snapshots: node-releases@2.0.18: {} + node-releases@2.0.27: {} + nopt@5.0.0: dependencies: abbrev: 1.1.1 @@ -17315,6 +18229,8 @@ snapshots: dependencies: boolbase: 1.0.0 + nwsapi@2.2.23: {} + nypm@0.3.11: dependencies: citty: 0.1.6 @@ -17494,11 +18410,11 @@ snapshots: pac-proxy-agent@7.0.2: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.1 + agent-base: 7.1.3 debug: 4.4.0 get-uri: 6.0.3 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.5 + https-proxy-agent: 7.0.6 pac-resolver: 7.0.1 socks-proxy-agent: 8.0.4 transitivePeerDependencies: @@ -17656,6 +18572,8 @@ snapshots: picocolors@1.1.0: {} + picocolors@1.1.1: {} + picomatch@2.3.1: {} picomatch@4.0.2: {} @@ -17930,7 +18848,7 @@ snapshots: postcss@8.4.47: dependencies: nanoid: 3.3.7 - picocolors: 1.1.0 + picocolors: 1.1.1 source-map-js: 1.2.1 postgres-array@3.0.2: {} @@ -17969,6 +18887,12 @@ snapshots: lodash: 4.17.21 renderkid: 3.0.0 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-format@3.8.0: {} pretty-time@1.1.0: {} @@ -18012,7 +18936,7 @@ snapshots: agent-base: 7.1.1 debug: 4.3.7 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.5 + https-proxy-agent: 7.0.6 lru-cache: 7.18.3 pac-proxy-agent: 7.0.2 proxy-from-env: 1.1.0 @@ -18187,6 +19111,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-json-view-lite@1.5.0(react@18.3.1): dependencies: react: 18.3.1 @@ -18197,6 +19123,8 @@ snapshots: react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.3.1)' webpack: 5.94.0 + react-refresh@0.18.0: {} + react-remove-scroll-bar@2.3.6(@types/react@18.3.3)(react@18.3.1): dependencies: react: 18.3.1 @@ -18334,6 +19262,11 @@ snapshots: dependencies: minimatch: 3.1.2 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redis-errors@1.2.0: {} redis-parser@3.0.0: @@ -18560,12 +19493,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.21.2 fsevents: 2.3.3 + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + rtl-detect@1.1.2: {} rtlcss@4.3.0: dependencies: escalade: 3.1.2 - picocolors: 1.0.1 + picocolors: 1.1.0 postcss: 8.4.47 strip-json-comments: 3.1.1 @@ -18604,6 +19541,10 @@ snapshots: sax@1.4.1: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -18817,7 +19758,7 @@ snapshots: socks-proxy-agent@8.0.4: dependencies: - agent-base: 7.1.1 + agent-base: 7.1.3 debug: 4.4.0 socks: 2.8.3 transitivePeerDependencies: @@ -19010,6 +19951,10 @@ snapshots: strip-final-newline@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -19077,13 +20022,15 @@ snapshots: css-tree: 2.3.1 css-what: 6.1.0 csso: 5.0.5 - picocolors: 1.1.0 + picocolors: 1.1.1 swap-case@1.1.2: dependencies: lower-case: 1.1.4 upper-case: 1.1.3 + symbol-tree@3.2.4: {} + system-architecture@0.1.0: {} tailwind-merge@2.4.0: {} @@ -19164,6 +20111,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + text-decoder@1.1.1: dependencies: b4a: 1.6.6 @@ -19213,6 +20166,12 @@ snapshots: no-case: 2.3.2 upper-case: 1.1.3 + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -19227,8 +20186,16 @@ snapshots: totalist@3.0.1: {} + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + tr46@0.0.3: {} + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -19526,13 +20493,19 @@ snapshots: dependencies: browserslist: 4.23.2 escalade: 3.1.2 - picocolors: 1.1.0 + picocolors: 1.1.1 update-browserslist-db@1.1.0(browserslist@4.23.3): dependencies: browserslist: 4.23.3 escalade: 3.1.2 - picocolors: 1.1.0 + picocolors: 1.1.1 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 update-check@1.5.4: dependencies: @@ -19659,7 +20632,7 @@ snapshots: fsevents: 2.3.3 terser: 5.31.6 - vitest@2.1.9(@types/node@20.16.5)(@vitest/ui@2.1.2)(terser@5.31.6): + vitest@2.1.9(@types/node@20.16.5)(@vitest/ui@2.1.2)(jsdom@25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4))(terser@5.31.6): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(vite@5.4.8(@types/node@20.16.5)(terser@5.31.6)) @@ -19684,6 +20657,7 @@ snapshots: optionalDependencies: '@types/node': 20.16.5 '@vitest/ui': 2.1.2(vitest@2.1.9) + jsdom: 25.0.1(bufferutil@4.0.8)(utf-8-validate@6.0.4) transitivePeerDependencies: - less - lightningcss @@ -19695,6 +20669,10 @@ snapshots: - supports-color - terser + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + watchpack@2.4.2: dependencies: glob-to-regexp: 0.4.1 @@ -19712,7 +20690,9 @@ snapshots: webidl-conversions@3.0.1: {} - webpack-bundle-analyzer@4.10.2(bufferutil@4.0.8): + webidl-conversions@7.0.0: {} + + webpack-bundle-analyzer@4.10.2(bufferutil@4.0.8)(utf-8-validate@6.0.4): dependencies: '@discoveryjs/json-ext': 0.5.7 acorn: 8.12.1 @@ -19725,7 +20705,7 @@ snapshots: opener: 1.5.2 picocolors: 1.0.1 sirv: 2.0.4 - ws: 7.5.10(bufferutil@4.0.8) + ws: 7.5.10(bufferutil@4.0.8)(utf-8-validate@6.0.4) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -19739,7 +20719,7 @@ snapshots: schema-utils: 4.2.0 webpack: 5.94.0 - webpack-dev-server@4.15.2(bufferutil@4.0.8)(webpack@5.94.0): + webpack-dev-server@4.15.2(bufferutil@4.0.8)(utf-8-validate@6.0.4)(webpack@5.94.0): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -19770,7 +20750,7 @@ snapshots: sockjs: 0.3.24 spdy: 4.0.2 webpack-dev-middleware: 5.3.4(webpack@5.94.0) - ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) + ws: 8.18.1(bufferutil@4.0.8)(utf-8-validate@6.0.4) optionalDependencies: webpack: 5.94.0 transitivePeerDependencies: @@ -19835,6 +20815,17 @@ snapshots: websocket-extensions@0.1.4: {} + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -19932,9 +20923,10 @@ snapshots: signal-exit: 3.0.7 typedarray-to-buffer: 3.1.5 - ws@7.5.10(bufferutil@4.0.8): + ws@7.5.10(bufferutil@4.0.8)(utf-8-validate@6.0.4): optionalDependencies: bufferutil: 4.0.8 + utf-8-validate: 6.0.4 ws@8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4): optionalDependencies: @@ -19952,6 +20944,10 @@ snapshots: dependencies: sax: 1.4.1 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..9b78000c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + exclude: [ + "**/node_modules/**", + "**/dist/**", + "**/.next/**", + "**/packages/validators/**", + "**/tooling/eslint/**", + "**/tooling/prettier/**", + "**/tooling/tailwind/**", + "**/tooling/typescript/**", + "**/tooling/github/**", + "**/turbo/generators/**", + "**/*.config.{js,ts,mjs,cjs}", + "**/vitest.workspace.ts", + "**/route.ts", + "**/font.ts", + "**/levels_company_names.ts", + "**/levels_company_data.ts", + "**/packages/auth/src/**", + "**/apps/web/src/trpc/**", + "**/apps/auth-proxy/**", + "**/apps/docs", + "**/apps/web/src/middleware.ts", + "**/packages/api/tests/mocks/**", + "**/packages/ab/src/client.ts", + "**/packages/api/src/index.ts", + "**/packages/api/src/trpc.ts", + "**/packages/db/src/client.ts" + ], + }, + }, +});