diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 7f10838..9583389 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -40,11 +40,11 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | POST /api/cron/garmin-sync | 501 | Has CRON_SECRET auth check, core logic TODO | | POST /api/cron/notifications | 501 | Has CRON_SECRET auth check, core logic TODO | -### Pages (7 total, ALL placeholders) +### Pages (7 total) | Page | Status | Notes | |------|--------|-------| | Dashboard (`/`) | Placeholder | Needs real data integration | -| Login (`/login`) | Placeholder | Needs PocketBase auth integration | +| Login (`/login`) | **COMPLETE** | Email/password form with auth, error handling, loading states | | Settings (`/settings`) | Placeholder | Needs form implementation | | Settings/Garmin (`/settings/garmin`) | Placeholder | Needs token management UI | | Calendar (`/calendar`) | Placeholder | Needs MonthView integration | @@ -75,6 +75,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) | | `src/app/api/today/route.test.ts` | **EXISTS** - 22 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics) | | `src/app/api/overrides/route.test.ts` | **EXISTS** - 14 tests (POST/DELETE overrides, auth, validation, type checks) | +| `src/app/login/page.test.tsx` | **EXISTS** - 14 tests (form rendering, auth flow, error handling, validation) | | `src/lib/nutrition.test.ts` | **MISSING** | | `src/lib/email.test.ts` | **MISSING** | | `src/lib/ics.test.ts` | **MISSING** | @@ -210,12 +211,15 @@ Minimum viable product - app can be used for daily decisions. - **Why:** Emergency overrides are critical for flare days - **Depends On:** P0.1, P0.2, P0.3 -### P1.6: Login Page Implementation -- [ ] Functional login form with PocketBase auth +### P1.6: Login Page Implementation ✅ COMPLETE +- [x] Functional login form with PocketBase auth - **Files:** - - `src/app/login/page.tsx` - Form with email/password, error handling, redirect + - `src/app/login/page.tsx` - Client component with email/password form, error handling, loading states, redirect - **Tests:** - - E2E test: valid login redirects to dashboard, invalid shows error + - `src/app/login/page.test.tsx` - 14 tests covering rendering, form submission, auth flow, error handling, validation +- **Infrastructure Added:** + - `src/test-setup.ts` - Global test setup with @testing-library/jest-dom and cleanup + - Updated `vitest.config.ts` to include setupFiles - **Why:** Users need to authenticate to use the app - **Depends On:** P0.1 @@ -507,6 +511,12 @@ P2.14 Mini calendar - [x] **POST /api/overrides** - Adds override to user.activeOverrides array, 14 tests (P1.5) - [x] **DELETE /api/overrides** - Removes override from user.activeOverrides array, 14 tests (P1.5) +### Pages +- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6) + +### Test Infrastructure +- [x] **test-setup.ts** - Global test setup with @testing-library/jest-dom matchers and cleanup + --- ## Discovered Issues diff --git a/package.json b/package.json index c9dbf0c..58c294a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@biomejs/biome": "2.3.11", "@tailwindcss/postcss": "^4", "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@types/node": "^20", "@types/node-cron": "^3.0.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e28d3b4..02f8412 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 '@testing-library/react': specifier: ^16.3.1 version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -99,6 +102,9 @@ packages: '@acemir/cssom@0.9.30': resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -1184,6 +1190,10 @@ packages: 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.1': resolution: {integrity: sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==} engines: {node: '>=18'} @@ -1330,6 +1340,9 @@ packages: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssstyle@5.3.7: resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} engines: {node: '>=20'} @@ -1364,6 +1377,9 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + drizzle-kit@0.31.8: resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==} hasBin: true @@ -1547,6 +1563,10 @@ packages: ics@3.8.1: resolution: {integrity: sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -1668,6 +1688,10 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1758,6 +1782,10 @@ packages: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1825,6 +1853,10 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -2045,6 +2077,8 @@ snapshots: '@acemir/cssom@0.9.30': {} + '@adobe/css-tools@4.4.4': {} + '@alloc/quick-lru@5.2.0': {} '@asamuzakjp/css-color@4.1.1': @@ -2784,6 +2818,15 @@ snapshots: 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.3.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + '@testing-library/react@16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.28.4 @@ -2938,6 +2981,8 @@ snapshots: mdn-data: 2.12.2 source-map-js: 1.2.1 + css.escape@1.5.1: {} + cssstyle@5.3.7: dependencies: '@asamuzakjp/css-color': 4.1.1 @@ -2964,6 +3009,8 @@ snapshots: dom-accessibility-api@0.5.16: {} + dom-accessibility-api@0.6.3: {} + drizzle-kit@0.31.8: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -3127,6 +3174,8 @@ snapshots: runes2: 1.1.4 yup: 1.7.1 + indent-string@4.0.0: {} + is-potential-custom-element-name@1.0.1: {} jiti@2.6.1: {} @@ -3232,6 +3281,8 @@ snapshots: mdn-data@2.12.2: {} + min-indent@1.0.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -3311,6 +3362,11 @@ snapshots: react@19.2.3: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + require-from-string@2.0.2: {} resend@6.7.0: @@ -3415,6 +3471,10 @@ snapshots: std-env@3.10.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3): dependencies: client-only: 0.0.1 diff --git a/src/app/login/page.test.tsx b/src/app/login/page.test.tsx new file mode 100644 index 0000000..c152693 --- /dev/null +++ b/src/app/login/page.test.tsx @@ -0,0 +1,261 @@ +// ABOUTME: Unit tests for the Login page component. +// ABOUTME: Tests form rendering, validation, auth flow, and error handling. +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock next/navigation +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: mockPush, + }), +})); + +// Mock PocketBase +const mockAuthWithPassword = vi.fn(); +vi.mock("@/lib/pocketbase", () => ({ + pb: { + collection: () => ({ + authWithPassword: mockAuthWithPassword, + }), + }, +})); + +import LoginPage from "./page"; + +describe("LoginPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("rendering", () => { + it("renders the login form with email and password inputs", () => { + render(); + + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + }); + + it("renders a sign in button", () => { + render(); + + expect( + screen.getByRole("button", { name: /sign in/i }), + ).toBeInTheDocument(); + }); + + it("renders the PhaseFlow branding", () => { + render(); + + expect(screen.getByText(/phaseflow/i)).toBeInTheDocument(); + }); + + it("has email input with type email", () => { + render(); + + const emailInput = screen.getByLabelText(/email/i); + expect(emailInput).toHaveAttribute("type", "email"); + }); + + it("has password input with type password", () => { + render(); + + const passwordInput = screen.getByLabelText(/password/i); + expect(passwordInput).toHaveAttribute("type", "password"); + }); + }); + + describe("form submission", () => { + it("calls PocketBase auth with email and password on submit", async () => { + mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" }); + render(); + + const emailInput = screen.getByLabelText(/email/i); + const passwordInput = screen.getByLabelText(/password/i); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput, { target: { value: "password123" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockAuthWithPassword).toHaveBeenCalledWith( + "test@example.com", + "password123", + ); + }); + }); + + it("redirects to dashboard on successful login", async () => { + mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" }); + render(); + + const emailInput = screen.getByLabelText(/email/i); + const passwordInput = screen.getByLabelText(/password/i); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput, { target: { value: "password123" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith("/"); + }); + }); + + it("shows loading state while authenticating", async () => { + // Create a promise that we can control + let resolveAuth: (value: unknown) => void; + const authPromise = new Promise((resolve) => { + resolveAuth = resolve; + }); + mockAuthWithPassword.mockReturnValue(authPromise); + + render(); + + const emailInput = screen.getByLabelText(/email/i); + const passwordInput = screen.getByLabelText(/password/i); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput, { target: { value: "password123" } }); + fireEvent.click(submitButton); + + // Button should show loading state + await waitFor(() => { + expect( + screen.getByRole("button", { name: /signing in/i }), + ).toBeInTheDocument(); + }); + + // Resolve the auth + resolveAuth?.({ token: "test-token" }); + }); + + it("disables form inputs while loading", async () => { + let resolveAuth: (value: unknown) => void; + const authPromise = new Promise((resolve) => { + resolveAuth = resolve; + }); + mockAuthWithPassword.mockReturnValue(authPromise); + + render(); + + const emailInput = screen.getByLabelText(/email/i); + const passwordInput = screen.getByLabelText(/password/i); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput, { target: { value: "password123" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(emailInput).toBeDisabled(); + expect(passwordInput).toBeDisabled(); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + resolveAuth?.({ token: "test-token" }); + }); + }); + + describe("error handling", () => { + it("shows error message on failed login", async () => { + mockAuthWithPassword.mockRejectedValueOnce( + new Error("Invalid credentials"), + ); + render(); + + const emailInput = screen.getByLabelText(/email/i); + const passwordInput = screen.getByLabelText(/password/i); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput, { target: { value: "wrongpassword" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument(); + }); + }); + + it("clears error when user starts typing again", async () => { + mockAuthWithPassword.mockRejectedValueOnce( + new Error("Invalid credentials"), + ); + render(); + + const emailInput = screen.getByLabelText(/email/i); + const passwordInput = screen.getByLabelText(/password/i); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput, { target: { value: "wrongpassword" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + // Start typing again + fireEvent.change(emailInput, { target: { value: "new@example.com" } }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("re-enables form after error", async () => { + mockAuthWithPassword.mockRejectedValueOnce( + new Error("Invalid credentials"), + ); + render(); + + const emailInput = screen.getByLabelText(/email/i); + const passwordInput = screen.getByLabelText(/password/i); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput, { target: { value: "wrongpassword" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + // Form should be re-enabled + expect(emailInput).not.toBeDisabled(); + expect(passwordInput).not.toBeDisabled(); + expect( + screen.getByRole("button", { name: /sign in/i }), + ).not.toBeDisabled(); + }); + }); + + describe("validation", () => { + it("does not submit with empty email", async () => { + render(); + + const passwordInput = screen.getByLabelText(/password/i); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + fireEvent.change(passwordInput, { target: { value: "password123" } }); + fireEvent.click(submitButton); + + // Should not call auth + expect(mockAuthWithPassword).not.toHaveBeenCalled(); + }); + + it("does not submit with empty password", async () => { + render(); + + const emailInput = screen.getByLabelText(/email/i); + const submitButton = screen.getByRole("button", { name: /sign in/i }); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.click(submitButton); + + // Should not call auth + expect(mockAuthWithPassword).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 7909ca3..05e8e54 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,12 +1,111 @@ // ABOUTME: Login page for user authentication. // ABOUTME: Provides email/password login form using PocketBase auth. +"use client"; + +import { useRouter } from "next/navigation"; +import { type FormEvent, useState } from "react"; + +import { pb } from "@/lib/pocketbase"; + export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + // Basic validation - don't submit with empty fields + if (!email.trim() || !password.trim()) { + return; + } + + setIsLoading(true); + setError(null); + + try { + await pb.collection("users").authWithPassword(email, password); + router.push("/"); + } catch (err) { + const message = + err instanceof Error ? err.message : "Invalid credentials"; + setError(message); + setIsLoading(false); + } + }; + + const handleInputChange = ( + setter: React.Dispatch>, + value: string, + ) => { + setter(value); + // Clear error when user starts typing again + if (error) { + setError(null); + } + }; + return (
-

PhaseFlow Login

- {/* Login form will be implemented here */} -

Login form placeholder

+

PhaseFlow

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + handleInputChange(setEmail, e.target.value)} + disabled={isLoading} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" + required + /> +
+ +
+ + handleInputChange(setPassword, e.target.value)} + disabled={isLoading} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" + required + /> +
+ + +
); diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..641330c --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,10 @@ +// ABOUTME: Test setup file that configures testing utilities. +// ABOUTME: Imports jest-dom matchers and sets up cleanup after each test. +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +// Cleanup after each test to avoid component accumulation +afterEach(() => { + cleanup(); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 5eb1a9c..847dc82 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,5 +14,6 @@ export default defineConfig({ test: { environment: "jsdom", include: ["src/**/*.test.{ts,tsx}"], + setupFiles: ["./src/test-setup.ts"], }, });