diff --git a/apps/server/package.json b/apps/server/package.json index 30010f28..1eb415a8 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -9,6 +9,7 @@ "main": "dist/index.js", "scripts": { "dev": "tsx watch src/index.ts", + "dev:test": "tsx src/index.ts", "build": "tsc", "start": "node dist/index.js", "lint": "eslint src/", diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index 26b41638..575000a8 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -28,6 +28,9 @@ import { const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute window const RATE_LIMIT_MAX_ATTEMPTS = 5; // Max 5 attempts per window +// Check if we're in test mode - disable rate limiting for E2E tests +const isTestMode = process.env.AUTOMAKER_MOCK_AGENT === 'true'; + // In-memory rate limit tracking (resets on server restart) const loginAttempts = new Map(); @@ -135,15 +138,18 @@ export function createAuthRoutes(): Router { router.post('/login', async (req, res) => { const clientIp = getClientIp(req); - // Check rate limit before processing - const rateLimit = checkRateLimit(clientIp); - if (rateLimit.limited) { - res.status(429).json({ - success: false, - error: 'Too many login attempts. Please try again later.', - retryAfter: rateLimit.retryAfter, - }); - return; + // Skip rate limiting in test mode to allow parallel E2E tests + if (!isTestMode) { + // Check rate limit before processing + const rateLimit = checkRateLimit(clientIp); + if (rateLimit.limited) { + res.status(429).json({ + success: false, + error: 'Too many login attempts. Please try again later.', + retryAfter: rateLimit.retryAfter, + }); + return; + } } const { apiKey } = req.body as { apiKey?: string }; @@ -156,8 +162,10 @@ export function createAuthRoutes(): Router { return; } - // Record this attempt (only for actual API key validation attempts) - recordLoginAttempt(clientIp); + // Record this attempt (only for actual API key validation attempts, skip in test mode) + if (!isTestMode) { + recordLoginAttempt(clientIp); + } if (!validateApiKey(apiKey)) { res.status(401).json({ diff --git a/apps/ui/package.json b/apps/ui/package.json index d45a2797..b069e28c 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -28,7 +28,7 @@ "postinstall": "electron-builder install-app-deps", "preview": "vite preview", "lint": "npx eslint", - "pretest": "node scripts/setup-e2e-fixtures.mjs", + "pretest": "node scripts/kill-test-servers.mjs && node scripts/setup-e2e-fixtures.mjs", "test": "playwright test", "test:headed": "playwright test --headed", "dev:electron:wsl": "cross-env vite", diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts index 66b0adfe..80ba9af3 100644 --- a/apps/ui/playwright.config.ts +++ b/apps/ui/playwright.config.ts @@ -3,21 +3,24 @@ import { defineConfig, devices } from '@playwright/test'; const port = process.env.TEST_PORT || 3007; const serverPort = process.env.TEST_SERVER_PORT || 3008; const reuseServer = process.env.TEST_REUSE_SERVER === 'true'; -const mockAgent = process.env.CI === 'true' || process.env.AUTOMAKER_MOCK_AGENT === 'true'; +// Always use mock agent for tests (disables rate limiting, uses mock Claude responses) +const mockAgent = true; export default defineConfig({ testDir: './tests', fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: undefined, + retries: 0, + workers: 1, // Run sequentially to avoid auth conflicts with shared server reporter: 'html', timeout: 30000, use: { baseURL: `http://localhost:${port}`, - trace: 'on-first-retry', + trace: 'on-failure', screenshot: 'only-on-failure', }, + // Global setup - authenticate before each test + globalSetup: require.resolve('./tests/global-setup.ts'), projects: [ { name: 'chromium', @@ -29,10 +32,12 @@ export default defineConfig({ : { webServer: [ // Backend server - runs with mock agent enabled in CI + // Uses dev:test (no file watching) to avoid port conflicts from server restarts { - command: `cd ../server && npm run dev`, + command: `cd ../server && npm run dev:test`, url: `http://localhost:${serverPort}/api/health`, - reuseExistingServer: true, + // Don't reuse existing server to ensure we use the test API key + reuseExistingServer: false, timeout: 60000, env: { ...process.env, @@ -41,6 +46,8 @@ export default defineConfig({ AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false', // Set a test API key for web mode authentication AUTOMAKER_API_KEY: process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests', + // Hide the API key banner to reduce log noise + AUTOMAKER_HIDE_API_KEY: 'true', // No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing }, }, @@ -53,8 +60,8 @@ export default defineConfig({ env: { ...process.env, VITE_SKIP_SETUP: 'true', - // Skip electron plugin in CI - no display available for Electron - VITE_SKIP_ELECTRON: process.env.CI === 'true' ? 'true' : undefined, + // Always skip electron plugin during tests - prevents duplicate server spawning + VITE_SKIP_ELECTRON: 'true', }, }, ], diff --git a/apps/ui/scripts/kill-test-servers.mjs b/apps/ui/scripts/kill-test-servers.mjs new file mode 100644 index 00000000..02121c74 --- /dev/null +++ b/apps/ui/scripts/kill-test-servers.mjs @@ -0,0 +1,44 @@ +/** + * Kill any existing servers on test ports before running tests + * This ensures the test server starts fresh with the correct API key + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +const SERVER_PORT = process.env.TEST_SERVER_PORT || 3008; +const UI_PORT = process.env.TEST_PORT || 3007; + +async function killProcessOnPort(port) { + try { + const { stdout } = await execAsync(`lsof -ti:${port}`); + const pids = stdout.trim().split('\n').filter(Boolean); + + if (pids.length > 0) { + console.log(`[KillTestServers] Found process(es) on port ${port}: ${pids.join(', ')}`); + for (const pid of pids) { + try { + await execAsync(`kill -9 ${pid}`); + console.log(`[KillTestServers] Killed process ${pid}`); + } catch (error) { + // Process might have already exited + } + } + // Wait a moment for the port to be released + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } catch (error) { + // No process on port, which is fine + } +} + +async function main() { + console.log('[KillTestServers] Checking for existing test servers...'); + await killProcessOnPort(Number(SERVER_PORT)); + await killProcessOnPort(Number(UI_PORT)); + console.log('[KillTestServers] Done'); +} + +main().catch(console.error); diff --git a/apps/ui/src/components/views/login-view.tsx b/apps/ui/src/components/views/login-view.tsx index cc8563c3..a30ca4ec 100644 --- a/apps/ui/src/components/views/login-view.tsx +++ b/apps/ui/src/components/views/login-view.tsx @@ -67,6 +67,7 @@ export function LoginView() { disabled={isLoading} autoFocus className="font-mono" + data-testid="login-api-key-input" /> @@ -77,7 +78,12 @@ export function LoginView() { )} -