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 ( -
- - - PRDforge -

- Sign in to your account +

+ {/* Background gradient blurs */} +
+
+
+
+ +
+ {/* Logo & tagline */} +
+
+ +
+

PRDforge

+

+ Collaborative PRD Management

- - -
+
+ + {/* Sign in card */} +
+ {error && ( -
+
{error}
)} +
-
+
-
- + - - + + {/* OAuth divider + button */} +
+
+
+
+
+
+ + or continue with + +
+
+ + +
+
+ + {/* Footer */} +

+ First time?{" "} + + Contact your admin + {" "} + for an account. +

+
); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 96b9d54..501b538 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -15,7 +15,7 @@ --color-muted-foreground: var(--fg-muted); --color-accent: var(--surface); --color-accent-foreground: var(--fg); - --color-destructive: #ef4444; + --color-destructive: var(--error); --color-destructive-foreground: #ffffff; --color-border: var(--border-color); --color-input: var(--border-color); @@ -26,32 +26,104 @@ --radius-xl: 12px; } -/* Light theme */ +/* ============================================ + Light theme — Stitch-derived + ============================================ */ :root { - --bg: #f5f5f7; - --fg: #1a1a2e; - --fg-muted: #6b6b80; + --bg: #f9f9fb; + --fg: #1a1c1d; + --fg-muted: #464554; --card-bg: #ffffff; - --surface: #e8e8ec; - --border-color: #d1d1d9; - --accent: #6366f1; - --accent-hover: #5558e6; + --surface: #eeeef0; + --surface-dim: #dcdce0; + --surface-high: #e8e8ea; + --surface-highest: #e2e2e4; + --border-color: rgba(144, 143, 160, 0.2); + --accent: #4648d4; + --accent-light: #6063ee; + --accent-hover: #3638b8; + --error: #ba1a1a; --notes-bg: #fef9e7; --notes-border: #e6d88a; + + /* Status colors */ + --status-success: #34a853; + --status-info: #4285f4; + --status-warning: #fbbf24; + --status-error: #ba1a1a; + + /* Code block colors */ + --code-bg: #f6f8fa; + --code-fg: #24292f; + --code-border: #d0d7de; + + /* Reading surface */ + --reading-bg: #ffffff; + --reading-fg: #374151; + --reading-heading: #111827; + + /* Markdown content colors */ + --md-heading: #111827; + --md-heading-sub: #1f2937; + --md-body: #374151; + --md-bold: #111827; + --md-muted: #6b7280; + --md-code: #7c3aed; + + /* Table colors */ + --table-border: #e5e7eb; + --table-header-bg: #f9fafb; + --table-hover: #f3f4f6; } -/* Dark theme */ +/* ============================================ + Dark theme — Stitch-derived (primary) + ============================================ */ .dark { - --bg: #2b2d35; - --fg: #e2e4ea; - --fg-muted: #9496ad; - --card-bg: #33353f; - --surface: #3d3f4a; - --border-color: #44465a; - --accent: #6366f1; + --bg: #131416; + --fg: #d4d2d8; + --fg-muted: #9490a0; + --card-bg: #1c1d21; + --surface: #191a1e; + --surface-dim: #141517; + --surface-high: #252629; + --surface-highest: #2f3034; + --border-color: rgba(144, 143, 160, 0.12); + --accent: #4648d4; + --accent-light: #6063ee; --accent-hover: #5558e6; - --notes-bg: #3a3520; - --notes-border: #665e3a; + --error: #ff5449; + --notes-bg: #2a2720; + --notes-border: #504a35; + + /* Reading surface — elevated, warm, easy on eyes */ + --reading-bg: #22232a; + --reading-fg: #d1d5db; + --reading-heading: #f3f4f6; + + /* Markdown content colors — WCAG AA compliant */ + --md-heading: #f3f4f6; + --md-heading-sub: #e5e7eb; + --md-body: #d1d5db; + --md-bold: #f9fafb; + --md-muted: #9ca3af; + --md-code: #c4b5fd; + + /* Table colors — soft but visible */ + --table-border: rgba(255, 255, 255, 0.08); + --table-header-bg: rgba(255, 255, 255, 0.06); + --table-hover: rgba(255, 255, 255, 0.04); + + /* Status colors */ + --status-success: #4ade80; + --status-info: #60a5fa; + --status-warning: #fbbf24; + --status-error: #ffb4ab; + + /* Code block colors */ + --code-bg: #161b22; + --code-fg: #c9d1d9; + --code-border: #30363d; } * { @@ -63,71 +135,83 @@ body { color: var(--fg); } -/* Markdown styles */ -.markdown-body { - color: var(--fg); - line-height: 1.7; -} -.markdown-body h1 { font-size: 1.5rem; font-weight: 700; margin: 1.5rem 0 0.75rem; } -.markdown-body h2 { font-size: 1.25rem; font-weight: 600; margin: 1.25rem 0 0.5rem; } -.markdown-body h3 { font-size: 1.1rem; font-weight: 600; margin: 1rem 0 0.5rem; } -.markdown-body p { margin: 0.5rem 0; } -.markdown-body ul { list-style: disc; padding-left: 1.5rem; margin: 0.5rem 0; } -.markdown-body ol { list-style: decimal; padding-left: 1.5rem; margin: 0.5rem 0; } -.markdown-body li { margin: 0.25rem 0; } -.markdown-body strong { font-weight: 600; } -.markdown-body em { font-style: italic; } -.markdown-body a { color: var(--accent); text-decoration: underline; } -.markdown-body blockquote { - border-left: 3px solid var(--border-color); - padding-left: 1rem; - margin: 0.75rem 0; - color: var(--fg-muted); - font-style: italic; +/* ============================================ + Utility classes — Stitch design patterns + ============================================ */ + +/* Glassmorphism panel */ +.glass-panel { + backdrop-filter: blur(12px); + background-color: rgba(42, 42, 44, 0.8); } -.markdown-body code { - background: var(--surface); - padding: 0.15em 0.4em; - border-radius: 4px; - font-size: 0.875em; - font-family: 'JetBrains Mono', ui-monospace, monospace; +.dark .glass-panel { + background-color: rgba(42, 42, 44, 0.8); } -.markdown-body pre { - background: var(--surface); - border-radius: 8px; - padding: 1rem; - overflow-x: auto; - margin: 0.75rem 0; +:root .glass-panel { + background-color: rgba(255, 255, 255, 0.8); } -.markdown-body pre code { - background: transparent; - padding: 0; - font-size: 0.85em; - line-height: 1.5; + +/* Etched input style */ +.input-etched { + background-color: var(--surface-dim); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); } -.markdown-body table { - width: 100%; - border-collapse: collapse; - margin: 0.75rem 0; - font-size: 0.875rem; +.input-etched:focus { + outline: none; + box-shadow: 0 0 0 1px var(--accent); } -.markdown-body th { - border: 1px solid var(--border-color); - padding: 0.5rem 0.75rem; - text-align: left; + +/* Non-functional UI element marker */ +.ui-placeholder { + border: 2px dashed var(--accent); + opacity: 0.5; + position: relative; + pointer-events: none; +} +.ui-placeholder::after { + content: 'Coming soon'; + position: absolute; + top: 4px; + right: 8px; + font-size: 0.625rem; font-weight: 600; - background: var(--surface); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--accent); + opacity: 0.8; } -.markdown-body td { - border: 1px solid var(--border-color); - padding: 0.5rem 0.75rem; + +/* Hide scrollbar utility */ +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} +.no-scrollbar::-webkit-scrollbar { + display: none; +} + +/* ============================================ + Markdown styles + ============================================ */ +/* Markdown base — component handles most styling via custom renderers */ +.markdown-body { + color: var(--md-body); + line-height: 1.75; + font-size: 0.9375rem; + font-family: 'Inter', system-ui, sans-serif; } -.markdown-body hr { - border: none; - border-top: 1px solid var(--border-color); - margin: 1.5rem 0; + +/* highlight.js overrides */ +.markdown-body pre code.hljs { + background: transparent; + padding: 0; } -.markdown-body img { - max-width: 100%; - border-radius: 8px; +.markdown-body pre code:not(.hljs) { + color: var(--code-fg); +} + +/* Monospace font for all code */ +.markdown-body code { + font-family: 'JetBrains Mono', ui-monospace, monospace; } diff --git a/frontend/src/app/projects/[slug]/page.tsx b/frontend/src/app/projects/[slug]/page.tsx index b0ca119..825c8d2 100644 --- a/frontend/src/app/projects/[slug]/page.tsx +++ b/frontend/src/app/projects/[slug]/page.tsx @@ -3,7 +3,6 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { useParams, useRouter } from "next/navigation"; import { - Settings, FileText, GitBranch, Clock, @@ -24,7 +23,7 @@ import { LoadingOverlay } from "@/components/loading-overlay"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; import { MarkdownRenderer } from "@/components/markdown-renderer"; import { fetchProject, fetchSection, fetchTokenStats } from "@/lib/api"; import type { @@ -463,7 +462,7 @@ export default function ProjectDetailPage() { if (loading) { return (
- +
); @@ -472,7 +471,7 @@ export default function ProjectDetailPage() { if (!project) { return (
- + -
+
-
- - - - Sections - - - - Comments - - - - Dependencies - - - - Changelog - - - - Stats - - - -
+ {/* Action bar — compact export controls */} +
+
-
@@ -624,6 +598,10 @@ export default function ProjectDetailPage() { sections={project.sections} activeSlug={activeSection?.section.slug} onSelect={handleSectionSelect} + projectName={project.project.name} + projectVersion={project.project.version} + projectSlug={slug} + onNavigateSettings={() => router.push(`/projects/${slug}/settings`)} /> {sectionLoading ? ( @@ -655,7 +633,7 @@ export default function ProjectDetailPage() { {/* Comments tab — all comments across project */} {allComments.length === 0 ? ( (
- -

Project Settings

- - {/* Features */} - - - Features - - -
-
-

Comment Auto-Replies

-

- Claude auto-replies when resolving comments -

-
- -
+
+ router.push(`/projects/${slug}`)} /> -
-
-

Chat Panel

-

- Enable AI chat for this project -

-
- +
+
+ {/* Breadcrumbs */} + + +

Project Settings

+ +
+ {/* ── General ── */} +
+
+ +

General

- - - - {/* Chat Configuration */} - - - Chat Configuration - - -
- - +
+
+
+

Comment auto-replies

+

Enable AI-generated suggestions for PRD comment threads.

+
+ +
-
- - +
+ + {/* ── Experimental Features ── */} +
+
+ +

Experimental Features

- - - - {/* Provider Authentication */} - - - - Provider Authentication - - - - {/* Claude CLI */} -
-

Claude CLI

-
- {providerStatus?.claude_cli.installed ? ( - - ) : ( - - )} - - {providerStatus?.claude_cli.installed - ? "Installed" - : "Not installed"} - +
+ {/* Chat toggle */} +
+
+

Chat Integration

+

Directly chat with your document context using large language models.

+
+
-
- {providerStatus?.claude_cli.logged_in ? ( - - ) : ( - - )} - - {providerStatus?.claude_cli.logged_in - ? "Logged in" - : "Not logged in"} - + + {/* Provider + Model dropdowns */} +
+
+ + +
+
+ + +
- {providerStatus?.claude_cli.installed && ( -
- - {showCodeInput && ( -
- setLoginCode(e.target.value)} - autoComplete="off" - className="text-sm h-9" - /> - -
+ + {/* Provider Authentication — inline */} +
+ {/* Claude CLI status */} +
+

Claude CLI Status

+
+ {providerStatus?.claude_cli.installed ? ( + + ) : ( + + )} + {providerStatus?.claude_cli.installed ? "Installed" : "Not installed"} + {providerStatus?.claude_cli.installed && ( + <> + · + {providerStatus?.claude_cli.logged_in ? ( + + ) : ( + + )} + {providerStatus?.claude_cli.logged_in ? "Logged in" : "Not logged in"} + )}
- )} + {providerStatus?.claude_cli.installed && ( +
+ + {showCodeInput && ( +
+ setLoginCode(e.target.value)} autoComplete="off" className="text-sm h-9 bg-[var(--surface-dim)] border-[var(--border-color)]" /> + +
+ )} +
+ )} +
+ + {/* Anthropic API key */} +
+

Anthropic API Key

+
+ {providerStatus?.anthropic_api.configured ? ( + <> + + Configured + {providerStatus.anthropic_api.key_hint} + + ) : ( + <> + + Not configured + + )} +
+
+ setApiKey(e.target.value)} type="password" className="text-sm h-9 bg-[var(--surface-dim)] border-[var(--border-color)]" /> + +
+
+
+
- {/* Divider */} -
- - {/* Anthropic API */} -
-

Anthropic API Key

-
- {providerStatus?.anthropic_api.configured ? ( - <> - - - Configured ( - {providerStatus.anthropic_api.key_hint}) - - - ) : ( - <> - - - Not configured - - - )} + {/* ── Members ── */} +
+
+
+ +

Members

-
- setApiKey(e.target.value)} - type="password" - className="text-sm h-9" - /> -
+
+ +
+
+ + {/* ── API Keys ── */} +
+
+
+ +

API Keys

+
+ +
+
+ + + + + + + + + + {providerStatus?.anthropic_api.configured ? ( + + + + + + ) : ( + + + + )} + +
NameKey PrefixStatus
Development Key + + {providerStatus.anthropic_api.key_hint} + + + + + Active + +
No API keys configured
+
+
+ + {/* Save button */} +
+ +
+ + {/* ── Danger Zone ── */} +
+
+ +

Danger Zone

+
+
+
+
+

Delete Project

+

+ Permanently delete this project and all associated PRDs, technical specs, and architecture diagrams. This action cannot be undone. +

+
+
- - - - {/* Members */} - - - Members - - - - - - -
- +
+
+ + {/* Footer */} +
+ Last Updated: {new Date().toISOString().slice(0, 10)} + System Health: Nominal
diff --git a/frontend/src/app/projects/page.tsx b/frontend/src/app/projects/page.tsx index 4489de6..50cae77 100644 --- a/frontend/src/app/projects/page.tsx +++ b/frontend/src/app/projects/page.tsx @@ -2,16 +2,17 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { FolderOpen, Plus, Check } from "lucide-react"; +import { + FolderOpen, + Plus, + Check, + FileText, + Layers, + Smartphone, + Code2, +} from "lucide-react"; import { TopBar } from "@/components/top-bar"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Dialog, DialogContent, @@ -21,14 +22,19 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; import { EmptyState } from "@/components/empty-state"; import { LoadingOverlay } from "@/components/loading-overlay"; import { fetchProjects, createProject, fetchTemplates } from "@/lib/api"; import type { TemplateInfo } from "@/lib/api"; import type { Project } from "@/lib/types"; +const TEMPLATE_ICONS: Record = { + blank: FileText, + "saas-mvp": Layers, + "mobile-app": Smartphone, + "api-design": Code2, +}; + export default function ProjectsPage() { const router = useRouter(); const [projects, setProjects] = useState([]); @@ -70,96 +76,103 @@ export default function ProjectsPage() { } }; + const formatDate = (dateStr: string) => { + const d = new Date(dateStr); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + }; + return ( -
- +
+ -
-
-
+
+
+ {/* Header */} +
-

Projects

+

Projects

- Manage your product requirement documents + Manage your product requirement documents and engineering blueprints.

- - + - Create Project + Forge New Project - Choose a template and create a new PRD project. + Select a starting point for your technical documentation. -
- {/* Template selector */} +
+ {/* Blueprints & Templates */}
- -
- {templates.map((t) => ( - - ))} + +
+ {templates.map((t) => { + const Icon = TEMPLATE_ICONS[t.id] || FileText; + const isActive = templateId === t.id; + return ( + + ); + })}
+ + {/* Project Name */}
- setName(e.target.value)} - className="mt-1.5" + className="input-etched w-full rounded-lg px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/50" />
+ + {/* Description */}
-