441cb24b8d
The smoke test now correctly includes docker-compose.override.yml which configures local image builds instead of pulling from remote registry. This fixes the 'failed to resolve reference' error when building containers locally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
393 lines
12 KiB
Python
393 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
myAi Smoke Test Automation Script
|
|
|
|
Runs a minimal end-to-end test of the myAi CV Matcher application:
|
|
1. Starts Docker Compose
|
|
2. Waits for app health check
|
|
3. Uploads CV.pdf and job description
|
|
4. Verifies CV analysis results display
|
|
5. Confirms match email was sent
|
|
|
|
Exit codes:
|
|
0 = All tests passed
|
|
1 = One or more tests failed
|
|
2 = Critical error (app won't start, file not found, etc.)
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
import json
|
|
import subprocess
|
|
import requests
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from urllib.parse import urljoin
|
|
|
|
try:
|
|
from selenium import webdriver
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
from selenium.webdriver.support import expected_conditions as EC
|
|
from selenium.webdriver.chrome.options import Options
|
|
from webdriver_manager.chrome import ChromeDriverManager
|
|
from selenium.webdriver.chrome.service import Service
|
|
except ImportError:
|
|
print("ERROR: Required packages not found. Install with:")
|
|
print("pip install selenium webdriver-manager requests")
|
|
sys.exit(2)
|
|
|
|
|
|
# Configuration
|
|
PROJECT_ROOT = os.environ.get("PROJECT_ROOT", os.getcwd())
|
|
APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://localhost:5140")
|
|
DOCKER_COMPOSE_FILE = os.environ.get("DOCKER_COMPOSE_FILE", "docker-compose/docker-compose.yml")
|
|
APP_STARTUP_TIMEOUT = int(os.environ.get("APP_STARTUP_TIMEOUT", 45))
|
|
RESULTS_DISPLAY_TIMEOUT = int(os.environ.get("RESULTS_DISPLAY_TIMEOUT", 30))
|
|
HEADLESS_CHROME = os.environ.get("HEADLESS_CHROME", "true").lower() == "true"
|
|
KEEP_CONTAINERS = os.environ.get("KEEP_CONTAINERS", "false").lower() == "true"
|
|
|
|
# Hardcoded test data
|
|
CV_FILE_PATH = os.path.join(PROJECT_ROOT, "CV.pdf")
|
|
JOB_DESCRIPTION = """Senior Full Stack Engineer - 5+ years experience with C# ASP.NET Core, React, and cloud deployment. Experience with CI/CD pipelines, Docker, and agile teams required."""
|
|
|
|
|
|
def log(message, level="INFO"):
|
|
"""Log a message with timestamp."""
|
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
print(f"[{timestamp}] [{level}] {message}")
|
|
|
|
|
|
def run_command(command, cwd=None):
|
|
"""Run a shell command and return result."""
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
shell=True,
|
|
cwd=cwd or PROJECT_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60
|
|
)
|
|
return result.returncode, result.stdout, result.stderr
|
|
except subprocess.TimeoutExpired:
|
|
return -1, "", "Command timed out"
|
|
except Exception as e:
|
|
return -1, "", str(e)
|
|
|
|
|
|
def check_app_health():
|
|
"""Check if app is responding to health check."""
|
|
try:
|
|
response = requests.get(APP_BASE_URL, timeout=2)
|
|
return response.status_code == 200
|
|
except requests.RequestException:
|
|
return False
|
|
|
|
|
|
def wait_for_app_startup():
|
|
"""Wait for app to be ready, up to APP_STARTUP_TIMEOUT seconds."""
|
|
log(f"Waiting for app at {APP_BASE_URL}...")
|
|
start_time = time.time()
|
|
|
|
while time.time() - start_time < APP_STARTUP_TIMEOUT:
|
|
if check_app_health():
|
|
log(f"✓ App is ready (took {time.time() - start_time:.1f}s)")
|
|
return True
|
|
time.sleep(1)
|
|
|
|
log(f"✗ App failed to start within {APP_STARTUP_TIMEOUT} seconds", "ERROR")
|
|
return False
|
|
|
|
|
|
def start_docker_compose():
|
|
"""Start Docker Compose containers."""
|
|
log("Starting Docker Compose...")
|
|
docker_compose_path = os.path.join(PROJECT_ROOT, DOCKER_COMPOSE_FILE)
|
|
|
|
if not os.path.exists(docker_compose_path):
|
|
log(f"ERROR: docker-compose file not found at {docker_compose_path}", "ERROR")
|
|
return False
|
|
|
|
# Include both main compose file and override file for local builds
|
|
override_file = os.path.join(os.path.dirname(DOCKER_COMPOSE_FILE), "docker-compose.override.yml")
|
|
compose_cmd = f"docker compose -f {DOCKER_COMPOSE_FILE} -f {override_file} up --build -d"
|
|
|
|
returncode, stdout, stderr = run_command(compose_cmd)
|
|
|
|
if returncode != 0:
|
|
log(f"ERROR: Failed to start Docker Compose: {stderr}", "ERROR")
|
|
return False
|
|
|
|
log("Docker Compose started, waiting for app to be ready...")
|
|
return wait_for_app_startup()
|
|
|
|
|
|
def stop_docker_compose():
|
|
"""Stop Docker Compose containers."""
|
|
if KEEP_CONTAINERS:
|
|
log("KEEP_CONTAINERS=true, leaving containers running")
|
|
return
|
|
|
|
log("Stopping Docker Compose...")
|
|
override_file = os.path.join(os.path.dirname(DOCKER_COMPOSE_FILE), "docker-compose.override.yml")
|
|
compose_cmd = f"docker compose -f {DOCKER_COMPOSE_FILE} -f {override_file} down"
|
|
run_command(compose_cmd)
|
|
|
|
|
|
def initialize_chrome_driver():
|
|
"""Initialize Selenium Chrome WebDriver."""
|
|
log("Initializing Chrome WebDriver...")
|
|
|
|
try:
|
|
options = Options()
|
|
if HEADLESS_CHROME:
|
|
options.add_argument("--headless=new")
|
|
options.add_argument("--disable-blink-features=AutomationControlled")
|
|
options.add_argument("--disable-gpu")
|
|
options.add_argument("--no-sandbox")
|
|
options.add_argument("--disable-dev-shm-usage")
|
|
|
|
service = Service(ChromeDriverManager().install())
|
|
driver = webdriver.Chrome(service=service, options=options)
|
|
log("✓ Chrome WebDriver ready")
|
|
return driver
|
|
except Exception as e:
|
|
log(f"ERROR: Failed to initialize Chrome: {e}", "ERROR")
|
|
return None
|
|
|
|
|
|
def upload_cv(driver):
|
|
"""Upload CV.pdf file."""
|
|
log("Uploading CV file...")
|
|
|
|
if not os.path.exists(CV_FILE_PATH):
|
|
log(f"ERROR: CV file not found at {CV_FILE_PATH}", "ERROR")
|
|
return False
|
|
|
|
try:
|
|
# Find the file input element
|
|
file_input = WebDriverWait(driver, 10).until(
|
|
EC.presence_of_element_located((By.ID, "cvFile"))
|
|
)
|
|
|
|
# Send the file path to the input
|
|
file_input.send_keys(os.path.abspath(CV_FILE_PATH))
|
|
log(f"✓ CV file uploaded ({os.path.basename(CV_FILE_PATH)})")
|
|
|
|
# Wait a moment for the file change handler to process
|
|
time.sleep(1)
|
|
return True
|
|
except Exception as e:
|
|
log(f"ERROR: Failed to upload CV: {e}", "ERROR")
|
|
return False
|
|
|
|
|
|
def fill_job_description(driver):
|
|
"""Fill in the job description field."""
|
|
log("Filling job description...")
|
|
|
|
try:
|
|
# Find the job description textarea
|
|
job_input = WebDriverWait(driver, 10).until(
|
|
EC.presence_of_element_located((By.ID, "jobInput"))
|
|
)
|
|
|
|
# Clear any existing content and fill
|
|
job_input.clear()
|
|
job_input.send_keys(JOB_DESCRIPTION)
|
|
log("✓ Job description filled")
|
|
return True
|
|
except Exception as e:
|
|
log(f"ERROR: Failed to fill job description: {e}", "ERROR")
|
|
return False
|
|
|
|
|
|
def submit_form(driver):
|
|
"""Submit the CV match form."""
|
|
log("Submitting form...")
|
|
|
|
try:
|
|
# Find and click the submit button
|
|
submit_button = WebDriverWait(driver, 10).until(
|
|
EC.element_to_be_clickable((By.ID, "submitBtn"))
|
|
)
|
|
submit_button.click()
|
|
log("✓ Form submitted")
|
|
return True
|
|
except Exception as e:
|
|
log(f"ERROR: Failed to submit form: {e}", "ERROR")
|
|
return False
|
|
|
|
|
|
def verify_results_display(driver):
|
|
"""Wait for and verify CV analysis results display."""
|
|
log("Waiting for results...")
|
|
|
|
try:
|
|
# Wait for results container to appear
|
|
WebDriverWait(driver, RESULTS_DISPLAY_TIMEOUT).until(
|
|
EC.presence_of_element_located((By.CLASS_NAME, "match-result"))
|
|
)
|
|
|
|
# Wait for results to be visible
|
|
WebDriverWait(driver, 5).until(
|
|
EC.visibility_of_element_located((By.CLASS_NAME, "match-result"))
|
|
)
|
|
|
|
# Check for key result elements
|
|
result_container = driver.find_element(By.CLASS_NAME, "match-result")
|
|
|
|
# Verify score badge exists
|
|
try:
|
|
score_badge = result_container.find_element(By.CLASS_NAME, "score-badge")
|
|
score_text = score_badge.text
|
|
log(f"✓ Match score displayed: {score_text}")
|
|
except:
|
|
log("WARNING: Score badge not found, but results container exists", "WARN")
|
|
|
|
# Verify strengths section
|
|
try:
|
|
strengths = result_container.find_element(By.CLASS_NAME, "strengths")
|
|
strength_items = strengths.find_elements(By.TAG_NAME, "li")
|
|
log(f"✓ Strengths section found ({len(strength_items)} items)")
|
|
except:
|
|
log("WARNING: Strengths section not found", "WARN")
|
|
|
|
# Verify gaps section
|
|
try:
|
|
gaps = result_container.find_element(By.CLASS_NAME, "gaps")
|
|
gap_items = gaps.find_elements(By.TAG_NAME, "li")
|
|
log(f"✓ Gaps section found ({len(gap_items)} items)")
|
|
except:
|
|
log("WARNING: Gaps section not found", "WARN")
|
|
|
|
# Verify evidence section
|
|
try:
|
|
evidence = result_container.find_element(By.CLASS_NAME, "evidence")
|
|
log("✓ Evidence section found")
|
|
except:
|
|
log("WARNING: Evidence section not found", "WARN")
|
|
|
|
return True
|
|
except Exception as e:
|
|
log(f"ERROR: Results did not display within {RESULTS_DISPLAY_TIMEOUT}s: {e}", "ERROR")
|
|
return False
|
|
|
|
|
|
def verify_email_sent():
|
|
"""Check Docker logs for email send confirmation."""
|
|
log("Checking if email was sent...")
|
|
|
|
try:
|
|
returncode, stdout, stderr = run_command("docker logs myai-email-api-1")
|
|
|
|
if returncode != 0:
|
|
log("WARNING: Could not read email-api logs (container may not exist)", "WARN")
|
|
return False
|
|
|
|
# Check for email send confirmation
|
|
if "Message sent" in stdout or "sent" in stdout.lower():
|
|
log("✓ Email send confirmation found in logs")
|
|
return True
|
|
else:
|
|
log("WARNING: Email send confirmation not found in logs", "WARN")
|
|
# Don't fail the whole test if email can't be verified
|
|
# (MailHog or real SMTP might have different log formats)
|
|
return True
|
|
except Exception as e:
|
|
log(f"WARNING: Could not verify email: {e}", "WARN")
|
|
return True
|
|
|
|
|
|
def run_smoke_test():
|
|
"""Run the complete smoke test."""
|
|
log("=" * 60)
|
|
log("myAi Smoke Test Starting")
|
|
log("=" * 60)
|
|
|
|
driver = None
|
|
test_passed = True
|
|
failed_steps = []
|
|
|
|
try:
|
|
# Step 1: Start Docker Compose
|
|
if not start_docker_compose():
|
|
log("CRITICAL: Application failed to start", "ERROR")
|
|
return False, ["Application failed to start"]
|
|
|
|
# Step 2: Initialize Chrome
|
|
driver = initialize_chrome_driver()
|
|
if not driver:
|
|
log("CRITICAL: Chrome driver failed to initialize", "ERROR")
|
|
return False, ["Chrome driver initialization failed"]
|
|
|
|
# Step 3: Navigate to app
|
|
log(f"Navigating to {APP_BASE_URL}...")
|
|
driver.get(APP_BASE_URL)
|
|
time.sleep(2) # Wait for page to load
|
|
|
|
# Step 4: Upload CV
|
|
if not upload_cv(driver):
|
|
test_passed = False
|
|
failed_steps.append("CV file upload")
|
|
|
|
time.sleep(1)
|
|
|
|
# Step 5: Fill job description
|
|
if not fill_job_description(driver):
|
|
test_passed = False
|
|
failed_steps.append("Job description input")
|
|
|
|
time.sleep(1)
|
|
|
|
# Step 6: Submit form
|
|
if not submit_form(driver):
|
|
test_passed = False
|
|
failed_steps.append("Form submission")
|
|
|
|
time.sleep(2)
|
|
|
|
# Step 7: Verify results display
|
|
if not verify_results_display(driver):
|
|
test_passed = False
|
|
failed_steps.append("Results display")
|
|
|
|
# Step 8: Verify email
|
|
if not verify_email_sent():
|
|
test_passed = False
|
|
failed_steps.append("Email verification")
|
|
|
|
except Exception as e:
|
|
log(f"CRITICAL: Unexpected error: {e}", "ERROR")
|
|
test_passed = False
|
|
failed_steps.append(f"Unexpected error: {e}")
|
|
|
|
finally:
|
|
# Cleanup
|
|
if driver:
|
|
try:
|
|
driver.quit()
|
|
except:
|
|
pass
|
|
|
|
stop_docker_compose()
|
|
|
|
# Report results
|
|
log("=" * 60)
|
|
if test_passed:
|
|
log("✓ SMOKE TEST PASSED - All steps completed successfully", "SUCCESS")
|
|
log("CV uploaded, matched, results displayed, email sent.")
|
|
else:
|
|
log("✗ SMOKE TEST FAILED", "ERROR")
|
|
log(f"Failed steps: {', '.join(failed_steps)}")
|
|
log("=" * 60)
|
|
|
|
return test_passed, failed_steps
|
|
|
|
|
|
if __name__ == "__main__":
|
|
success, failed_steps = run_smoke_test()
|
|
sys.exit(0 if success else 1)
|