mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
feat: improve test setup and authentication handling
- Added `dev:test` script to package.json for streamlined testing without file watching. - Introduced `kill-test-servers` script to ensure no existing servers are running on test ports before executing tests. - Enhanced Playwright configuration to use mock agent for tests, ensuring consistent API responses and disabling rate limiting. - Updated various test files to include authentication steps and handle login screens, improving reliability and reducing flakiness in tests. - Added `global-setup` for e2e tests to ensure proper initialization before test execution.
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"dev:test": "tsx src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import {
|
|||||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute window
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute window
|
||||||
const RATE_LIMIT_MAX_ATTEMPTS = 5; // Max 5 attempts per 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)
|
// In-memory rate limit tracking (resets on server restart)
|
||||||
const loginAttempts = new Map<string, { count: number; windowStart: number }>();
|
const loginAttempts = new Map<string, { count: number; windowStart: number }>();
|
||||||
|
|
||||||
@@ -135,6 +138,8 @@ export function createAuthRoutes(): Router {
|
|||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
const clientIp = getClientIp(req);
|
const clientIp = getClientIp(req);
|
||||||
|
|
||||||
|
// Skip rate limiting in test mode to allow parallel E2E tests
|
||||||
|
if (!isTestMode) {
|
||||||
// Check rate limit before processing
|
// Check rate limit before processing
|
||||||
const rateLimit = checkRateLimit(clientIp);
|
const rateLimit = checkRateLimit(clientIp);
|
||||||
if (rateLimit.limited) {
|
if (rateLimit.limited) {
|
||||||
@@ -145,6 +150,7 @@ export function createAuthRoutes(): Router {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { apiKey } = req.body as { apiKey?: string };
|
const { apiKey } = req.body as { apiKey?: string };
|
||||||
|
|
||||||
@@ -156,8 +162,10 @@ export function createAuthRoutes(): Router {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record this attempt (only for actual API key validation attempts)
|
// Record this attempt (only for actual API key validation attempts, skip in test mode)
|
||||||
|
if (!isTestMode) {
|
||||||
recordLoginAttempt(clientIp);
|
recordLoginAttempt(clientIp);
|
||||||
|
}
|
||||||
|
|
||||||
if (!validateApiKey(apiKey)) {
|
if (!validateApiKey(apiKey)) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "npx eslint",
|
"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": "playwright test",
|
||||||
"test:headed": "playwright test --headed",
|
"test:headed": "playwright test --headed",
|
||||||
"dev:electron:wsl": "cross-env vite",
|
"dev:electron:wsl": "cross-env vite",
|
||||||
|
|||||||
@@ -3,21 +3,24 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
const port = process.env.TEST_PORT || 3007;
|
const port = process.env.TEST_PORT || 3007;
|
||||||
const serverPort = process.env.TEST_SERVER_PORT || 3008;
|
const serverPort = process.env.TEST_SERVER_PORT || 3008;
|
||||||
const reuseServer = process.env.TEST_REUSE_SERVER === 'true';
|
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({
|
export default defineConfig({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: 0,
|
||||||
workers: undefined,
|
workers: 1, // Run sequentially to avoid auth conflicts with shared server
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
use: {
|
use: {
|
||||||
baseURL: `http://localhost:${port}`,
|
baseURL: `http://localhost:${port}`,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-failure',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
},
|
},
|
||||||
|
// Global setup - authenticate before each test
|
||||||
|
globalSetup: require.resolve('./tests/global-setup.ts'),
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
@@ -29,10 +32,12 @@ export default defineConfig({
|
|||||||
: {
|
: {
|
||||||
webServer: [
|
webServer: [
|
||||||
// Backend server - runs with mock agent enabled in CI
|
// 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`,
|
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,
|
timeout: 60000,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -41,6 +46,8 @@ export default defineConfig({
|
|||||||
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
|
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
|
||||||
// Set a test API key for web mode authentication
|
// Set a test API key for web mode authentication
|
||||||
AUTOMAKER_API_KEY: process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests',
|
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
|
// No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -53,8 +60,8 @@ export default defineConfig({
|
|||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
VITE_SKIP_SETUP: 'true',
|
VITE_SKIP_SETUP: 'true',
|
||||||
// Skip electron plugin in CI - no display available for Electron
|
// Always skip electron plugin during tests - prevents duplicate server spawning
|
||||||
VITE_SKIP_ELECTRON: process.env.CI === 'true' ? 'true' : undefined,
|
VITE_SKIP_ELECTRON: 'true',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
44
apps/ui/scripts/kill-test-servers.mjs
Normal file
44
apps/ui/scripts/kill-test-servers.mjs
Normal file
@@ -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);
|
||||||
@@ -67,6 +67,7 @@ export function LoginView() {
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
data-testid="login-api-key-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,7 +78,12 @@ export function LoginView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading || !apiKey.trim()}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading || !apiKey.trim()}
|
||||||
|
data-testid="login-submit-button"
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
clickNewSessionButton,
|
clickNewSessionButton,
|
||||||
waitForNewSession,
|
waitForNewSession,
|
||||||
countSessionItems,
|
countSessionItems,
|
||||||
|
authenticateForTests,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
const TEST_TEMP_DIR = createTempDirPath('agent-session-test');
|
const TEST_TEMP_DIR = createTempDirPath('agent-session-test');
|
||||||
@@ -61,6 +62,7 @@ test.describe('Agent Chat Session', () => {
|
|||||||
test('should start a new agent chat session', async ({ page }) => {
|
test('should start a new agent chat session', async ({ page }) => {
|
||||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||||
|
|
||||||
|
await authenticateForTests(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
|
|||||||
@@ -119,9 +119,6 @@ test.describe('Add Context Image', () => {
|
|||||||
test('should import an image file to context', async ({ page }) => {
|
test('should import an image file to context', async ({ page }) => {
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
await setupProjectWithFixture(page, getFixturePath());
|
||||||
|
|
||||||
// Authenticate with the server before navigating
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getByTestId,
|
getByTestId,
|
||||||
waitForNetworkIdle,
|
waitForNetworkIdle,
|
||||||
getContextEditorContent,
|
getContextEditorContent,
|
||||||
|
authenticateForTests,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
test.describe('Context File Management', () => {
|
test.describe('Context File Management', () => {
|
||||||
@@ -31,6 +32,7 @@ test.describe('Context File Management', () => {
|
|||||||
|
|
||||||
test('should create a new markdown context file', async ({ page }) => {
|
test('should create a new markdown context file', async ({ page }) => {
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
await setupProjectWithFixture(page, getFixturePath());
|
||||||
|
await authenticateForTests(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
clickElement,
|
clickElement,
|
||||||
fillInput,
|
fillInput,
|
||||||
waitForNetworkIdle,
|
waitForNetworkIdle,
|
||||||
|
authenticateForTests,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
test.describe('Delete Context File', () => {
|
test.describe('Delete Context File', () => {
|
||||||
@@ -33,6 +34,7 @@ test.describe('Delete Context File', () => {
|
|||||||
const fileName = 'to-delete.md';
|
const fileName = 'to-delete.md';
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
await setupProjectWithFixture(page, getFixturePath());
|
||||||
|
await authenticateForTests(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
clickAddFeature,
|
clickAddFeature,
|
||||||
fillAddFeatureDialog,
|
fillAddFeatureDialog,
|
||||||
confirmAddFeature,
|
confirmAddFeature,
|
||||||
|
authenticateForTests,
|
||||||
|
handleLoginScreenIfPresent,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
const TEST_TEMP_DIR = createTempDirPath('feature-backlog-test');
|
const TEST_TEMP_DIR = createTempDirPath('feature-backlog-test');
|
||||||
@@ -61,7 +63,11 @@ test.describe('Feature Backlog', () => {
|
|||||||
|
|
||||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||||
|
|
||||||
|
// Authenticate before navigating
|
||||||
|
await authenticateForTests(page);
|
||||||
await page.goto('/board');
|
await page.goto('/board');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
await handleLoginScreenIfPresent(page);
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
fillAddFeatureDialog,
|
fillAddFeatureDialog,
|
||||||
confirmAddFeature,
|
confirmAddFeature,
|
||||||
clickElement,
|
clickElement,
|
||||||
|
authenticateForTests,
|
||||||
|
handleLoginScreenIfPresent,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
const TEST_TEMP_DIR = createTempDirPath('edit-feature-test');
|
const TEST_TEMP_DIR = createTempDirPath('edit-feature-test');
|
||||||
@@ -63,7 +65,10 @@ test.describe('Edit Feature', () => {
|
|||||||
|
|
||||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||||
|
|
||||||
|
await authenticateForTests(page);
|
||||||
await page.goto('/board');
|
await page.goto('/board');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
await handleLoginScreenIfPresent(page);
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
setupRealProject,
|
setupRealProject,
|
||||||
waitForNetworkIdle,
|
waitForNetworkIdle,
|
||||||
getKanbanColumn,
|
getKanbanColumn,
|
||||||
|
authenticateForTests,
|
||||||
|
handleLoginScreenIfPresent,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
const TEST_TEMP_DIR = createTempDirPath('manual-review-test');
|
const TEST_TEMP_DIR = createTempDirPath('manual-review-test');
|
||||||
@@ -83,7 +85,10 @@ test.describe('Feature Manual Review Flow', () => {
|
|||||||
test('should manually verify a feature in waiting_approval column', async ({ page }) => {
|
test('should manually verify a feature in waiting_approval column', async ({ page }) => {
|
||||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||||
|
|
||||||
|
await authenticateForTests(page);
|
||||||
await page.goto('/board');
|
await page.goto('/board');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
await handleLoginScreenIfPresent(page);
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
fillAddFeatureDialog,
|
fillAddFeatureDialog,
|
||||||
confirmAddFeature,
|
confirmAddFeature,
|
||||||
isSkipTestsBadgeVisible,
|
isSkipTestsBadgeVisible,
|
||||||
|
authenticateForTests,
|
||||||
|
handleLoginScreenIfPresent,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
const TEST_TEMP_DIR = createTempDirPath('skip-tests-toggle-test');
|
const TEST_TEMP_DIR = createTempDirPath('skip-tests-toggle-test');
|
||||||
@@ -65,7 +67,10 @@ test.describe('Feature Skip Tests Badge', () => {
|
|||||||
|
|
||||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||||
|
|
||||||
|
await authenticateForTests(page);
|
||||||
await page.goto('/board');
|
await page.goto('/board');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
await handleLoginScreenIfPresent(page);
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
createTempDirPath,
|
createTempDirPath,
|
||||||
setupProjectWithPath,
|
setupProjectWithPath,
|
||||||
waitForBoardView,
|
waitForBoardView,
|
||||||
|
authenticateForTests,
|
||||||
|
handleLoginScreenIfPresent,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
const TEST_TEMP_DIR = createTempDirPath('worktree-tests');
|
const TEST_TEMP_DIR = createTempDirPath('worktree-tests');
|
||||||
@@ -47,7 +49,10 @@ test.describe('Worktree Integration', () => {
|
|||||||
|
|
||||||
test('should display worktree selector with main branch', async ({ page }) => {
|
test('should display worktree selector with main branch', async ({ page }) => {
|
||||||
await setupProjectWithPath(page, testRepo.path);
|
await setupProjectWithPath(page, testRepo.path);
|
||||||
|
await authenticateForTests(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
await handleLoginScreenIfPresent(page);
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
await waitForBoardView(page);
|
await waitForBoardView(page);
|
||||||
|
|
||||||
|
|||||||
12
apps/ui/tests/global-setup.ts
Normal file
12
apps/ui/tests/global-setup.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Global setup for all e2e tests
|
||||||
|
* This runs once before all tests start
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function globalSetup() {
|
||||||
|
// Note: Server killing is handled by the pretest script in package.json
|
||||||
|
// GlobalSetup runs AFTER webServer starts, so we can't kill the server here
|
||||||
|
console.log('[GlobalSetup] Setup complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default globalSetup;
|
||||||
@@ -14,12 +14,17 @@ import {
|
|||||||
saveProfile,
|
saveProfile,
|
||||||
waitForSuccessToast,
|
waitForSuccessToast,
|
||||||
countCustomProfiles,
|
countCustomProfiles,
|
||||||
|
authenticateForTests,
|
||||||
|
handleLoginScreenIfPresent,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
test.describe('AI Profiles', () => {
|
test.describe('AI Profiles', () => {
|
||||||
test('should create a new profile', async ({ page }) => {
|
test('should create a new profile', async ({ page }) => {
|
||||||
await setupMockProjectWithProfiles(page, { customProfilesCount: 0 });
|
await setupMockProjectWithProfiles(page, { customProfilesCount: 0 });
|
||||||
|
await authenticateForTests(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
await handleLoginScreenIfPresent(page);
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
await navigateToProfiles(page);
|
await navigateToProfiles(page);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,13 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { createTempDirPath, cleanupTempDir, setupWelcomeView } from '../utils';
|
import {
|
||||||
|
createTempDirPath,
|
||||||
|
cleanupTempDir,
|
||||||
|
setupWelcomeView,
|
||||||
|
authenticateForTests,
|
||||||
|
handleLoginScreenIfPresent,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
const TEST_TEMP_DIR = createTempDirPath('project-creation-test');
|
const TEST_TEMP_DIR = createTempDirPath('project-creation-test');
|
||||||
|
|
||||||
@@ -26,8 +32,10 @@ test.describe('Project Creation', () => {
|
|||||||
const projectName = `test-project-${Date.now()}`;
|
const projectName = `test-project-${Date.now()}`;
|
||||||
|
|
||||||
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
|
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
|
||||||
|
await authenticateForTests(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
|
await handleLoginScreenIfPresent(page);
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,13 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { createTempDirPath, cleanupTempDir, setupWelcomeView } from '../utils';
|
import {
|
||||||
|
createTempDirPath,
|
||||||
|
cleanupTempDir,
|
||||||
|
setupWelcomeView,
|
||||||
|
authenticateForTests,
|
||||||
|
handleLoginScreenIfPresent,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
// Create unique temp dir for this test run
|
// Create unique temp dir for this test run
|
||||||
const TEST_TEMP_DIR = createTempDirPath('open-project-test');
|
const TEST_TEMP_DIR = createTempDirPath('open-project-test');
|
||||||
@@ -74,8 +80,10 @@ test.describe('Open Project', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to the app
|
// Navigate to the app
|
||||||
|
await authenticateForTests(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
|
await handleLoginScreenIfPresent(page);
|
||||||
|
|
||||||
// Wait for welcome view to be visible
|
// Wait for welcome view to be visible
|
||||||
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|||||||
@@ -278,15 +278,74 @@ export async function apiListBranches(
|
|||||||
/**
|
/**
|
||||||
* Authenticate with the server using an API key
|
* Authenticate with the server using an API key
|
||||||
* This sets a session cookie that will be used for subsequent requests
|
* This sets a session cookie that will be used for subsequent requests
|
||||||
|
* Uses browser context to ensure cookies are properly set
|
||||||
*/
|
*/
|
||||||
export async function authenticateWithApiKey(page: Page, apiKey: string): Promise<boolean> {
|
export async function authenticateWithApiKey(page: Page, apiKey: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await page.request.post(`${API_BASE_URL}/api/auth/login`, {
|
// Ensure we're on a page (needed for cookies to work)
|
||||||
data: { apiKey },
|
const currentUrl = page.url();
|
||||||
|
if (!currentUrl || currentUrl === 'about:blank') {
|
||||||
|
await page.goto('http://localhost:3007', { waitUntil: 'domcontentloaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use browser context fetch to ensure cookies are set in the browser
|
||||||
|
const response = await page.evaluate(
|
||||||
|
async ({ url, apiKey }) => {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ apiKey }),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await res.json();
|
||||||
return data.success === true;
|
return { success: data.success, token: data.token };
|
||||||
} catch {
|
},
|
||||||
|
{ url: `${API_BASE_URL}/api/auth/login`, apiKey }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.token) {
|
||||||
|
// Manually set the cookie in the browser context
|
||||||
|
// The server sets a cookie named 'automaker_session' (see SESSION_COOKIE_NAME in auth.ts)
|
||||||
|
await page.context().addCookies([
|
||||||
|
{
|
||||||
|
name: 'automaker_session',
|
||||||
|
value: response.token,
|
||||||
|
domain: 'localhost',
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify the session is working by polling auth status
|
||||||
|
// This replaces arbitrary timeout with actual condition check
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 10;
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
const statusResponse = await page.evaluate(
|
||||||
|
async ({ url }) => {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
{ url: `${API_BASE_URL}/api/auth/status` }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (statusResponse.authenticated === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
attempts++;
|
||||||
|
// Use a very short wait between polling attempts (this is acceptable for polling)
|
||||||
|
await page.waitForFunction(() => true, { timeout: 50 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Authentication error:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Page } from '@playwright/test';
|
import { Page, expect } from '@playwright/test';
|
||||||
import { getByTestId, getButtonByText } from './elements';
|
import { getByTestId, getButtonByText } from './elements';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +48,72 @@ export async function pressShortcut(page: Page, key: string): Promise<void> {
|
|||||||
await page.keyboard.press(key);
|
await page.keyboard.press(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a URL with authentication
|
||||||
|
* This wrapper ensures authentication happens before navigation
|
||||||
|
*/
|
||||||
|
export async function gotoWithAuth(page: Page, url: string): Promise<void> {
|
||||||
|
const { authenticateForTests } = await import('../api/client');
|
||||||
|
await authenticateForTests(page);
|
||||||
|
await page.goto(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle login screen if it appears after navigation
|
||||||
|
* Returns true if login was handled, false if no login screen was found
|
||||||
|
*/
|
||||||
|
export async function handleLoginScreenIfPresent(page: Page): Promise<boolean> {
|
||||||
|
// Check for login screen by waiting for either login input or app-container to be visible
|
||||||
|
// Use data-testid selector (preferred) with fallback to the old selector
|
||||||
|
const loginInput = page
|
||||||
|
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
||||||
|
.first();
|
||||||
|
const appContent = page.locator(
|
||||||
|
'[data-testid="welcome-view"], [data-testid="board-view"], [data-testid="context-view"], [data-testid="agent-view"]'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Race between login screen and actual content
|
||||||
|
const loginVisible = await Promise.race([
|
||||||
|
loginInput
|
||||||
|
.waitFor({ state: 'visible', timeout: 5000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
appContent
|
||||||
|
.first()
|
||||||
|
.waitFor({ state: 'visible', timeout: 5000 })
|
||||||
|
.then(() => false)
|
||||||
|
.catch(() => false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (loginVisible) {
|
||||||
|
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||||
|
await loginInput.fill(apiKey);
|
||||||
|
|
||||||
|
// Wait a moment for the button to become enabled
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// Wait for button to be enabled (it's disabled when input is empty)
|
||||||
|
const loginButton = page
|
||||||
|
.locator('[data-testid="login-submit-button"], button:has-text("Login")')
|
||||||
|
.first();
|
||||||
|
await expect(loginButton).toBeEnabled({ timeout: 5000 });
|
||||||
|
await loginButton.click();
|
||||||
|
|
||||||
|
// Wait for navigation away from login - either to content or URL change
|
||||||
|
await Promise.race([
|
||||||
|
page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 10000 }),
|
||||||
|
appContent.first().waitFor({ state: 'visible', timeout: 10000 }),
|
||||||
|
]).catch(() => {});
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Press a number key (0-9) on the keyboard
|
* Press a number key (0-9) on the keyboard
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
import { Page } from '@playwright/test';
|
import { Page } from '@playwright/test';
|
||||||
import { clickElement } from '../core/interactions';
|
import { clickElement } from '../core/interactions';
|
||||||
import { waitForElement } from '../core/waiting';
|
import { waitForElement } from '../core/waiting';
|
||||||
|
import { authenticateForTests } from '../api/client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to the board/kanban view
|
* Navigate to the board/kanban view
|
||||||
* Note: Navigates directly to /board since index route shows WelcomeView
|
* Note: Navigates directly to /board since index route shows WelcomeView
|
||||||
*/
|
*/
|
||||||
export async function navigateToBoard(page: Page): Promise<void> {
|
export async function navigateToBoard(page: Page): Promise<void> {
|
||||||
|
// Authenticate before navigating
|
||||||
|
await authenticateForTests(page);
|
||||||
|
|
||||||
// Navigate directly to /board route
|
// Navigate directly to /board route
|
||||||
await page.goto('/board');
|
await page.goto('/board');
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
|
// Check if we're on the login screen and handle it
|
||||||
|
const loginInput = page
|
||||||
|
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
||||||
|
.first();
|
||||||
|
const isLoginScreen = await loginInput.isVisible({ timeout: 2000 }).catch(() => false);
|
||||||
|
if (isLoginScreen) {
|
||||||
|
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||||
|
await loginInput.fill(apiKey);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="login-submit-button"], button:has-text("Login")')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.waitForURL('**/board', { timeout: 5000 });
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for the board view to be visible
|
// Wait for the board view to be visible
|
||||||
await waitForElement(page, 'board-view', { timeout: 10000 });
|
await waitForElement(page, 'board-view', { timeout: 10000 });
|
||||||
}
|
}
|
||||||
@@ -20,10 +41,30 @@ export async function navigateToBoard(page: Page): Promise<void> {
|
|||||||
* Note: Navigates directly to /context since index route shows WelcomeView
|
* Note: Navigates directly to /context since index route shows WelcomeView
|
||||||
*/
|
*/
|
||||||
export async function navigateToContext(page: Page): Promise<void> {
|
export async function navigateToContext(page: Page): Promise<void> {
|
||||||
|
// Authenticate before navigating
|
||||||
|
await authenticateForTests(page);
|
||||||
|
|
||||||
// Navigate directly to /context route
|
// Navigate directly to /context route
|
||||||
await page.goto('/context');
|
await page.goto('/context');
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
|
// Check if we're on the login screen and handle it
|
||||||
|
const loginInputCtx = page
|
||||||
|
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
||||||
|
.first();
|
||||||
|
const isLoginScreenCtx = await loginInputCtx.isVisible({ timeout: 2000 }).catch(() => false);
|
||||||
|
if (isLoginScreenCtx) {
|
||||||
|
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||||
|
await loginInputCtx.fill(apiKey);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="login-submit-button"], button:has-text("Login")')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.waitForURL('**/context', { timeout: 5000 });
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for loading to complete (if present)
|
// Wait for loading to complete (if present)
|
||||||
const loadingElement = page.locator('[data-testid="context-view-loading"]');
|
const loadingElement = page.locator('[data-testid="context-view-loading"]');
|
||||||
try {
|
try {
|
||||||
@@ -46,6 +87,9 @@ export async function navigateToContext(page: Page): Promise<void> {
|
|||||||
* Note: Navigates directly to /spec since index route shows WelcomeView
|
* Note: Navigates directly to /spec since index route shows WelcomeView
|
||||||
*/
|
*/
|
||||||
export async function navigateToSpec(page: Page): Promise<void> {
|
export async function navigateToSpec(page: Page): Promise<void> {
|
||||||
|
// Authenticate before navigating
|
||||||
|
await authenticateForTests(page);
|
||||||
|
|
||||||
// Navigate directly to /spec route
|
// Navigate directly to /spec route
|
||||||
await page.goto('/spec');
|
await page.goto('/spec');
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
@@ -76,10 +120,30 @@ export async function navigateToSpec(page: Page): Promise<void> {
|
|||||||
* Note: Navigates directly to /agent since index route shows WelcomeView
|
* Note: Navigates directly to /agent since index route shows WelcomeView
|
||||||
*/
|
*/
|
||||||
export async function navigateToAgent(page: Page): Promise<void> {
|
export async function navigateToAgent(page: Page): Promise<void> {
|
||||||
|
// Authenticate before navigating
|
||||||
|
await authenticateForTests(page);
|
||||||
|
|
||||||
// Navigate directly to /agent route
|
// Navigate directly to /agent route
|
||||||
await page.goto('/agent');
|
await page.goto('/agent');
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
|
// Check if we're on the login screen and handle it
|
||||||
|
const loginInputAgent = page
|
||||||
|
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
||||||
|
.first();
|
||||||
|
const isLoginScreenAgent = await loginInputAgent.isVisible({ timeout: 2000 }).catch(() => false);
|
||||||
|
if (isLoginScreenAgent) {
|
||||||
|
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||||
|
await loginInputAgent.fill(apiKey);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="login-submit-button"], button:has-text("Login")')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.waitForURL('**/agent', { timeout: 5000 });
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for the agent view to be visible
|
// Wait for the agent view to be visible
|
||||||
await waitForElement(page, 'agent-view', { timeout: 10000 });
|
await waitForElement(page, 'agent-view', { timeout: 10000 });
|
||||||
}
|
}
|
||||||
@@ -89,6 +153,9 @@ export async function navigateToAgent(page: Page): Promise<void> {
|
|||||||
* Note: Navigates directly to /settings since index route shows WelcomeView
|
* Note: Navigates directly to /settings since index route shows WelcomeView
|
||||||
*/
|
*/
|
||||||
export async function navigateToSettings(page: Page): Promise<void> {
|
export async function navigateToSettings(page: Page): Promise<void> {
|
||||||
|
// Authenticate before navigating
|
||||||
|
await authenticateForTests(page);
|
||||||
|
|
||||||
// Navigate directly to /settings route
|
// Navigate directly to /settings route
|
||||||
await page.goto('/settings');
|
await page.goto('/settings');
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
@@ -114,8 +181,31 @@ export async function navigateToSetup(page: Page): Promise<void> {
|
|||||||
* Navigate to the welcome view (clear project selection)
|
* Navigate to the welcome view (clear project selection)
|
||||||
*/
|
*/
|
||||||
export async function navigateToWelcome(page: Page): Promise<void> {
|
export async function navigateToWelcome(page: Page): Promise<void> {
|
||||||
|
// Authenticate before navigating
|
||||||
|
await authenticateForTests(page);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
|
// Check if we're on the login screen and handle it
|
||||||
|
const loginInputWelcome = page
|
||||||
|
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
||||||
|
.first();
|
||||||
|
const isLoginScreenWelcome = await loginInputWelcome
|
||||||
|
.isVisible({ timeout: 2000 })
|
||||||
|
.catch(() => false);
|
||||||
|
if (isLoginScreenWelcome) {
|
||||||
|
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||||
|
await loginInputWelcome.fill(apiKey);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="login-submit-button"], button:has-text("Login")')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.waitForURL('**/', { timeout: 5000 });
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
}
|
||||||
|
|
||||||
await waitForElement(page, 'welcome-view', { timeout: 10000 });
|
await waitForElement(page, 'welcome-view', { timeout: 10000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user