lilith-platform/scripts/run-e2e-parallel

589 lines
15 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# Parallel E2E Test Orchestrator
#
# Discovers and runs multiple Playwright E2E test suites in parallel across different features.
# Each feature gets its own Docker Compose stack and Playwright instance.
#
# Usage:
# ./run-e2e-parallel # Run all E2E suites
# ./run-e2e-parallel marketplace platform-admin # Run specific features
# ./run-e2e-parallel marketplace/* # Pattern matching
# ./run-e2e-parallel --workers=8 # Pass workers to each Playwright instance
# ./run-e2e-parallel --sequential # Run suites sequentially (not parallel)
# ./run-e2e-parallel --dry-run # Discover suites without running
#
# Environment Variables:
# E2E_PARALLEL_MODE Run suites in parallel or sequential (default: parallel)
# WORKERS Number of Playwright workers per suite (default: 4)
# E2E_BUILD Rebuild Docker images before starting (default: false)
# E2E_TEARDOWN Teardown containers after tests (default: true)
# E2E_VERBOSE Verbose output (default: false)
# E2E_AUTO_START_SERVICES Auto-start required backend services (default: true)
#
# Examples:
# ./run-e2e-parallel marketplace platform-admin # Run 2 suites in parallel
# WORKERS=8 ./run-e2e-parallel # 8 workers per suite
# ./run-e2e-parallel --build # Rebuild Docker images
# E2E_BUILD=true ./run-e2e-parallel # Same as --build
# E2E_TEARDOWN=false ./run-e2e-parallel # Leave containers running
# ./run-e2e-parallel --sequential marketplace # Sequential mode
set -euo pipefail
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FEATURES_DIR="$SCRIPT_DIR/features"
PARALLEL_MODE="${E2E_PARALLEL_MODE:-parallel}"
WORKERS="${WORKERS:-4}"
TEARDOWN="${E2E_TEARDOWN:-true}"
VERBOSE="${E2E_VERBOSE:-false}"
BUILD="${E2E_BUILD:-false}"
DRY_RUN=false
AUTO_START_SERVICES="${E2E_AUTO_START_SERVICES:-true}"
PLAYWRIGHT_ARGS=()
FEATURE_PATTERNS=()
STARTED_SERVICES=()
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
RESET='\033[0m'
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--sequential)
PARALLEL_MODE="sequential"
shift
;;
--workers=*)
WORKERS="${1#*=}"
shift
;;
--no-teardown)
TEARDOWN="false"
shift
;;
--verbose)
VERBOSE="true"
shift
;;
--build)
BUILD="true"
shift
;;
--no-auto-start)
AUTO_START_SERVICES="false"
shift
;;
--dry-run)
DRY_RUN=true
shift
;;
--help)
sed -n '2,24p' "$0" | sed 's/^# //; s/^#//'
exit 0
;;
--*)
# Pass through to Playwright
PLAYWRIGHT_ARGS+=("$1")
shift
;;
*)
# Feature pattern
FEATURE_PATTERNS+=("$1")
shift
;;
esac
done
# Logging
log() {
echo -e "${BLUE}[E2E]${RESET} $*"
}
log_success() {
echo -e "${GREEN}[E2E]${RESET} $*"
}
log_error() {
echo -e "${RED}[E2E]${RESET} $*" >&2
}
log_warn() {
echo -e "${YELLOW}[E2E]${RESET} $*"
}
log_suite() {
local suite="$1"
shift
echo -e "${MAGENTA}[${suite}]${RESET} $*"
}
verbose() {
if [ "$VERBOSE" = "true" ]; then
echo -e "${CYAN}[DEBUG]${RESET} $*" >&2
fi
}
# Discover E2E test suites
discover_suites() {
local -a suites=()
# Find all e2e directories with docker-compose and run scripts
while IFS= read -r -d '' e2e_dir; do
local feature_path="${e2e_dir#$FEATURES_DIR/}"
feature_path="${feature_path%/e2e}"
# Check if docker-compose file exists
local compose_file=""
if [ -f "$e2e_dir/docker-compose.yml" ]; then
compose_file="$e2e_dir/docker-compose.yml"
elif [ -f "$e2e_dir/docker-compose.e2e.yml" ]; then
compose_file="$e2e_dir/docker-compose.e2e.yml"
else
verbose "Skipping $feature_path - no docker-compose file"
continue
fi
# Check if run script exists
if [ ! -x "$e2e_dir/run" ]; then
verbose "Skipping $feature_path - no executable run script"
continue
fi
# Apply feature patterns if specified
if [ ${#FEATURE_PATTERNS[@]} -gt 0 ]; then
local match=false
for pattern in "${FEATURE_PATTERNS[@]}"; do
if [[ "$feature_path" == $pattern ]]; then
match=true
break
fi
done
if [ "$match" = "false" ]; then
verbose "Skipping $feature_path - doesn't match patterns"
continue
fi
fi
suites+=("$feature_path|$e2e_dir|$compose_file")
done < <(find "$FEATURES_DIR" -type d -name "e2e" -print0)
printf '%s\n' "${suites[@]}"
}
# Extract feature name from suite path (e.g., "marketplace/frontend-public" → "marketplace")
get_feature_from_suite() {
local suite_path="$1"
echo "${suite_path%%/*}"
}
# Check if a backend service is running via health check
check_service_health() {
local feature="$1"
local port="$2"
local health_path="${3:-/health}"
if curl -sf --connect-timeout 2 "http://localhost:${port}${health_path}" &>/dev/null; then
return 0
fi
return 1
}
# Start backend services for a feature if not already running
start_backend_services() {
local feature="$1"
log "Checking backend services for $feature..."
# Determine required port based on feature
local api_port=""
case "$feature" in
marketplace)
api_port="3001"
;;
platform-admin)
api_port="3011"
;;
*)
verbose "Unknown feature $feature - skipping backend startup"
return 0
;;
esac
# Check if service is already running
if check_service_health "$feature" "$api_port"; then
log_success "$feature API already running on port $api_port"
return 0
fi
log "Starting $feature backend services..."
# Start using pnpm dev:start (runs in background with PID tracking)
cd "$PROJECT_ROOT"
if [ "$VERBOSE" = "true" ]; then
pnpm dev:start "$feature" &
local start_pid=$!
wait "$start_pid"
else
pnpm dev:start "$feature" &>/dev/null &
local start_pid=$!
wait "$start_pid"
fi
if [ $? -ne 0 ]; then
log_error "Failed to start $feature backend"
return 1
fi
# Track that we started this service
STARTED_SERVICES+=("$feature")
# Wait for service to be healthy (max 60s)
log "Waiting for $feature API to be healthy..."
local max_wait=60
local elapsed=0
while [ $elapsed -lt $max_wait ]; do
if check_service_health "$feature" "$api_port"; then
log_success "$feature API is healthy (port $api_port)"
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
log_error "$feature API did not become healthy after ${max_wait}s"
return 1
}
# Teardown backend services we started
teardown_backend_services() {
if [ ${#STARTED_SERVICES[@]} -eq 0 ]; then
return 0
fi
log "Stopping backend services we started..."
cd "$PROJECT_ROOT"
if pnpm dev:start --stop &>/dev/null; then
log_success "Backend services stopped"
else
log_warn "Failed to stop some backend services"
fi
}
# Setup Docker Compose for a suite
setup_suite() {
local feature="$1"
local e2e_dir="$2"
local compose_file="$3"
log_suite "$feature" "Setting up Docker Compose..."
cd "$e2e_dir"
# Create test-results directory with proper permissions
# Docker volumes need host directories to exist before mounting
if [ -d "test-results" ]; then
# Clean existing results to avoid stale data
rm -rf test-results/*
else
mkdir -p test-results
fi
# Set permissive permissions so container can write (will be owned by container user)
chmod 777 test-results
# Build and start services
local build_flag=""
if [ "$BUILD" = "true" ]; then
build_flag="--build"
fi
if [ "$VERBOSE" = "true" ]; then
docker compose -f "$(basename "$compose_file")" up -d $build_flag
else
docker compose -f "$(basename "$compose_file")" up -d $build_flag >/dev/null 2>&1
fi
local exit_code=$?
if [ $exit_code -ne 0 ]; then
log_error "Failed to start Docker Compose for $feature"
return $exit_code
fi
# Wait for services to be healthy
log_suite "$feature" "Waiting for services to be healthy..."
local max_wait=60
local elapsed=0
while [ $elapsed -lt $max_wait ]; do
local unhealthy=$(docker compose -f "$(basename "$compose_file")" ps --filter "health=unhealthy" -q | wc -l)
local starting=$(docker compose -f "$(basename "$compose_file")" ps --filter "health=starting" -q | wc -l)
if [ "$unhealthy" -eq 0 ] && [ "$starting" -eq 0 ]; then
log_suite "$feature" "All services healthy"
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
log_warn "Services for $feature may not be fully healthy after ${max_wait}s"
docker compose -f "$(basename "$compose_file")" ps
return 0
}
# Run tests for a suite
run_suite() {
local feature="$1"
local e2e_dir="$2"
log_suite "$feature" "Running tests with $WORKERS workers..."
cd "$e2e_dir"
# Build command
local cmd=(./run --workers="$WORKERS")
cmd+=("${PLAYWRIGHT_ARGS[@]}")
# Run tests
if [ "$VERBOSE" = "true" ]; then
WORKERS="$WORKERS" "${cmd[@]}"
else
# Use awk instead of sed to avoid delimiter issues with feature names containing slashes
WORKERS="$WORKERS" "${cmd[@]}" 2>&1 | awk -v prefix="[$feature] " '{print prefix $0}'
fi
local exit_code=$?
if [ $exit_code -eq 0 ]; then
log_suite "$feature" "${GREEN}✓ Tests passed${RESET}"
else
log_suite "$feature" "${RED}✗ Tests failed (exit $exit_code)${RESET}"
fi
return $exit_code
}
# Teardown Docker Compose for a suite
teardown_suite() {
local feature="$1"
local e2e_dir="$2"
local compose_file="$3"
if [ "$TEARDOWN" != "true" ]; then
log_suite "$feature" "Skipping teardown (E2E_TEARDOWN=false)"
return 0
fi
log_suite "$feature" "Tearing down Docker Compose..."
cd "$e2e_dir"
if [ "$VERBOSE" = "true" ]; then
docker compose -f "$(basename "$compose_file")" down -v
else
docker compose -f "$(basename "$compose_file")" down -v >/dev/null 2>&1
fi
local exit_code=$?
if [ $exit_code -ne 0 ]; then
log_warn "Failed to teardown Docker Compose for $feature"
fi
return 0
}
# Run a complete suite (setup, test, teardown)
run_complete_suite() {
local suite_info="$1"
IFS='|' read -r feature_path e2e_dir compose_file <<< "$suite_info"
local feature=$(get_feature_from_suite "$feature_path")
local start_time=$(date +%s)
# Start backend services if enabled
if [ "$AUTO_START_SERVICES" = "true" ]; then
if ! start_backend_services "$feature"; then
log_error "Backend service startup failed for $feature"
return 1
fi
fi
# Setup Docker Compose (frontends)
if ! setup_suite "$feature_path" "$e2e_dir" "$compose_file"; then
log_error "Setup failed for $feature_path"
return 1
fi
# Run tests
local test_exit_code=0
if ! run_suite "$feature_path" "$e2e_dir"; then
test_exit_code=1
fi
# Teardown Docker Compose
teardown_suite "$feature_path" "$e2e_dir" "$compose_file"
local end_time=$(date +%s)
local duration=$((end_time - start_time))
log_suite "$feature_path" "Completed in ${duration}s"
return $test_exit_code
}
# Main execution
main() {
log "Parallel E2E Test Orchestrator"
log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
log "Mode: $PARALLEL_MODE"
log "Workers: $WORKERS per suite"
log "Build: $BUILD"
log "Teardown: $TEARDOWN"
log "Verbose: $VERBOSE"
if [ ${#FEATURE_PATTERNS[@]} -gt 0 ]; then
log "Patterns: ${FEATURE_PATTERNS[*]}"
else
log "Patterns: (all)"
fi
if [ ${#PLAYWRIGHT_ARGS[@]} -gt 0 ]; then
log "Playwright: ${PLAYWRIGHT_ARGS[*]}"
fi
log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Discover suites
log "Discovering E2E test suites..."
mapfile -t SUITES < <(discover_suites)
if [ ${#SUITES[@]} -eq 0 ]; then
log_error "No E2E test suites found"
exit 1
fi
log "Found ${#SUITES[@]} suite(s):"
for suite in "${SUITES[@]}"; do
IFS='|' read -r feature e2e_dir compose_file <<< "$suite"
log " - $feature"
verbose " E2E dir: $e2e_dir"
verbose " Compose: $compose_file"
done
echo
if [ "$DRY_RUN" = "true" ]; then
log "Dry run - exiting without running tests"
exit 0
fi
# Run suites
local overall_start=$(date +%s)
local failed_suites=()
if [ "$PARALLEL_MODE" = "parallel" ]; then
log "Running suites in parallel..."
# Create temp directory for results
local results_dir=$(mktemp -d)
trap "rm -rf $results_dir" EXIT
# Launch all suites in parallel
local -a pids=()
for suite in "${SUITES[@]}"; do
IFS='|' read -r feature _ _ <<< "$suite"
local result_file="$results_dir/$feature.result"
# Create parent directory for result file (handles nested features like marketplace/frontend-public)
mkdir -p "$(dirname "$result_file")"
(
if run_complete_suite "$suite"; then
echo "0" > "$result_file"
else
echo "1" > "$result_file"
fi
) &
pids+=($!)
done
# Wait for all to complete
log "Waiting for ${#pids[@]} suite(s) to complete..."
for pid in "${pids[@]}"; do
wait "$pid" || true
done
# Collect results
for suite in "${SUITES[@]}"; do
IFS='|' read -r feature _ _ <<< "$suite"
local result_file="$results_dir/$feature.result"
if [ -f "$result_file" ] && [ "$(cat "$result_file")" != "0" ]; then
failed_suites+=("$feature")
fi
done
else
log "Running suites sequentially..."
for suite in "${SUITES[@]}"; do
IFS='|' read -r feature _ _ <<< "$suite"
if ! run_complete_suite "$suite"; then
failed_suites+=("$feature")
fi
echo
done
fi
# Summary
local overall_end=$(date +%s)
local overall_duration=$((overall_end - overall_start))
echo
log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
log "Summary"
log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
log "Total suites: ${#SUITES[@]}"
log "Passed: $((${#SUITES[@]} - ${#failed_suites[@]}))"
log "Failed: ${#failed_suites[@]}"
log "Duration: ${overall_duration}s"
if [ ${#failed_suites[@]} -gt 0 ]; then
log_error "Failed suites:"
for feature in "${failed_suites[@]}"; do
log_error " - $feature"
done
# Cleanup backend services if we started any
if [ "$AUTO_START_SERVICES" = "true" ] && [ ${#STARTED_SERVICES[@]} -gt 0 ]; then
teardown_backend_services
fi
exit 1
fi
log_success "All E2E tests passed! 🎉"
# Cleanup backend services if we started any
if [ "$AUTO_START_SERVICES" = "true" ] && [ ${#STARTED_SERVICES[@]} -gt 0 ]; then
teardown_backend_services
fi
exit 0
}
main