From ed0b3b7bd43163b72728ca0a3d640df9f14a1917 Mon Sep 17 00:00:00 2001 From: Felix Schulze Date: Mon, 24 Nov 2025 22:43:46 +0100 Subject: [PATCH] Adds Playwright E2E and Vitest test infrastructure Integrates Playwright for end-to-end browser testing with automated web server setup, example smoke tests, and CI-compatible configuration. Introduces Vitest, Testing Library, and related utilities for fast component and unit testing. Updates scripts, development dependencies, and lockfile to support both test suites. Establishes unified testing commands for local and CI workflows, laying groundwork for comprehensive automated UI and integration coverage. --- .cursorrules | 20 ++++++ .gitignore | 4 ++ app/dashboard/page.test.tsx | 120 ++++++++++++++++++++++++++++++++++++ app/dashboard/page.tsx | 1 + package.json | 3 + playwright.config.ts | 26 ++++++++ tests/e2e/smoke.spec.ts | 14 +++++ vitest.config.ts | 17 +++++ vitest.setup.ts | 2 + 9 files changed, 207 insertions(+) create mode 100644 .cursorrules create mode 100644 app/dashboard/page.test.tsx create mode 100644 playwright.config.ts create mode 100644 tests/e2e/smoke.spec.ts create mode 100644 vitest.config.ts create mode 100644 vitest.setup.ts 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'; +