diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0ff1a71..6038970 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,3 +65,43 @@ jobs: yarn lint yarn typecheck yarn test --run + + e2e: + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: actions/checkout@v4 + + - name: Boot full Docker Compose stack + run: | + docker compose up -d --build + timeout 120 bash -c 'until curl -sf http://localhost:3000 > /dev/null 2>&1; do sleep 2; done' + timeout 60 bash -c 'until curl -sf http://localhost:8088/health > /dev/null 2>&1; do sleep 2; done' + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Enable Corepack and install deps + run: corepack enable + + - name: Install frontend deps + Playwright + working-directory: frontend + run: | + yarn install --immutable + npx playwright install --with-deps chromium + + - name: Run E2E tests + working-directory: frontend + run: npx playwright test --reporter=html + + - name: Upload E2E report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: frontend/playwright-report/ + + - name: Teardown + if: always() + run: docker compose down -v diff --git a/.gitignore b/.gitignore index e613ae6..067a959 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,9 @@ demo.webm docs/prd backup -design \ No newline at end of file +design + +# Playwright +frontend/playwright-report/ +frontend/test-results/ +frontend/e2e/.auth/ \ No newline at end of file diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 0000000..9aafda0 --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Authentication', () => { + test.use({ storageState: { cookies: [], origins: [] } }); // No pre-auth for login tests + + test('signin page renders form fields', async ({ page }) => { + await page.goto('/signin'); + await expect(page.locator('input#email')).toBeVisible(); + await expect(page.locator('input#password')).toBeVisible(); + await expect(page.locator('button[type="submit"]')).toBeVisible(); + await expect(page.locator('button[type="submit"]')).toHaveText(/Sign In/); + }); + + test('signin form submits without error', async ({ page }) => { + await page.goto('/signin'); + await page.fill('input#email', 'e2e@test.local'); + await page.fill('input#password', 'e2e-test-password-123'); + await page.click('button[type="submit"]'); + await page.waitForTimeout(3000); + // Should either redirect or show no error (no destructive error banner) + const errorBanner = page.locator('[class*="destructive"]'); + const errorCount = await errorBanner.count(); + expect(errorCount).toBe(0); + }); + + test('signin with invalid credentials shows error', async ({ page }) => { + await page.goto('/signin'); + await page.fill('input#email', 'wrong@test.local'); + await page.fill('input#password', 'wrong-password'); + await page.click('button[type="submit"]'); + // Should stay on signin page or show error + await page.waitForTimeout(2000); + await expect(page).toHaveURL(/\/signin/); + }); +}); + +test.describe('Authenticated navigation', () => { + test('authenticated user sees TopBar with user menu', async ({ page }) => { + await page.goto('/projects'); + await expect(page.locator('button[aria-label="User menu"]')).toBeVisible(); + await expect(page.locator('button[aria-label="Toggle theme"]')).toBeVisible(); + }); + + test('unauthenticated user is redirected to signin', async ({ page, context }) => { + await context.clearCookies(); + await page.goto('/projects'); + await page.waitForURL('**/signin', { timeout: 10000 }); + await expect(page).toHaveURL(/\/signin/); + }); +}); diff --git a/frontend/e2e/chat-panel.spec.ts b/frontend/e2e/chat-panel.spec.ts new file mode 100644 index 0000000..b81c02c --- /dev/null +++ b/frontend/e2e/chat-panel.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Chat Panel', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/projects'); + await page.waitForTimeout(1000); + const cards = page.locator('[class*="card" i], [class*="Card" i]').filter({ hasText: /section/ }); + if (await cards.count() === 0) { test.skip(); return; } + await cards.first().click(); + await page.waitForURL(/\/projects\/[^/]+$/, { timeout: 10000 }); + }); + + test('chat panel renders if enabled', async ({ page }) => { + const chatInput = page.locator('textarea[placeholder="Type a message..."]'); + const chatVisible = await chatInput.isVisible().catch(() => false); + + if (chatVisible) { + // Chat is enabled — verify UI elements + await expect(chatInput).toBeVisible(); + await expect(page.getByRole('button', { name: /Send/ })).toBeVisible(); + } + // If chat is not visible, it's disabled in settings — that's valid too + }); + + test('send button is disabled when input is empty', async ({ page }) => { + const chatInput = page.locator('textarea[placeholder="Type a message..."]'); + if (await chatInput.isVisible().catch(() => false)) { + const sendButton = page.getByRole('button', { name: /Send/ }); + await expect(sendButton).toBeDisabled(); + } + }); + + test('typing a message enables send button', async ({ page }) => { + const chatInput = page.locator('textarea[placeholder="Type a message..."]'); + if (await chatInput.isVisible().catch(() => false)) { + await chatInput.fill('Hello test message'); + const sendButton = page.getByRole('button', { name: /Send/ }); + await expect(sendButton).toBeEnabled(); + } + }); + + test('attach file button is present', async ({ page }) => { + const chatInput = page.locator('textarea[placeholder="Type a message..."]'); + if (await chatInput.isVisible().catch(() => false)) { + const attachButton = page.getByRole('button', { name: /Attach/i }).or( + page.locator('button').filter({ hasText: /attach/i }) + ); + // Attach might be an icon-only button + const fileInput = page.locator('input[type="file"]'); + await expect(fileInput).toBeAttached(); + } + }); +}); diff --git a/frontend/e2e/dependencies.spec.ts b/frontend/e2e/dependencies.spec.ts new file mode 100644 index 0000000..3abfcd5 --- /dev/null +++ b/frontend/e2e/dependencies.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Dependencies View', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/projects'); + await page.waitForTimeout(1000); + const cards = page.locator('[class*="card" i], [class*="Card" i]').filter({ hasText: /section/ }); + if (await cards.count() === 0) { test.skip(); return; } + await cards.first().click(); + await page.waitForURL(/\/projects\/[^/]+$/, { timeout: 10000 }); + await page.getByRole('tab', { name: 'Dependencies' }).click(); + }); + + test('dependencies tab renders content', async ({ page }) => { + const tabPanel = page.getByRole('tabpanel'); + await expect(tabPanel).toBeVisible(); + }); + + test('graph/list view toggle exists', async ({ page }) => { + await page.waitForTimeout(1000); + // Look for graph/list toggle buttons + const graphButton = page.getByRole('button', { name: /Graph/i }); + const listButton = page.getByRole('button', { name: /List/i }); + + const hasToggle = (await graphButton.count()) > 0 || (await listButton.count()) > 0; + expect(hasToggle).toBeTruthy(); + }); + + test('toggling between graph and list view works', async ({ page }) => { + await page.waitForTimeout(1000); + const graphButton = page.getByRole('button', { name: /Graph/i }); + const listButton = page.getByRole('button', { name: /List/i }); + + if (await graphButton.isVisible()) { + await graphButton.click(); + await page.waitForTimeout(500); + // Should see SVG graph or graph container + } + + if (await listButton.isVisible()) { + await listButton.click(); + await page.waitForTimeout(500); + // Should see list view + } + }); + + test('dependency legend is visible in graph view', async ({ page }) => { + await page.waitForTimeout(1000); + // Check for dependency type labels + const types = ['references', 'implements', 'blocks', 'extends']; + let foundAny = false; + for (const t of types) { + const el = page.getByText(new RegExp(t, 'i')); + if (await el.count() > 0) { + foundAny = true; + break; + } + } + expect(foundAny).toBeTruthy(); + }); +}); diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 0000000..5030b2a --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,87 @@ +import { chromium, type FullConfig } from '@playwright/test'; + +const BASE_URL = 'http://localhost:3000'; +const API_URL = 'http://localhost:8088'; +const TEST_USER = { + name: 'E2E Test User', + email: 'e2e@test.local', + password: 'e2e-test-password-123', +}; + +async function globalSetup(_config: FullConfig) { + // Step 1: Try bootstrap (creates first user + org on fresh DB) + const setupRes = await fetch(`${BASE_URL}/api/auth/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(TEST_USER), + }); + + if (setupRes.status === 200) { + console.log('Global setup: created first user via /api/auth/setup'); + } else if (setupRes.status === 409) { + console.log('Global setup: bootstrap already done, ensuring test user exists...'); + const signupRes = await fetch(`${BASE_URL}/api/auth/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: TEST_USER.name, + email: TEST_USER.email, + password: TEST_USER.password, + }), + }); + if (signupRes.ok) { + console.log('Global setup: created test user via signup'); + } else { + console.log('Global setup: test user likely already exists'); + } + } else { + const body = await setupRes.text(); + throw new Error(`Auth setup failed (${setupRes.status}): ${body}`); + } + + // Step 2: Sign in via browser to get session cookie + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(`${BASE_URL}/signin`); + await page.fill('input#email', TEST_USER.email); + await page.fill('input#password', TEST_USER.password); + await page.click('button[type="submit"]'); + await page.waitForURL('**/projects', { timeout: 15000 }); + + // Step 3: Ensure test user has at least one project + // Create a test project if none exist (uses the authenticated session) + const projectsRes = await page.evaluate(async () => { + const res = await fetch('/api/projects'); + return res.json(); + }); + + if (!projectsRes || !Array.isArray(projectsRes) || projectsRes.length === 0) { + console.log('Global setup: no projects found, creating test project...'); + await page.evaluate(async () => { + await fetch('/api/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'E2E Test Project', + description: 'Automated test project for E2E testing', + template: 'saas-mvp', + }), + }); + }); + // Wait for project to be created + await page.waitForTimeout(2000); + console.log('Global setup: test project created'); + } else { + console.log(`Global setup: ${projectsRes.length} project(s) already exist`); + } + + // Step 4: Save authenticated state for all tests + await context.storageState({ path: './e2e/.auth/user.json' }); + await browser.close(); + + console.log('Global setup: complete'); +} + +export default globalSetup; diff --git a/frontend/e2e/project-workspace.spec.ts b/frontend/e2e/project-workspace.spec.ts new file mode 100644 index 0000000..e8cff93 --- /dev/null +++ b/frontend/e2e/project-workspace.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Project Workspace', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/projects'); + await page.waitForTimeout(1000); + const cards = page.locator('[class*="card" i], [class*="Card" i]').filter({ hasText: /section/ }); + const cardCount = await cards.count(); + if (cardCount === 0) { + test.skip(); + return; + } + await cards.first().click(); + await page.waitForURL(/\/projects\/[^/]+$/, { timeout: 10000 }); + }); + + test('workspace renders 3-column layout', async ({ page }) => { + // Left sidebar (sections nav) + await expect(page.locator('nav').filter({ hasText: 'Sections' })).toBeVisible(); + + // Center content area should have content + const mainContent = page.locator('main, [class*="flex-1"]').first(); + await expect(mainContent).toBeVisible(); + }); + + test('tab bar renders all tabs', async ({ page }) => { + const tabs = ['Sections', 'Comments', 'Dependencies', 'Changelog', 'Stats']; + for (const tabName of tabs) { + await expect(page.getByRole('tab', { name: tabName })).toBeVisible(); + } + }); + + test('clicking each tab switches content', async ({ page }) => { + // Sections tab (default) + await page.getByRole('tab', { name: 'Sections' }).click(); + await expect(page.getByRole('tabpanel')).toBeVisible(); + + // Comments tab + await page.getByRole('tab', { name: 'Comments' }).click(); + await expect(page.getByRole('tabpanel')).toBeVisible(); + + // Dependencies tab + await page.getByRole('tab', { name: 'Dependencies' }).click(); + await expect(page.getByRole('tabpanel')).toBeVisible(); + + // Changelog tab + await page.getByRole('tab', { name: 'Changelog' }).click(); + await expect(page.getByRole('tabpanel')).toBeVisible(); + + // Stats tab + await page.getByRole('tab', { name: 'Stats' }).click(); + await expect(page.getByRole('tabpanel')).toBeVisible(); + }); + + test('sidebar shows section list', async ({ page }) => { + // Sections sidebar may use nav, aside, or div — look for the section list + await page.waitForTimeout(1000); + const sidebarButtons = page.locator('button').filter({ hasText: /\d+w/ }); + const count = await sidebarButtons.count(); + // At least one section with word count should be visible + if (count > 0) { + await expect(sidebarButtons.first()).toBeVisible(); + } else { + // Fallback: look for any clickable section items in sidebar area + const sectionItems = page.getByText(/section|overview|feature/i); + expect(await sectionItems.count()).toBeGreaterThan(0); + } + }); + + test('clicking a section in sidebar loads its content', async ({ page }) => { + const sidebarButtons = page.locator('nav button'); + const count = await sidebarButtons.count(); + if (count > 1) { + // Click second section + await sidebarButtons.nth(1).click(); + // Content area should update (wait for any loading to finish) + await page.waitForTimeout(1000); + // The clicked section should become active + await expect(sidebarButtons.nth(1)).toHaveClass(/bg-accent|font-medium/); + } + }); + + test('section viewer shows markdown content', async ({ page }) => { + // Wait for section content to load + await page.waitForTimeout(1000); + // Should see rendered markdown (headings, paragraphs) + const contentArea = page.locator('[class*="prose"], [class*="markdown"]'); + if (await contentArea.count() > 0) { + await expect(contentArea.first()).toBeVisible(); + } + }); + + test('export buttons are visible', async ({ page }) => { + await expect(page.getByRole('button', { name: /Preview/ })).toBeVisible(); + await expect(page.getByRole('button', { name: '.md' })).toBeVisible(); + }); +}); diff --git a/frontend/e2e/projects-dashboard.spec.ts b/frontend/e2e/projects-dashboard.spec.ts new file mode 100644 index 0000000..a34b5ee --- /dev/null +++ b/frontend/e2e/projects-dashboard.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Projects Dashboard', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/projects'); + }); + + test('page renders with heading and new project button', async ({ page }) => { + await expect(page.getByText('Projects')).toBeVisible(); + await expect(page.getByRole('button', { name: /New Project/ })).toBeVisible(); + }); + + test('project cards display with metadata', async ({ page }) => { + // Seed data should include at least one project (SnapHabit) + const cards = page.locator('[class*="card" i], [class*="Card" i]').filter({ hasText: /section/ }); + const cardCount = await cards.count(); + if (cardCount > 0) { + const firstCard = cards.first(); + await expect(firstCard).toBeVisible(); + // Card should show section count and word count + await expect(firstCard.getByText(/section/)).toBeVisible(); + } + }); + + test('clicking a project card navigates to project detail', async ({ page }) => { + const cards = page.locator('[class*="card" i], [class*="Card" i]').filter({ hasText: /section/ }); + const cardCount = await cards.count(); + if (cardCount > 0) { + await cards.first().click(); + await page.waitForURL(/\/projects\/[^/]+$/, { timeout: 10000 }); + await expect(page).toHaveURL(/\/projects\/[^/]+$/); + } + }); + + test('New Project dialog opens and has form fields', async ({ page }) => { + await page.getByRole('button', { name: /New Project/ }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Verify key elements: Template/Blueprint label, Name input, Description textarea + await expect(dialog.getByText(/Template|Blueprint/i)).toBeVisible(); + await expect(dialog.getByText(/Name/i)).toBeVisible(); + await expect(dialog.locator('input#project-name')).toBeVisible(); + await expect(dialog.locator('textarea#project-description')).toBeVisible(); + await expect(dialog.getByRole('button', { name: /Create/ })).toBeVisible(); + await expect(dialog.getByRole('button', { name: /Cancel/ })).toBeVisible(); + }); + + test('New Project dialog can be filled and cancelled', async ({ page }) => { + await page.getByRole('button', { name: /New Project/ }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Fill form + await dialog.locator('input#project-name').fill('Test Project'); + await dialog.locator('textarea#project-description').fill('Test description'); + + // Cancel + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).not.toBeVisible({ timeout: 3000 }); + }); +}); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts new file mode 100644 index 0000000..e7305aa --- /dev/null +++ b/frontend/e2e/settings.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Project Settings', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/projects'); + await page.waitForTimeout(1000); + const cards = page.locator('[class*="card" i], [class*="Card" i]').filter({ hasText: /section/ }); + const count = await cards.count(); + if (count === 0) { + test.skip(); + return; + } + await cards.first().click(); + await page.waitForURL(/\/projects\/[^/]+$/, { timeout: 10000 }); + + // Navigate to settings — try button first, then user menu + const settingsButton = page.getByRole('button', { name: /Settings/ }); + if (await settingsButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await settingsButton.click(); + } else { + const userMenu = page.getByRole('button', { name: /User menu/ }); + if (await userMenu.isVisible({ timeout: 3000 }).catch(() => false)) { + await userMenu.click(); + await page.getByRole('menuitem', { name: /Settings/ }).click(); + } + } + await page.waitForURL(/\/settings/, { timeout: 10000 }); + }); + + test('settings page loads with heading', async ({ page }) => { + await expect(page.getByText(/Settings/i).first()).toBeVisible(); + }); + + test('toggle switches are visible', async ({ page }) => { + await page.waitForTimeout(1000); + const toggles = page.locator('button[role="switch"]'); + const count = await toggles.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test('comment auto-replies toggle can be clicked', async ({ page }) => { + const toggles = page.locator('button[role="switch"]'); + if (await toggles.count() > 0) { + const firstToggle = toggles.first(); + const initialState = await firstToggle.getAttribute('aria-checked'); + await firstToggle.click(); + await page.waitForTimeout(1000); + const newState = await firstToggle.getAttribute('aria-checked'); + // State should change + expect(newState).not.toBe(initialState); + // Toggle back + await firstToggle.click(); + } + }); + + test('provider and model dropdowns are visible', async ({ page }) => { + // Look for select/combobox elements + const selects = page.locator('[role="combobox"]'); + const selectCount = await selects.count(); + // Should have at least provider and model selects (if chat is enabled) + if (selectCount >= 2) { + await expect(selects.first()).toBeVisible(); + await expect(selects.nth(1)).toBeVisible(); + } + }); + + test('members section displays', async ({ page }) => { + const membersSection = page.getByText(/Members/i); + await expect(membersSection.first()).toBeVisible(); + }); +}); diff --git a/frontend/e2e/stats-dashboard.spec.ts b/frontend/e2e/stats-dashboard.spec.ts new file mode 100644 index 0000000..e0f5c0e --- /dev/null +++ b/frontend/e2e/stats-dashboard.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Stats Dashboard', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/projects'); + await page.waitForTimeout(1000); + const cards = page.locator('[class*="card" i], [class*="Card" i]').filter({ hasText: /section/ }); + if (await cards.count() === 0) { test.skip(); return; } + await cards.first().click(); + await page.waitForURL(/\/projects\/[^/]+$/, { timeout: 10000 }); + await page.getByRole('tab', { name: 'Stats' }).click(); + }); + + test('stats tab renders metric cards', async ({ page }) => { + // Wait for stats to load + await page.waitForTimeout(2000); + const tabPanel = page.getByRole('tabpanel'); + await expect(tabPanel).toBeVisible(); + + // Should show some metric-related content (cards, charts, or empty state) + const hasContent = await tabPanel.locator('text=/token|savings|section|operation/i').count(); + expect(hasContent).toBeGreaterThan(0); + }); + + test('stats tab shows charts or empty state', async ({ page }) => { + await page.waitForTimeout(2000); + // Look for recharts SVG elements or empty state messages + const charts = page.locator('svg.recharts-surface, [class*="recharts"]'); + const emptyState = page.getByText(/no data|no usage|no activity/i); + + const chartCount = await charts.count(); + const emptyCount = await emptyState.count(); + + // Either charts render OR empty state shows — both are valid + expect(chartCount + emptyCount).toBeGreaterThan(0); + }); +}); diff --git a/frontend/e2e/theme-toggle.spec.ts b/frontend/e2e/theme-toggle.spec.ts new file mode 100644 index 0000000..ac4c6cd --- /dev/null +++ b/frontend/e2e/theme-toggle.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Theme Toggle', () => { + test('theme toggle button exists on projects page', async ({ page }) => { + await page.goto('/projects'); + const themeButton = page.locator('button[aria-label="Toggle theme"]'); + await expect(themeButton).toBeVisible(); + }); + + test('clicking theme toggle changes body class', async ({ page }) => { + await page.goto('/projects'); + const themeButton = page.locator('button[aria-label="Toggle theme"]'); + + // Get initial theme state + const initialDark = await page.locator('html').evaluate(el => el.classList.contains('dark')); + + // Toggle theme + await themeButton.click(); + await page.waitForTimeout(500); + + // Verify class changed + const afterToggle = await page.locator('html').evaluate(el => el.classList.contains('dark')); + expect(afterToggle).not.toBe(initialDark); + + // Toggle back to restore original state + await themeButton.click(); + await page.waitForTimeout(500); + + const restored = await page.locator('html').evaluate(el => el.classList.contains('dark')); + expect(restored).toBe(initialDark); + }); + + test('theme persists across navigation', async ({ page }) => { + await page.goto('/projects'); + const themeButton = page.locator('button[aria-label="Toggle theme"]'); + + // Set to dark mode + const isDark = await page.locator('html').evaluate(el => el.classList.contains('dark')); + if (!isDark) { + await themeButton.click(); + await page.waitForTimeout(500); + } + + // Navigate to a different page and back + await page.goto('/projects'); + await page.waitForTimeout(1000); + + // Should still be dark + const stillDark = await page.locator('html').evaluate(el => el.classList.contains('dark')); + expect(stillDark).toBe(true); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 143b24b..69c6e53 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,9 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "vitest" + "test": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@prisma/client": "5.22.0", @@ -39,6 +41,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", + "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4.1.3", "@types/bcryptjs": "^3.0.0", "@types/http-proxy": "^1.17.17", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..82913ee --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? 'html' : 'list', + globalSetup: './e2e/global-setup.ts', + use: { + baseURL: 'http://localhost:3000', + storageState: './e2e/.auth/user.json', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/frontend/src/app/(auth)/signin/page.tsx b/frontend/src/app/(auth)/signin/page.tsx index f726498..763b9c4 100644 --- a/frontend/src/app/(auth)/signin/page.tsx +++ b/frontend/src/app/(auth)/signin/page.tsx @@ -3,9 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { signIn } from "@/lib/auth-client"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Shield } from "lucide-react"; export default function SignInPage() { const router = useRouter(); @@ -34,54 +32,127 @@ export default function SignInPage() { }; return ( -
- Sign in to your account +
+ Collaborative PRD Management
- -