diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..af1fbe6 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,20 @@ +# Project Rules + +## Testing Policy +- **Mandatory Tests**: All new features and bug fixes must be accompanied by tests. +- **Unit/Integration Tests**: Use **Vitest** for testing utilities, hooks, and components. +- **E2E Tests**: Use **Playwright** for critical user flows (auth, core features). +- **Coverage**: Aim for high coverage on business logic and critical paths. + +## Tech Stack +- **Framework**: Next.js 15 (App Router) +- **Language**: TypeScript +- **Styling**: Tailwind CSS +- **Database**: PostgreSQL with Drizzle ORM +- **State Management**: React Query + +## Code Style +- **Functional Components**: Use arrow functions for components. +- **Types**: strict TypeScript usage (avoid `any`). +- **Imports**: Use absolute imports (`@/...`). + diff --git a/.gitignore b/.gitignore index dce92ca..eaed81d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ yarn-error.log* next-env.d.ts .turbo/ + +playwright-report/ + +test-results/ diff --git a/app/dashboard/page.test.tsx b/app/dashboard/page.test.tsx new file mode 100644 index 0000000..9bce6a0 --- /dev/null +++ b/app/dashboard/page.test.tsx @@ -0,0 +1,120 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Dashboard from './page'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + }), +})); + +// Mock ResizeObserver +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +// Mock fetch +global.fetch = vi.fn(); + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +describe('Dashboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock Auth Response + (global.fetch as any).mockImplementation((url: string) => { + if (url === '/api/auth') { + return Promise.resolve({ + json: () => Promise.resolve({ authenticated: true, token: 'test-token' }), + }); + } + if (url === '/api/habits') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + habits: [ + { + id: 1, + name: 'Test Habit', + type: 'neutral', + totalLogs: 5, + logsLastWeek: 2, + logsLastMonth: 5, + createdAt: new Date().toISOString(), + lastLoggedAt: new Date().toISOString(), + } + ] + }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + }); + + it('renders habits correctly', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Test Habit')).toBeInTheDocument(); + }); + }); + + it('opens edit dialog when edit button is clicked', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Test Habit')).toBeInTheDocument(); + }); + + const editBtn = screen.getByTestId('edit-habit-1'); + fireEvent.click(editBtn); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Edit Habit')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Test Habit')).toBeInTheDocument(); + }); + }); + + it('opens archive confirmation when archive button is clicked', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Test Habit')).toBeInTheDocument(); + }); + + // Open Edit Dialog + fireEvent.click(screen.getByTestId('edit-habit-1')); + await waitFor(() => screen.getByRole('dialog')); + + // Click Archive + fireEvent.click(screen.getByText('Archive Habit')); + + await waitFor(() => { + expect(screen.getByText('Confirm Delete')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index d66ad68..af42ef5 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -451,6 +451,7 @@ export default function Dashboard() { size="icon" className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/20" onClick={(e) => openEditDialog(e, habit)} + data-testid={`edit-habit-${habit.id}`} > diff --git a/package.json b/package.json index 53c7b2f..b77e76f 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "build": "next build", "start": "next start", "lint": "next typegen && eslint . && npx tsc --noEmit", + "test": "vitest", + "test:e2e": "playwright test", + "test:coverage": "vitest run --coverage", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..9e5c8d0 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'pnpm dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}); + diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..8daf6ff --- /dev/null +++ b/tests/e2e/smoke.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '@playwright/test'; + +test('landing page loads and has create account button', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Track Every Day' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Start Tracking Now' })).toBeVisible(); +}); + +test('can navigate to login input', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'I Have a Token' }).click(); + await expect(page.getByLabel('Access Token')).toBeVisible(); +}); + diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..deef3bc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./vitest.setup.ts'], + alias: { + '@': path.resolve(__dirname, './'), + }, + // Exclude E2E tests from Vitest + exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**', '**/tests/e2e/**'], + }, +}); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..adee3c8 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,2 @@ +import '@testing-library/jest-dom'; +