589 lines
15 KiB
Bash
Executable file
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
|