Adds Playwright E2E and Vitest test infrastructure
Some checks failed
Lint / Lint and Check (push) Failing after 48s
Some checks failed
Lint / Lint and Check (push) Failing after 48s
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.
This commit is contained in:
20
.cursorrules
Normal file
20
.cursorrules
Normal file
@@ -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 (`@/...`).
|
||||||
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -36,3 +36,7 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
.turbo/
|
.turbo/
|
||||||
|
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
test-results/
|
||||||
|
|||||||
120
app/dashboard/page.test.tsx
Normal file
120
app/dashboard/page.test.tsx
Normal file
@@ -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(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<Dashboard />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test Habit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens edit dialog when edit button is clicked', async () => {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<Dashboard />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<Dashboard />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -451,6 +451,7 @@ export default function Dashboard() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/20"
|
className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/20"
|
||||||
onClick={(e) => openEditDialog(e, habit)}
|
onClick={(e) => openEditDialog(e, habit)}
|
||||||
|
data-testid={`edit-habit-${habit.id}`}
|
||||||
>
|
>
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -8,6 +8,9 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next typegen && eslint . && npx tsc --noEmit",
|
"lint": "next typegen && eslint . && npx tsc --noEmit",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
@@ -43,22 +46,29 @@
|
|||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.56.1",
|
||||||
"@tailwindcss/postcss": "4.1.17",
|
"@tailwindcss/postcss": "4.1.17",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/node": "24.10.1",
|
"@types/node": "24.10.1",
|
||||||
"@types/pg": "8.15.6",
|
"@types/pg": "8.15.6",
|
||||||
"@types/react": "19.2.6",
|
"@types/react": "19.2.6",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"drizzle-kit": "0.31.7",
|
"drizzle-kit": "0.31.7",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.3",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"jsdom": "^27.2.0",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "0.7.1",
|
"prettier-plugin-tailwindcss": "0.7.1",
|
||||||
"tailwindcss": "4.1.17",
|
"tailwindcss": "4.1.17",
|
||||||
"turbo": "2.6.1",
|
"turbo": "2.6.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.47.0"
|
"typescript-eslint": "8.47.0",
|
||||||
|
"vitest": "^4.0.13"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.23.0",
|
"packageManager": "pnpm@10.23.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
|||||||
26
playwright.config.ts
Normal file
26
playwright.config.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
1100
pnpm-lock.yaml
generated
1100
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
14
tests/e2e/smoke.spec.ts
Normal file
14
tests/e2e/smoke.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
17
vitest.config.ts
Normal file
17
vitest.config.ts
Normal file
@@ -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/**'],
|
||||||
|
},
|
||||||
|
});
|
||||||
2
vitest.setup.ts
Normal file
2
vitest.setup.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
Reference in New Issue
Block a user