#!/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