working
This commit is contained in:
parent
c0999370c6
commit
4e1a339432
17 changed files with 393 additions and 91 deletions
35
Makefile
35
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: install-backend install-frontend install setup-hooks backend frontend worker db db-stop db-ready db-seed dev test test-backend test-frontend test-e2e typecheck generate-types generate-types-standalone check-types-fresh check-constants lint-backend format-backend fix-backend security-backend lint-frontend fix-frontend format-frontend pre-commit lint
|
.PHONY: install-backend install-frontend install setup-hooks backend frontend worker db db-stop db-ready db-ready-backend-tests db-ready-e2e-tests db-seed dev test test-backend test-frontend test-e2e typecheck generate-types generate-types-standalone check-types-fresh check-constants lint-backend format-backend fix-backend security-backend lint-frontend fix-frontend format-frontend pre-commit lint
|
||||||
|
|
||||||
-include .env
|
-include .env
|
||||||
export
|
export
|
||||||
|
|
@ -40,17 +40,27 @@ db-ready:
|
||||||
done
|
done
|
||||||
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret'" | grep -q 1 || \
|
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret'" | grep -q 1 || \
|
||||||
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret"
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret"
|
||||||
|
@echo "PostgreSQL is ready"
|
||||||
|
|
||||||
|
# Create worker-specific databases for parallel backend test execution (pytest-xdist)
|
||||||
|
db-ready-backend-tests: db-ready
|
||||||
|
@echo "Creating backend test databases..."
|
||||||
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test'" | grep -q 1 || \
|
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test'" | grep -q 1 || \
|
||||||
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test"
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test"
|
||||||
@# Create worker-specific databases for parallel backend test execution (pytest-xdist)
|
|
||||||
@for i in 0 1 2 3 4 5 6 7; do \
|
@for i in 0 1 2 3 4 5 6 7; do \
|
||||||
docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test_gw$$i'" | grep -q 1 || \
|
docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test_gw$$i'" | grep -q 1 || \
|
||||||
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test_gw$$i"; \
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test_gw$$i"; \
|
||||||
done
|
done
|
||||||
@# Create separate database for e2e tests
|
@echo "Backend test databases ready"
|
||||||
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_e2e'" | grep -q 1 || \
|
|
||||||
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_e2e"
|
# Create worker-specific databases for parallel e2e test execution
|
||||||
@echo "PostgreSQL is ready"
|
db-ready-e2e-tests: db-ready
|
||||||
|
@echo "Creating e2e test databases..."
|
||||||
|
@for i in 0 1 2 3 4 5 6 7; do \
|
||||||
|
docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_e2e_worker$$i'" | grep -q 1 || \
|
||||||
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_e2e_worker$$i"; \
|
||||||
|
done
|
||||||
|
@echo "E2E test databases ready"
|
||||||
|
|
||||||
db-seed: db-ready
|
db-seed: db-ready
|
||||||
cd backend && uv run python seed.py
|
cd backend && uv run python seed.py
|
||||||
|
|
@ -68,7 +78,7 @@ dev:
|
||||||
# E2E: TEST="auth" (file pattern matching e2e/*.spec.ts)
|
# E2E: TEST="auth" (file pattern matching e2e/*.spec.ts)
|
||||||
TEST ?=
|
TEST ?=
|
||||||
|
|
||||||
test-backend: db-ready test-backend-clean-dbs
|
test-backend: db-ready-backend-tests test-backend-clean-dbs
|
||||||
cd backend && uv run pytest -v -n 8 $(TEST)
|
cd backend && uv run pytest -v -n 8 $(TEST)
|
||||||
|
|
||||||
# Clean only backend test databases (not e2e or main db)
|
# Clean only backend test databases (not e2e or main db)
|
||||||
|
|
@ -81,13 +91,16 @@ test-backend-clean-dbs:
|
||||||
test-frontend:
|
test-frontend:
|
||||||
cd frontend && npm run test $(if $(TEST),-- $(TEST),)
|
cd frontend && npm run test $(if $(TEST),-- $(TEST),)
|
||||||
|
|
||||||
test-e2e: db-ready test-e2e-clean-db
|
test-e2e: db-ready-e2e-tests test-e2e-clean-db
|
||||||
./scripts/e2e.sh $(TEST)
|
./scripts/e2e.sh $(TEST)
|
||||||
|
|
||||||
# Clean only e2e database (not backend test dbs or main db)
|
# Clean only e2e databases (not backend test dbs or main db)
|
||||||
|
# Create worker-specific databases for parallel e2e test execution
|
||||||
test-e2e-clean-db:
|
test-e2e-clean-db:
|
||||||
@docker compose exec -T db psql -U postgres -c "DROP DATABASE IF EXISTS arbret_e2e" 2>/dev/null || true
|
@for i in 0 1 2 3 4 5 6 7; do \
|
||||||
@docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_e2e"
|
docker compose exec -T db psql -U postgres -c "DROP DATABASE IF EXISTS arbret_e2e_worker$$i" 2>/dev/null || true; \
|
||||||
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_e2e_worker$$i" 2>/dev/null || true; \
|
||||||
|
done
|
||||||
|
|
||||||
test: check-constants check-types-fresh test-backend test-frontend test-e2e
|
test: check-constants check-types-fresh test-backend test-frontend test-e2e
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from routes import exchange as exchange_routes
|
||||||
from routes import invites as invites_routes
|
from routes import invites as invites_routes
|
||||||
from routes import meta as meta_routes
|
from routes import meta as meta_routes
|
||||||
from routes import profile as profile_routes
|
from routes import profile as profile_routes
|
||||||
|
from routes import test as test_routes
|
||||||
from shared_constants import PRICE_REFRESH_SECONDS
|
from shared_constants import PRICE_REFRESH_SECONDS
|
||||||
from validate_constants import validate_shared_constants
|
from validate_constants import validate_shared_constants
|
||||||
|
|
||||||
|
|
@ -91,6 +92,7 @@ app.include_router(audit_routes.router)
|
||||||
app.include_router(profile_routes.router)
|
app.include_router(profile_routes.router)
|
||||||
app.include_router(availability_routes.router)
|
app.include_router(availability_routes.router)
|
||||||
app.include_router(meta_routes.router)
|
app.include_router(meta_routes.router)
|
||||||
|
app.include_router(test_routes.router)
|
||||||
|
|
||||||
# Include routers - modules with multiple routers
|
# Include routers - modules with multiple routers
|
||||||
for r in invites_routes.routers:
|
for r in invites_routes.routers:
|
||||||
|
|
|
||||||
49
backend/routes/test.py
Normal file
49
backend/routes/test.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Test-only endpoints for e2e test isolation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/test", tags=["test"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset")
|
||||||
|
async def reset_database(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Truncate all tables and re-seed base data.
|
||||||
|
Only available when E2E_MODE environment variable is set.
|
||||||
|
"""
|
||||||
|
# Safety check - only allow in e2e mode
|
||||||
|
if not os.getenv("E2E_MODE"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="This endpoint is only available in E2E_MODE"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all table names from the database
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND tablename != 'alembic_version'
|
||||||
|
ORDER BY tablename
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
tables = [row[0] for row in result]
|
||||||
|
|
||||||
|
# Truncate all tables (CASCADE handles foreign keys)
|
||||||
|
if tables:
|
||||||
|
table_list = ", ".join(f'"{table}"' for table in tables)
|
||||||
|
await db.execute(text(f"TRUNCATE TABLE {table_list} CASCADE"))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Re-seed essential data
|
||||||
|
from seed_e2e import seed_base_data
|
||||||
|
|
||||||
|
await seed_base_data(db)
|
||||||
|
|
||||||
|
return {"status": "reset", "tables_truncated": len(tables)}
|
||||||
90
backend/seed_e2e.py
Normal file
90
backend/seed_e2e.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Fast re-seeding function for e2e tests - only seeds essential data."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from auth import get_password_hash
|
||||||
|
from models import (
|
||||||
|
ROLE_ADMIN,
|
||||||
|
ROLE_DEFINITIONS,
|
||||||
|
ROLE_REGULAR,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_base_data(db: AsyncSession) -> None:
|
||||||
|
"""
|
||||||
|
Seed only the minimal data needed for e2e tests:
|
||||||
|
- Roles (admin, regular) with permissions
|
||||||
|
- Test users (from env vars)
|
||||||
|
"""
|
||||||
|
# Get environment variables with defaults
|
||||||
|
dev_user_email = os.getenv("DEV_USER_EMAIL", "user@example.com")
|
||||||
|
dev_user_password = os.getenv("DEV_USER_PASSWORD", "user123")
|
||||||
|
dev_admin_email = os.getenv("DEV_ADMIN_EMAIL", "admin@example.com")
|
||||||
|
dev_admin_password = os.getenv("DEV_ADMIN_PASSWORD", "admin123")
|
||||||
|
|
||||||
|
# Create roles with permissions
|
||||||
|
for role_name, role_config in ROLE_DEFINITIONS.items():
|
||||||
|
result = await db.execute(select(Role).where(Role.name == role_name))
|
||||||
|
role = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not role:
|
||||||
|
role = Role(name=role_name, description=role_config["description"])
|
||||||
|
db.add(role)
|
||||||
|
await db.flush() # Get the role ID
|
||||||
|
|
||||||
|
# Set permissions for the role
|
||||||
|
await role.set_permissions(db, role_config["permissions"])
|
||||||
|
|
||||||
|
await db.flush() # Ensure roles are committed before creating users
|
||||||
|
|
||||||
|
# Get roles for users
|
||||||
|
admin_role_result = await db.execute(select(Role).where(Role.name == ROLE_ADMIN))
|
||||||
|
admin_role = admin_role_result.scalar_one()
|
||||||
|
|
||||||
|
regular_role_result = await db.execute(
|
||||||
|
select(Role).where(Role.name == ROLE_REGULAR)
|
||||||
|
)
|
||||||
|
regular_role = regular_role_result.scalar_one()
|
||||||
|
|
||||||
|
# Create regular dev user
|
||||||
|
regular_user_result = await db.execute(
|
||||||
|
select(User).where(User.email == dev_user_email)
|
||||||
|
)
|
||||||
|
regular_user = regular_user_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not regular_user:
|
||||||
|
regular_user = User(
|
||||||
|
email=dev_user_email,
|
||||||
|
hashed_password=get_password_hash(dev_user_password),
|
||||||
|
roles=[regular_role],
|
||||||
|
)
|
||||||
|
db.add(regular_user)
|
||||||
|
else:
|
||||||
|
# Update existing user
|
||||||
|
regular_user.hashed_password = get_password_hash(dev_user_password)
|
||||||
|
regular_user.roles = [regular_role]
|
||||||
|
|
||||||
|
# Create admin dev user
|
||||||
|
admin_user_result = await db.execute(
|
||||||
|
select(User).where(User.email == dev_admin_email)
|
||||||
|
)
|
||||||
|
admin_user = admin_user_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not admin_user:
|
||||||
|
admin_user = User(
|
||||||
|
email=dev_admin_email,
|
||||||
|
hashed_password=get_password_hash(dev_admin_password),
|
||||||
|
roles=[admin_role],
|
||||||
|
)
|
||||||
|
db.add(admin_user)
|
||||||
|
else:
|
||||||
|
# Update existing user
|
||||||
|
admin_user.hashed_password = get_password_hash(dev_admin_password)
|
||||||
|
admin_user.roles = [admin_role]
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
@ -212,6 +212,27 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/test/reset": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Reset Database
|
||||||
|
* @description Truncate all tables and re-seed base data.
|
||||||
|
* Only available when E2E_MODE environment variable is set.
|
||||||
|
*/
|
||||||
|
post: operations["reset_database_api_test_reset_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/invites/{identifier}/check": {
|
"/api/invites/{identifier}/check": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -1434,6 +1455,26 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
reset_database_api_test_reset_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
check_invite_api_invites__identifier__check_get: {
|
check_invite_api_invites__identifier__check_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
|
|
@ -124,15 +124,25 @@ test.describe("Admin Invites Page", () => {
|
||||||
|
|
||||||
// Test status filter - filter by "revoked" status
|
// Test status filter - filter by "revoked" status
|
||||||
const statusFilter = page.locator("select").nth(1); // Second select is the status filter
|
const statusFilter = page.locator("select").nth(1); // Second select is the status filter
|
||||||
await statusFilter.selectOption("revoked");
|
|
||||||
|
|
||||||
// Wait for the filter to apply and verify revoked invite is visible
|
// Wait for filter response, but don't fail if it doesn't come (might be cached)
|
||||||
await page.waitForResponse((resp) => resp.url().includes("status=revoked"));
|
const filterPromise = page
|
||||||
|
.waitForResponse((resp) => resp.url().includes("status=revoked"), { timeout: 5000 })
|
||||||
|
.catch(() => null); // Ignore timeout - filter might be cached
|
||||||
|
|
||||||
|
await statusFilter.selectOption("revoked");
|
||||||
|
await filterPromise; // Wait for response if it comes
|
||||||
|
|
||||||
|
// Verify revoked invite is visible
|
||||||
await expect(revokedRow).toBeVisible({ timeout: 5000 });
|
await expect(revokedRow).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Filter by "ready" status - should not show our revoked invite
|
// Filter by "ready" status - should not show our revoked invite
|
||||||
|
const readyFilterPromise = page
|
||||||
|
.waitForResponse((resp) => resp.url().includes("status=ready"), { timeout: 5000 })
|
||||||
|
.catch(() => null); // Ignore timeout - filter might be cached
|
||||||
|
|
||||||
await statusFilter.selectOption("ready");
|
await statusFilter.selectOption("ready");
|
||||||
await page.waitForResponse((resp) => resp.url().includes("status=ready"));
|
await readyFilterPromise; // Wait for response if it comes
|
||||||
await expect(revokedRow).not.toBeVisible({ timeout: 5000 });
|
await expect(revokedRow).not.toBeVisible({ timeout: 5000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -151,12 +161,13 @@ test.describe("Admin Invites Access Control", () => {
|
||||||
await page.fill('input[type="email"]', REGULAR_USER_EMAIL);
|
await page.fill('input[type="email"]', REGULAR_USER_EMAIL);
|
||||||
await page.fill('input[type="password"]', "user123");
|
await page.fill('input[type="password"]', "user123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL("/");
|
// Regular users are redirected to /exchange after login
|
||||||
|
await expect(page).toHaveURL("/exchange");
|
||||||
|
|
||||||
// Try to access admin invites page
|
// Try to access admin invites page
|
||||||
await page.goto("/admin/invites");
|
await page.goto("/admin/invites");
|
||||||
|
|
||||||
// Should be redirected away (to home page based on fallbackRedirect)
|
// Should be redirected away (to exchange page based on fallbackRedirect)
|
||||||
await expect(page).not.toHaveURL("/admin/invites");
|
await expect(page).not.toHaveURL("/admin/invites");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { test, expect, Page, APIRequestContext } from "@playwright/test";
|
import { test, expect, Page, APIRequestContext } from "@playwright/test";
|
||||||
|
import { getBackendUrl } from "./helpers/backend-url";
|
||||||
|
|
||||||
// Helper to generate unique email for each test
|
// Helper to generate unique email for each test
|
||||||
function uniqueEmail(): string {
|
function uniqueEmail(): string {
|
||||||
|
|
@ -14,24 +15,22 @@ async function clearAuth(page: Page) {
|
||||||
const ADMIN_EMAIL = "admin@example.com";
|
const ADMIN_EMAIL = "admin@example.com";
|
||||||
const ADMIN_PASSWORD = "admin123";
|
const ADMIN_PASSWORD = "admin123";
|
||||||
|
|
||||||
// Helper to create an invite via the API
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
|
||||||
|
|
||||||
async function createInvite(request: APIRequestContext): Promise<string> {
|
async function createInvite(request: APIRequestContext): Promise<string> {
|
||||||
|
const apiBase = getBackendUrl();
|
||||||
// Login as admin
|
// Login as admin
|
||||||
const loginResp = await request.post(`${API_BASE}/api/auth/login`, {
|
const loginResp = await request.post(`${apiBase}/api/auth/login`, {
|
||||||
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||||
});
|
});
|
||||||
const cookies = loginResp.headers()["set-cookie"];
|
const cookies = loginResp.headers()["set-cookie"];
|
||||||
|
|
||||||
// Get admin user ID (we'll use admin as godfather for simplicity)
|
// Get admin user ID (we'll use admin as godfather for simplicity)
|
||||||
const meResp = await request.get(`${API_BASE}/api/auth/me`, {
|
const meResp = await request.get(`${apiBase}/api/auth/me`, {
|
||||||
headers: { Cookie: cookies },
|
headers: { Cookie: cookies },
|
||||||
});
|
});
|
||||||
const admin = await meResp.json();
|
const admin = await meResp.json();
|
||||||
|
|
||||||
// Create invite
|
// Create invite
|
||||||
const inviteResp = await request.post(`${API_BASE}/api/admin/invites`, {
|
const inviteResp = await request.post(`${apiBase}/api/admin/invites`, {
|
||||||
data: { godfather_id: admin.id },
|
data: { godfather_id: admin.id },
|
||||||
headers: { Cookie: cookies },
|
headers: { Cookie: cookies },
|
||||||
});
|
});
|
||||||
|
|
@ -112,8 +111,8 @@ test.describe("Signup with Invite", () => {
|
||||||
await expect(page).toHaveURL("/exchange");
|
await expect(page).toHaveURL("/exchange");
|
||||||
|
|
||||||
// Test logged-in user visiting signup page - should redirect to exchange
|
// Test logged-in user visiting signup page - should redirect to exchange
|
||||||
await page.goto("/signup");
|
await page.goto("/signup", { waitUntil: "networkidle" });
|
||||||
await expect(page).toHaveURL("/exchange");
|
await expect(page).toHaveURL("/exchange", { timeout: 10000 });
|
||||||
|
|
||||||
// Test signup via direct URL (new session)
|
// Test signup via direct URL (new session)
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
|
|
@ -174,7 +173,7 @@ test.describe("Login", () => {
|
||||||
const inviteCode = await createInvite(request);
|
const inviteCode = await createInvite(request);
|
||||||
|
|
||||||
// Register the test user via backend API
|
// Register the test user via backend API
|
||||||
await request.post(`${API_BASE}/api/auth/register`, {
|
await request.post(`${getBackendUrl()}/api/auth/register`, {
|
||||||
data: {
|
data: {
|
||||||
email: testEmail,
|
email: testEmail,
|
||||||
password: testPassword,
|
password: testPassword,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import { getTomorrowDateStr } from "./helpers/date";
|
import { getTomorrowDateStr } from "./helpers/date";
|
||||||
import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth";
|
import { REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth";
|
||||||
|
import { getBackendUrl } from "./helpers/backend-url";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Availability Page E2E Tests
|
* Availability Page E2E Tests
|
||||||
|
|
@ -177,7 +178,7 @@ test.describe("Availability API", () => {
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
const dateStr = getTomorrowDateStr();
|
const dateStr = getTomorrowDateStr();
|
||||||
const response = await request.put(`${API_URL}/api/admin/availability`, {
|
const response = await request.put(`${getBackendUrl()}/api/admin/availability`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -203,7 +204,7 @@ test.describe("Availability API", () => {
|
||||||
if (regularAuthCookie) {
|
if (regularAuthCookie) {
|
||||||
const dateStr = getTomorrowDateStr();
|
const dateStr = getTomorrowDateStr();
|
||||||
const response = await request.get(
|
const response = await request.get(
|
||||||
`${API_URL}/api/admin/availability?from=${dateStr}&to=${dateStr}`,
|
`${getBackendUrl()}/api/admin/availability?from=${dateStr}&to=${dateStr}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${regularAuthCookie.value}`,
|
Cookie: `auth_token=${regularAuthCookie.value}`,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { test, expect, Page } from "@playwright/test";
|
import { test, expect, Page } from "@playwright/test";
|
||||||
import { getTomorrowDateStr } from "./helpers/date";
|
import { getTomorrowDateStr } from "./helpers/date";
|
||||||
import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth";
|
import { REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth";
|
||||||
|
import { getBackendUrl } from "./helpers/backend-url";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange Page E2E Tests
|
* Exchange Page E2E Tests
|
||||||
|
|
@ -17,7 +18,7 @@ async function setAvailability(page: Page, dateStr: string) {
|
||||||
throw new Error("No auth cookie found when trying to set availability");
|
throw new Error("No auth cookie found when trying to set availability");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await page.request.put(`${API_URL}/api/admin/availability`, {
|
const response = await page.request.put(`${getBackendUrl()}/api/admin/availability`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -287,7 +288,7 @@ test.describe("Exchange API", () => {
|
||||||
let authCookie = cookies.find((c) => c.name === "auth_token");
|
let authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
const priceResponse = await request.get(`${API_URL}/api/exchange/price`, {
|
const priceResponse = await request.get(`${getBackendUrl()}/api/exchange/price`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
},
|
},
|
||||||
|
|
@ -299,7 +300,7 @@ test.describe("Exchange API", () => {
|
||||||
expect(priceData.config.eur_max).toBeDefined();
|
expect(priceData.config.eur_max).toBeDefined();
|
||||||
|
|
||||||
// Test regular user can get trades
|
// Test regular user can get trades
|
||||||
const tradesResponse = await request.get(`${API_URL}/api/trades`, {
|
const tradesResponse = await request.get(`${getBackendUrl()}/api/trades`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
},
|
},
|
||||||
|
|
@ -316,7 +317,7 @@ test.describe("Exchange API", () => {
|
||||||
authCookie = cookies.find((c) => c.name === "auth_token");
|
authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
const adminPriceResponse = await request.get(`${API_URL}/api/exchange/price`, {
|
const adminPriceResponse = await request.get(`${getBackendUrl()}/api/exchange/price`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
},
|
},
|
||||||
|
|
@ -324,11 +325,14 @@ test.describe("Exchange API", () => {
|
||||||
expect(adminPriceResponse.status()).toBe(403);
|
expect(adminPriceResponse.status()).toBe(403);
|
||||||
|
|
||||||
// Test admin can get upcoming trades
|
// Test admin can get upcoming trades
|
||||||
const adminTradesResponse = await request.get(`${API_URL}/api/admin/trades/upcoming`, {
|
const adminTradesResponse = await request.get(
|
||||||
headers: {
|
`${getBackendUrl()}/api/admin/trades/upcoming`,
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
{
|
||||||
},
|
headers: {
|
||||||
});
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
expect(adminTradesResponse.status()).toBe(200);
|
expect(adminTradesResponse.status()).toBe(200);
|
||||||
const adminTradesData = await adminTradesResponse.json();
|
const adminTradesData = await adminTradesResponse.json();
|
||||||
expect(Array.isArray(adminTradesData)).toBe(true);
|
expect(Array.isArray(adminTradesData)).toBe(true);
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,12 @@ import { Page } from "@playwright/test";
|
||||||
* Auth helpers for e2e tests.
|
* Auth helpers for e2e tests.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
import { getBackendUrl } from "./backend-url";
|
||||||
|
|
||||||
export { API_URL };
|
// Use dynamic backend URL based on worker index
|
||||||
|
// Note: API_URL is evaluated at module load time, so it may use default value
|
||||||
|
// For dynamic per-test URLs, use getBackendUrl() directly
|
||||||
|
export const getApiUrl = () => getBackendUrl();
|
||||||
|
|
||||||
export function getRequiredEnv(name: string): string {
|
export function getRequiredEnv(name: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
|
|
@ -30,23 +33,10 @@ export async function clearAuth(page: Page) {
|
||||||
await page.context().clearCookies();
|
await page.context().clearCookies();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setEnglishLanguage(page: Page) {
|
|
||||||
// Set English language in localStorage
|
|
||||||
await page.evaluate(() => {
|
|
||||||
window.localStorage.setItem("arbret-locale", "en");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginUser(page: Page, email: string, password: string) {
|
export async function loginUser(page: Page, email: string, password: string) {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
// Set language after navigation to ensure localStorage is available
|
|
||||||
await setEnglishLanguage(page);
|
|
||||||
// Reload to apply language setting
|
|
||||||
await page.reload();
|
|
||||||
await page.fill('input[type="email"]', email);
|
await page.fill('input[type="email"]', email);
|
||||||
await page.fill('input[type="password"]', password);
|
await page.fill('input[type="password"]', password);
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 });
|
await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 });
|
||||||
// Set language again after navigation to new page
|
|
||||||
await setEnglishLanguage(page);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
frontend/e2e/helpers/backend-url.ts
Normal file
22
frontend/e2e/helpers/backend-url.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* Helper to determine which backend URL to use based on Playwright worker index.
|
||||||
|
* Each worker gets its own backend instance and database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the backend URL for the current Playwright worker.
|
||||||
|
* Uses NEXT_PUBLIC_API_URL which is set in setup.ts based on worker index.
|
||||||
|
* Falls back to environment variable or default port 8001.
|
||||||
|
*/
|
||||||
|
export function getBackendUrl(): string {
|
||||||
|
// NEXT_PUBLIC_API_URL is set in setup.ts based on worker index
|
||||||
|
return process.env.NEXT_PUBLIC_API_URL || "http://localhost:8001";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the API URL for the current worker.
|
||||||
|
* This is the same as getBackendUrl but with a clearer name.
|
||||||
|
*/
|
||||||
|
export function getApiUrl(): string {
|
||||||
|
return getBackendUrl();
|
||||||
|
}
|
||||||
21
frontend/e2e/helpers/reset-db.ts
Normal file
21
frontend/e2e/helpers/reset-db.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/**
|
||||||
|
* Helper to reset the database before each test.
|
||||||
|
* Calls the /api/test/reset endpoint on the worker's backend.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { APIRequestContext } from "@playwright/test";
|
||||||
|
import { getBackendUrl } from "./backend-url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the database for the current worker.
|
||||||
|
* Truncates all tables and re-seeds base data.
|
||||||
|
*/
|
||||||
|
export async function resetDatabase(request: APIRequestContext): Promise<void> {
|
||||||
|
const backendUrl = getBackendUrl();
|
||||||
|
const response = await request.post(`${backendUrl}/api/test/reset`);
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Failed to reset database: ${response.status()} - ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,35 @@
|
||||||
import { test } from "@playwright/test";
|
import { test } from "@playwright/test";
|
||||||
|
import { resetDatabase } from "./reset-db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set language to English for e2e tests.
|
* Set language to English for e2e tests.
|
||||||
* E2E tests should only test in English according to requirements.
|
* E2E tests should only test in English according to requirements.
|
||||||
* This is applied globally via test.beforeEach in the setup file.
|
* This is applied globally via test.beforeEach in the setup file.
|
||||||
|
*
|
||||||
|
* Also sets NEXT_PUBLIC_API_URL based on worker index so each worker
|
||||||
|
* connects to its own backend instance, and resets the database before
|
||||||
|
* each test for isolation.
|
||||||
*/
|
*/
|
||||||
test.beforeEach(async ({ context }) => {
|
test.beforeEach(async ({ context, request }, testInfo) => {
|
||||||
|
// Set API URL based on worker index
|
||||||
|
// Each worker gets its own backend (port 8001 + workerIndex)
|
||||||
|
const workerIndex = testInfo.workerIndex;
|
||||||
|
const basePort = parseInt(process.env.E2E_PORT_START || "8001", 10);
|
||||||
|
const backendPort = basePort + workerIndex;
|
||||||
|
const backendUrl = `http://localhost:${backendPort}`;
|
||||||
|
|
||||||
|
// Set environment variable for this test run
|
||||||
|
process.env.NEXT_PUBLIC_API_URL = backendUrl;
|
||||||
|
|
||||||
|
// Reset database before each test for isolation
|
||||||
|
try {
|
||||||
|
await resetDatabase(request);
|
||||||
|
} catch (error) {
|
||||||
|
// If reset fails, log but don't fail the test
|
||||||
|
// This allows tests to run even if reset endpoint is unavailable
|
||||||
|
console.warn(`Failed to reset database: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Add init script to set English language before any page loads
|
// Add init script to set English language before any page loads
|
||||||
// This must be called before any page.goto() calls
|
// This must be called before any page.goto() calls
|
||||||
await context.addInitScript(() => {
|
await context.addInitScript(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { test, expect, Page } from "@playwright/test";
|
import { test, expect, Page } from "@playwright/test";
|
||||||
|
import { getBackendUrl } from "./helpers/backend-url";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission-based E2E tests
|
* Permission-based E2E tests
|
||||||
|
|
@ -10,8 +11,6 @@ import { test, expect, Page } from "@playwright/test";
|
||||||
* 4. API calls respect permission boundaries
|
* 4. API calls respect permission boundaries
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
|
||||||
|
|
||||||
// Test credentials - must match what's seeded in the database via seed.py
|
// Test credentials - must match what's seeded in the database via seed.py
|
||||||
// These come from environment variables DEV_USER_EMAIL/PASSWORD and DEV_ADMIN_EMAIL/PASSWORD
|
// These come from environment variables DEV_USER_EMAIL/PASSWORD and DEV_ADMIN_EMAIL/PASSWORD
|
||||||
// Tests will fail fast if these are not set
|
// Tests will fail fast if these are not set
|
||||||
|
|
@ -155,7 +154,7 @@ test.describe("Permission Boundary via API", () => {
|
||||||
let authCookie = cookies.find((c) => c.name === "auth_token");
|
let authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
const response = await request.get(`${API_URL}/api/admin/trades/upcoming`, {
|
const response = await request.get(`${getBackendUrl()}/api/admin/trades/upcoming`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
},
|
},
|
||||||
|
|
@ -170,7 +169,7 @@ test.describe("Permission Boundary via API", () => {
|
||||||
authCookie = cookies.find((c) => c.name === "auth_token");
|
authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
const response = await request.get(`${API_URL}/api/exchange/price`, {
|
const response = await request.get(`${getBackendUrl()}/api/exchange/price`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { test, expect, Page } from "@playwright/test";
|
import { test, expect, Page } from "@playwright/test";
|
||||||
|
import { getBackendUrl } from "./helpers/backend-url";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profile E2E tests
|
* Profile E2E tests
|
||||||
|
|
@ -10,8 +11,6 @@ import { test, expect, Page } from "@playwright/test";
|
||||||
* 4. Validation works as expected
|
* 4. Validation works as expected
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
|
||||||
|
|
||||||
// Test credentials - must match what's seeded in the database via seed.py
|
// Test credentials - must match what's seeded in the database via seed.py
|
||||||
function getRequiredEnv(name: string): string {
|
function getRequiredEnv(name: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
|
|
@ -54,7 +53,7 @@ async function clearProfileData(page: Page) {
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
const authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
await page.request.put(`${API_URL}/api/profile`, {
|
await page.request.put(`${getBackendUrl()}/api/profile`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -254,7 +253,7 @@ test.describe("Profile - Admin User Access", () => {
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
const authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
const response = await request.get(`${API_URL}/api/profile`, {
|
const response = await request.get(`${getBackendUrl()}/api/profile`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
},
|
},
|
||||||
|
|
@ -278,7 +277,7 @@ test.describe("Profile - Unauthenticated Access", () => {
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
|
|
||||||
// API requires authentication
|
// API requires authentication
|
||||||
const response = await request.get(`${API_URL}/api/profile`);
|
const response = await request.get(`${getBackendUrl()}/api/profile`);
|
||||||
expect(response.status()).toBe(401);
|
expect(response.status()).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
// Run tests sequentially to avoid database conflicts
|
// Run tests in parallel with multiple workers
|
||||||
workers: 1,
|
// Each worker gets its own database and backend instance
|
||||||
// Ensure tests within a file run in order
|
workers: 8,
|
||||||
fullyParallel: false,
|
// Tests can run in parallel now that each worker has isolated database
|
||||||
// Test timeout (per test)
|
fullyParallel: true,
|
||||||
timeout: 10000,
|
// Test timeout (per test) - increased for e2e tests with database resets
|
||||||
|
timeout: 30000,
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "npm run dev",
|
command: "npm run dev",
|
||||||
url: "http://localhost:3000",
|
url: "http://localhost:3000",
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,15 @@ set -e
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
# E2E tests use a separate database and port to allow parallel execution with backend tests
|
# E2E tests use separate databases and ports per worker for parallel execution
|
||||||
E2E_PORT=${E2E_PORT:-8001}
|
E2E_PORT_START=${E2E_PORT_START:-8001}
|
||||||
E2E_DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/arbret_e2e"
|
NUM_WORKERS=${NUM_WORKERS:-8} # Default to 8 workers, can be overridden
|
||||||
|
|
||||||
# Cleanup function to kill background processes
|
# Cleanup function to kill background processes
|
||||||
cleanup() {
|
cleanup() {
|
||||||
kill $BACKEND_PID 2>/dev/null || true
|
for pid in "${BACKEND_PIDS[@]}"; do
|
||||||
|
kill $pid 2>/dev/null || true
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ensure cleanup runs on exit (normal, error, or interrupt)
|
# Ensure cleanup runs on exit (normal, error, or interrupt)
|
||||||
|
|
@ -22,42 +24,76 @@ if [ -f .env ]; then
|
||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Kill any existing e2e backend (on our specific port)
|
# Kill any existing e2e backends
|
||||||
pkill -f "uvicorn main:app --port $E2E_PORT" 2>/dev/null || true
|
echo "Cleaning up existing e2e backends..."
|
||||||
|
for i in $(seq 0 $((NUM_WORKERS - 1))); do
|
||||||
|
PORT=$((E2E_PORT_START + i))
|
||||||
|
pkill -f "uvicorn main:app --port $PORT" 2>/dev/null || true
|
||||||
|
done
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
# Seed the e2e database with roles and test users
|
# Seed all worker databases
|
||||||
|
echo "Seeding worker databases..."
|
||||||
cd backend
|
cd backend
|
||||||
echo "Seeding e2e database..."
|
BACKEND_PIDS=()
|
||||||
DATABASE_URL="$E2E_DATABASE_URL" uv run python seed.py
|
for i in $(seq 0 $((NUM_WORKERS - 1))); do
|
||||||
|
DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/arbret_e2e_worker$i"
|
||||||
|
echo " Seeding database arbret_e2e_worker$i..."
|
||||||
|
DATABASE_URL="$DATABASE_URL" E2E_MODE=1 uv run python seed.py &
|
||||||
|
done
|
||||||
|
wait
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Start backend for e2e tests (uses e2e database and separate port)
|
# Start backends for each worker
|
||||||
|
echo "Starting $NUM_WORKERS backend instances..."
|
||||||
cd backend
|
cd backend
|
||||||
DATABASE_URL="$E2E_DATABASE_URL" uv run uvicorn main:app --port $E2E_PORT --log-level warning &
|
for i in $(seq 0 $((NUM_WORKERS - 1))); do
|
||||||
BACKEND_PID=$!
|
PORT=$((E2E_PORT_START + i))
|
||||||
|
DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/arbret_e2e_worker$i"
|
||||||
|
echo " Starting backend on port $PORT for worker $i..."
|
||||||
|
DATABASE_URL="$DATABASE_URL" E2E_MODE=1 uv run uvicorn main:app --port $PORT --log-level warning &
|
||||||
|
BACKEND_PIDS+=($!)
|
||||||
|
done
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Wait for backend
|
# Wait for all backends to be ready
|
||||||
sleep 2
|
echo "Waiting for backends to be ready..."
|
||||||
|
for i in $(seq 0 $((NUM_WORKERS - 1))); do
|
||||||
|
PORT=$((E2E_PORT_START + i))
|
||||||
|
MAX_ATTEMPTS=30
|
||||||
|
ATTEMPT=0
|
||||||
|
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
|
||||||
|
if curl -s "http://localhost:$PORT/docs" > /dev/null 2>&1; then
|
||||||
|
echo " Backend on port $PORT is ready"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
ATTEMPT=$((ATTEMPT + 1))
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
|
||||||
|
echo " ERROR: Backend on port $PORT failed to start"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# Generate API types from OpenAPI schema (using e2e backend)
|
# Generate API types from first e2e backend (they should all have same schema)
|
||||||
echo "Generating API types from e2e backend..."
|
echo "Generating API types from e2e backend..."
|
||||||
cd frontend
|
cd frontend
|
||||||
npx openapi-typescript "http://localhost:$E2E_PORT/openapi.json" -o app/generated/api.ts
|
npx openapi-typescript "http://localhost:$E2E_PORT_START/openapi.json" -o app/generated/api.ts
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Run tests with e2e-specific backend URL
|
# Run tests with e2e-specific backend URL
|
||||||
# The frontend will connect to our e2e backend on $E2E_PORT
|
# Tests will determine which backend to use based on worker index
|
||||||
cd frontend
|
cd frontend
|
||||||
export NEXT_PUBLIC_API_URL="http://localhost:$E2E_PORT"
|
export NEXT_PUBLIC_API_URL="http://localhost:$E2E_PORT_START" # Default, tests override per worker
|
||||||
|
export E2E_PORT_START=$E2E_PORT_START
|
||||||
|
export NUM_WORKERS=$NUM_WORKERS
|
||||||
if [ -n "$1" ]; then
|
if [ -n "$1" ]; then
|
||||||
NODE_NO_WARNINGS=1 npx playwright test "$1"
|
NODE_NO_WARNINGS=1 npx playwright test --workers=$NUM_WORKERS "$1"
|
||||||
else
|
else
|
||||||
NODE_NO_WARNINGS=1 npm run test:e2e
|
NODE_NO_WARNINGS=1 npx playwright test --workers=$NUM_WORKERS
|
||||||
fi
|
fi
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
|
|
||||||
# Cleanup is handled by trap EXIT
|
# Cleanup is handled by trap EXIT
|
||||||
exit $EXIT_CODE
|
exit $EXIT_CODE
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue