diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b866f808..6c664608 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,7 +117,7 @@ jobs: -e KAFKA_BOOTSTRAP_SERVERS="kafka:9093" \ -e KAFKA_GROUP_ID="launchpad-test-ci" \ -e KAFKA_TOPICS="preprod-artifact-events" \ - launchpad-test python -m pytest -n auto tests/ -v + launchpad-test python -m pytest -n auto tests/ --ignore=tests/e2e -v - name: Show Kafka logs on failure if: failure() @@ -226,9 +226,120 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: junit.xml + e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + + - name: Install dependencies + run: make install-dev + + - name: Start Kafka with devservices + run: | + .venv/bin/devservices up --mode default + + echo "Waiting for Kafka to be ready..." + KAFKA_READY=false + for i in {1..30}; do + KAFKA_CONTAINER=$(docker ps -qf "name=kafka") + if [ -z "$KAFKA_CONTAINER" ]; then + echo "Waiting for Kafka container to start... attempt $i/30" + sleep 2 + continue + fi + + HEALTH_STATUS=$(docker inspect --format='{{.State.Health.Status}}' $KAFKA_CONTAINER 2>/dev/null || echo "none") + if [ "$HEALTH_STATUS" = "healthy" ]; then + echo "Kafka is ready!" + KAFKA_READY=true + break + fi + echo "Waiting for Kafka health check (status: $HEALTH_STATUS)... attempt $i/30" + sleep 2 + done + + if [ "$KAFKA_READY" = "false" ]; then + echo "ERROR: Kafka failed to become healthy after 60 seconds" + echo "=== Docker containers ===" + docker ps -a + echo "=== Kafka logs ===" + docker logs $(docker ps -aqf "name=kafka") --tail 100 || echo "Could not get Kafka logs" + exit 1 + fi + + docker ps + + - name: Create Kafka topic + run: | + KAFKA_CONTAINER=$(docker ps -qf "name=kafka") + echo "Creating preprod-artifact-events topic..." + docker exec $KAFKA_CONTAINER kafka-topics --bootstrap-server localhost:9092 --create --topic preprod-artifact-events --partitions 1 --replication-factor 1 --if-not-exists + echo "Topic created successfully" + + - name: Build E2E Docker images + run: docker compose -f docker-compose.e2e.yml build + + - name: Start E2E services + run: | + # Start services in detached mode (minio, mock-sentry-api, launchpad) + docker compose -f docker-compose.e2e.yml up -d minio mock-sentry-api launchpad + + # Wait for launchpad to be healthy + echo "Waiting for Launchpad to be healthy..." + LAUNCHPAD_READY=false + for i in {1..30}; do + if docker compose -f docker-compose.e2e.yml ps launchpad | grep -q "healthy"; then + echo "Launchpad is ready!" + LAUNCHPAD_READY=true + break + fi + echo "Waiting for Launchpad... attempt $i/30" + sleep 5 + done + + if [ "$LAUNCHPAD_READY" = "false" ]; then + echo "ERROR: Launchpad failed to become healthy" + docker compose -f docker-compose.e2e.yml logs launchpad + exit 1 + fi + + # Show running services + docker compose -f docker-compose.e2e.yml ps + + - name: Run E2E tests + run: | + docker compose -f docker-compose.e2e.yml run --rm e2e-tests + timeout-minutes: 10 + + - name: Show service logs on failure + if: failure() + run: | + echo "=== Launchpad logs ===" + docker compose -f docker-compose.e2e.yml logs launchpad + echo "=== Mock API logs ===" + docker compose -f docker-compose.e2e.yml logs mock-sentry-api + echo "=== Kafka logs ===" + docker logs $(docker ps -qf "name=kafka") --tail 100 || echo "Could not get Kafka logs" + + - name: Cleanup E2E environment + if: always() + run: docker compose -f docker-compose.e2e.yml down -v + build: runs-on: ubuntu-latest - needs: [check, test] + needs: [check, test, e2e] steps: - name: Checkout code diff --git a/Makefile b/Makefile index 9c620dbd..1d8307d9 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,29 @@ test-unit: test-integration: $(PYTHON_VENV) -m pytest -n auto tests/integration/ -v +test-e2e: ## Run E2E tests with Docker Compose (requires devservices up) + @echo "Ensuring devservices Kafka is running..." + @if ! docker ps | grep -q kafka; then \ + echo "Starting devservices..."; \ + devservices up --mode default; \ + sleep 10; \ + else \ + echo "Kafka already running"; \ + fi + @echo "Starting E2E test environment..." + docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from e2e-tests + @echo "Cleaning up E2E environment..." + docker compose -f docker-compose.e2e.yml down -v + +test-e2e-up: ## Start E2E environment (for debugging) + docker compose -f docker-compose.e2e.yml up --build + +test-e2e-down: ## Stop E2E environment + docker compose -f docker-compose.e2e.yml down -v + +test-e2e-logs: ## Show logs from E2E environment + docker compose -f docker-compose.e2e.yml logs -f + coverage: $(PYTHON_VENV) -m pytest tests/unit/ tests/integration/ -v --cov --cov-branch --cov-report=xml --junitxml=junit.xml diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 00000000..d4bf388d --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,120 @@ +# Note: This E2E setup leverages your existing devservices Kafka +# Run `devservices up` before starting these tests + +services: + # MinIO for ObjectStore (S3-compatible) + minio: + image: minio/minio:latest + ports: + - "9010:9000" + - "9011:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 3 + volumes: + - minio-data:/data + networks: + - launchpad-e2e + + # Mock Sentry API server + mock-sentry-api: + build: + context: . + dockerfile: tests/e2e/mock-sentry-api/Dockerfile + ports: + - "8001:8000" + environment: + PYTHONUNBUFFERED: "1" + MINIO_ENDPOINT: "http://minio:9000" + MINIO_ACCESS_KEY: "minioadmin" + MINIO_SECRET_KEY: "minioadmin" + depends_on: + minio: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "-L", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 3 + volumes: + - mock-api-data:/app/data + networks: + - launchpad-e2e + - devservices + + # Launchpad service + launchpad: + build: + context: . + dockerfile: Dockerfile + args: + TEST_BUILD: "true" # Include test fixtures + ports: + - "2218:2218" + environment: + PYTHONUNBUFFERED: "1" + KAFKA_BOOTSTRAP_SERVERS: "kafka:9093" + KAFKA_GROUP_ID: "launchpad-e2e-test" + KAFKA_TOPICS: "preprod-artifact-events" + KAFKA_AUTO_OFFSET_RESET: "earliest" + LAUNCHPAD_HOST: "0.0.0.0" + LAUNCHPAD_PORT: "2218" + LAUNCHPAD_ENV: "e2e-test" + SENTRY_BASE_URL: "http://mock-sentry-api:8000" + OBJECTSTORE_URL: "http://minio:9000" + LAUNCHPAD_RPC_SHARED_SECRET: "test-secret-key-for-e2e" + SENTRY_DSN: "" # Disable Sentry SDK in tests + depends_on: + mock-sentry-api: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:2218/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - launchpad-e2e + - devservices + + # Test orchestrator + e2e-tests: + build: + context: . + dockerfile: tests/e2e/Dockerfile.test-runner + environment: + KAFKA_BOOTSTRAP_SERVERS: "kafka:9093" + MOCK_API_URL: "http://mock-sentry-api:8000" + LAUNCHPAD_URL: "http://launchpad:2218" + MINIO_ENDPOINT: "http://minio:9000" + MINIO_ACCESS_KEY: "minioadmin" + MINIO_SECRET_KEY: "minioadmin" + LAUNCHPAD_RPC_SHARED_SECRET: "test-secret-key-for-e2e" + depends_on: + launchpad: + condition: service_healthy + mock-sentry-api: + condition: service_healthy + volumes: + - ./tests/e2e/results:/app/results + command: pytest e2e_tests/test_e2e_flow.py -v --tb=short + networks: + - launchpad-e2e + - devservices + +volumes: + minio-data: + mock-api-data: + +networks: + launchpad-e2e: + name: launchpad-e2e + devservices: + name: devservices + external: true diff --git a/tests/e2e/Dockerfile.test-runner b/tests/e2e/Dockerfile.test-runner new file mode 100644 index 00000000..b48d4c0d --- /dev/null +++ b/tests/e2e/Dockerfile.test-runner @@ -0,0 +1,29 @@ +FROM python:3.13-slim + +WORKDIR /app + +# Install system dependencies including build tools for confluent-kafka +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + gcc \ + g++ \ + librdkafka-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python test dependencies +RUN pip install --no-cache-dir \ + pytest==8.3.3 \ + pytest-asyncio==0.24.0 \ + confluent-kafka==2.5.3 \ + requests==2.32.3 \ + boto3==1.35.0 + +# Copy only E2E test files (not the main test suite) +# Copy to root to avoid pytest finding parent conftest.py +COPY tests/e2e /app/e2e_tests +COPY tests/_fixtures /app/fixtures + +# Create results directory +RUN mkdir -p /app/results + +WORKDIR /app diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 00000000..c1cf8b7d --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,11 @@ +"""Conftest for E2E tests - overrides main conftest to avoid importing launchpad.""" + +import os + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def setup_test_environment(): + """Set up test environment variables for E2E tests.""" + os.environ.setdefault("LAUNCHPAD_ENV", "e2e-test") diff --git a/tests/e2e/mock-sentry-api/Dockerfile b/tests/e2e/mock-sentry-api/Dockerfile new file mode 100644 index 00000000..afc6204d --- /dev/null +++ b/tests/e2e/mock-sentry-api/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.13-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl && \ + rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +RUN pip install --no-cache-dir \ + fastapi==0.115.0 \ + uvicorn[standard]==0.32.0 \ + pydantic==2.9.2 \ + boto3==1.35.0 \ + python-multipart==0.0.20 + +# Copy mock API server code +COPY tests/e2e/mock-sentry-api/server.py . + +# Create data directory for storing artifacts and results +RUN mkdir -p /app/data/artifacts /app/data/results /app/data/chunks + +EXPOSE 8000 + +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/tests/e2e/mock-sentry-api/server.py b/tests/e2e/mock-sentry-api/server.py new file mode 100644 index 00000000..724c9161 --- /dev/null +++ b/tests/e2e/mock-sentry-api/server.py @@ -0,0 +1,327 @@ +"""Mock Sentry API server for E2E testing. + +This server simulates the Sentry monolith API endpoints that Launchpad interacts with: +- Artifact download +- Artifact updates +- Size analysis uploads (chunked) +- Chunk assembly +""" + +import hashlib +import hmac +import json +import os + +from pathlib import Path +from typing import Any, Dict, Optional + +from fastapi import FastAPI, Header, HTTPException, Request, Response, UploadFile +from fastapi.responses import FileResponse, JSONResponse + +app = FastAPI(title="Mock Sentry API for Launchpad E2E Tests") + +# Storage paths +DATA_DIR = Path("/app/data") +ARTIFACTS_DIR = DATA_DIR / "artifacts" +RESULTS_DIR = DATA_DIR / "results" +CHUNKS_DIR = DATA_DIR / "chunks" + +# Create directories +for dir_path in [ARTIFACTS_DIR, RESULTS_DIR, CHUNKS_DIR]: + dir_path.mkdir(parents=True, exist_ok=True) + + +def safe_filename(artifact_id: str, suffix: str = "") -> str: + """Convert artifact_id to a safe filename using SHA256 hash. + + This prevents path traversal by ensuring user input never directly + becomes part of the filename - only the hash is used. + """ + hash_digest = hashlib.sha256(artifact_id.encode()).hexdigest()[:16] + return f"{hash_digest}{suffix}" + + +def safe_chunk_filename(checksum: str) -> str: + """Convert checksum to a safe filename using SHA256 hash. + + This prevents path traversal by ensuring user input never directly + becomes part of the filename - only the hash is used. + """ + return hashlib.sha256(checksum.encode()).hexdigest()[:16] + + +# In-memory storage for test data +artifacts_db: Dict[str, Dict[str, Any]] = {} +size_analysis_db: Dict[str, Dict[str, Any]] = {} + +# Expected RPC secret (should match docker-compose env var) +RPC_SHARED_SECRET = os.getenv("LAUNCHPAD_RPC_SHARED_SECRET", "test-secret-key-for-e2e") + + +def verify_rpc_signature(authorization: str, body: bytes) -> bool: + """Verify RPC signature from Authorization header.""" + if not authorization or not authorization.startswith("rpcsignature rpc0:"): + return False + + signature = authorization.replace("rpcsignature rpc0:", "") + expected_signature = hmac.new(RPC_SHARED_SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest() + + return hmac.compare_digest(signature, expected_signature) + + +@app.get("/health") +async def health(): + """Health check endpoint.""" + return {"status": "ok", "service": "mock-sentry-api"} + + +@app.head("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/") +@app.get("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/") +async def download_artifact( + org: str, + project: str, + artifact_id: str, + request: Request, + authorization: str = Header(None), +): + """Download artifact file.""" + artifact_path = ARTIFACTS_DIR / safe_filename(artifact_id, ".zip") + + if not artifact_path.exists(): + raise HTTPException(status_code=404, detail="Artifact not found") + + # Handle HEAD request + if request.method == "HEAD": + file_size = artifact_path.stat().st_size + return Response(headers={"Content-Length": str(file_size)}, status_code=200) + + # Handle Range requests for resumable downloads + range_header = request.headers.get("range") + if range_header: + # Parse range header (simplified implementation) + file_size = artifact_path.stat().st_size + range_start = int(range_header.replace("bytes=", "").split("-")[0]) + with open(artifact_path, "rb") as f: + f.seek(range_start) + content = f.read() + range_end = range_start + len(content) - 1 + return Response( + content=content, + status_code=206, + headers={"Content-Range": f"bytes {range_start}-{range_end}/{file_size}"}, + ) + + return FileResponse(artifact_path) + + +@app.put("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/update/") +async def update_artifact( + org: str, + project: str, + artifact_id: str, + request: Request, + authorization: str = Header(None), +): + """Update artifact metadata.""" + body = await request.body() + + # Verify signature + if not verify_rpc_signature(authorization, body): + raise HTTPException(status_code=403, detail="Invalid signature") + + data = json.loads(body) + + # Store update in database + if artifact_id not in artifacts_db: + artifacts_db[artifact_id] = {} + + artifacts_db[artifact_id].update(data) + + # Track which fields were updated + updated_fields = list(data.keys()) + + return {"success": True, "artifactId": artifact_id, "updatedFields": updated_fields} + + +@app.get("/api/0/organizations/{org}/chunk-upload/") +async def get_chunk_options(org: str): + """Get chunk upload configuration.""" + return { + "url": f"/api/0/organizations/{org}/chunk-upload/", + "chunkSize": 8388608, # 8MB + "chunksPerRequest": 64, + "maxFileSize": 2147483648, # 2GB + "maxRequestSize": 33554432, # 32MB + "concurrency": 8, + "hashAlgorithm": "sha1", + "compression": ["gzip"], + "accept": ["*"], + } + + +@app.post("/api/0/organizations/{org}/chunk-upload/") +async def upload_chunk( + org: str, + file: UploadFile, + authorization: str = Header(None), +): + """Upload a file chunk.""" + # Read chunk data + chunk_data = await file.read() + + # Calculate checksum + checksum = hashlib.sha1(chunk_data).hexdigest() + + # Store chunk using safe filename (hash of checksum prevents path injection) + chunk_path = CHUNKS_DIR / safe_chunk_filename(checksum) + chunk_path.write_bytes(chunk_data) + + # Return 200 if successful, 409 if already exists + return JSONResponse({"checksum": checksum}, status_code=200) + + +@app.post("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/assemble-generic/") +async def assemble_file( + org: str, + project: str, + artifact_id: str, + request: Request, + authorization: str = Header(None), +): + """Assemble uploaded chunks into complete file.""" + body = await request.body() + + # Verify signature + if not verify_rpc_signature(authorization, body): + raise HTTPException(status_code=403, detail="Invalid signature") + + data = json.loads(body) + checksum = data["checksum"] + chunks = data["chunks"] + assemble_type = data["assemble_type"] + + # Check which chunks are missing (safe_chunk_filename hashes input to prevent path injection) + missing_chunks = [] + for chunk_checksum in chunks: + chunk_path = CHUNKS_DIR / safe_chunk_filename(chunk_checksum) + if not chunk_path.exists(): + missing_chunks.append(chunk_checksum) + + if missing_chunks: + return {"state": "not_found", "missingChunks": missing_chunks} + + # Assemble the file + file_data = b"" + for chunk_checksum in chunks: + chunk_path = CHUNKS_DIR / safe_chunk_filename(chunk_checksum) + file_data += chunk_path.read_bytes() + + # Verify checksum + actual_checksum = hashlib.sha1(file_data).hexdigest() + if actual_checksum != checksum: + return { + "state": "error", + "missingChunks": [], + "detail": f"Checksum mismatch: expected {checksum}, got {actual_checksum}", + } + + # Store assembled file + if assemble_type == "size_analysis": + result_path = RESULTS_DIR / safe_filename(artifact_id, "_size_analysis.json") + result_path.write_bytes(file_data) + + # Parse and store in database - fail if JSON is invalid + try: + size_analysis_db[artifact_id] = json.loads(file_data.decode("utf-8")) + except json.JSONDecodeError: + return { + "state": "error", + "missingChunks": [], + "detail": "Invalid JSON in size analysis", + } + + elif assemble_type == "installable_app": + app_path = RESULTS_DIR / safe_filename(artifact_id, "_app") + app_path.write_bytes(file_data) + + else: + return { + "state": "error", + "missingChunks": [], + "detail": f"Unknown assemble_type: {assemble_type}", + } + + return {"state": "ok", "missingChunks": []} + + +@app.put("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/size/") +@app.put("/api/0/internal/{org}/{project}/files/preprodartifacts/{artifact_id}/size/{identifier}/") +async def update_size_analysis( + org: str, + project: str, + artifact_id: str, + request: Request, + identifier: Optional[str] = None, + authorization: str = Header(None), +): + """Update size analysis metadata.""" + body = await request.body() + + # Verify signature + if not verify_rpc_signature(authorization, body): + raise HTTPException(status_code=403, detail="Invalid signature") + + data = json.loads(body) + + # Store in database + key = f"{artifact_id}:{identifier}" if identifier else artifact_id + if key not in size_analysis_db: + size_analysis_db[key] = {} + size_analysis_db[key].update(data) + + return {"artifactId": artifact_id} + + +# Test helper endpoints (not part of real Sentry API) + + +@app.post("/test/upload-artifact/{artifact_id}") +async def test_upload_artifact(artifact_id: str, file: UploadFile): + """Test helper: Upload an artifact file for testing.""" + artifact_path = ARTIFACTS_DIR / safe_filename(artifact_id, ".zip") + + with open(artifact_path, "wb") as f: + content = await file.read() + f.write(content) + + return {"artifact_id": artifact_id, "size": len(content)} + + +@app.get("/test/results/{artifact_id}") +async def test_get_results(artifact_id: str): + """Test helper: Get analysis results for an artifact.""" + size_analysis_path = RESULTS_DIR / safe_filename(artifact_id, "_size_analysis.json") + installable_app_path = RESULTS_DIR / safe_filename(artifact_id, "_app") + return { + "artifact_metadata": artifacts_db.get(artifact_id, {}), + "size_analysis": size_analysis_db.get(artifact_id, {}), + "has_size_analysis_file": size_analysis_path.exists(), + "has_installable_app": installable_app_path.exists(), + } + + +@app.get("/test/results/{artifact_id}/size-analysis-raw") +async def test_get_size_analysis_raw(artifact_id: str): + """Test helper: Get raw size analysis JSON.""" + result_path = RESULTS_DIR / safe_filename(artifact_id, "_size_analysis.json") + + if not result_path.exists(): + raise HTTPException(status_code=404, detail="Size analysis not found") + + return JSONResponse(json.loads(result_path.read_text())) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/tests/e2e/test_e2e_flow.py b/tests/e2e/test_e2e_flow.py new file mode 100644 index 00000000..0e7181ae --- /dev/null +++ b/tests/e2e/test_e2e_flow.py @@ -0,0 +1,388 @@ +"""End-to-end tests for Launchpad service. + +Tests the full flow: +1. Upload test artifact to mock API +2. Send Kafka message to trigger processing +3. Wait for Launchpad to process +4. Verify results via mock API +""" + +import json +import os +import time + +from pathlib import Path +from typing import Any, Dict + +import pytest +import requests + +from confluent_kafka import Producer + +# Configuration from environment +KAFKA_BOOTSTRAP_SERVERS = os.getenv("KAFKA_BOOTSTRAP_SERVERS", "kafka:9093") +MOCK_API_URL = os.getenv("MOCK_API_URL", "http://mock-sentry-api:8000") +LAUNCHPAD_URL = os.getenv("LAUNCHPAD_URL", "http://launchpad:2218") +KAFKA_TOPIC = "preprod-artifact-events" + +# Test fixtures +FIXTURES_DIR = Path("/app/fixtures") +IOS_FIXTURE = FIXTURES_DIR / "ios" / "HackerNews.xcarchive.zip" +ANDROID_APK_FIXTURE = FIXTURES_DIR / "android" / "hn.apk" +ANDROID_AAB_FIXTURE = FIXTURES_DIR / "android" / "hn.aab" + + +def wait_for_service(url: str, timeout: int = 60, service_name: str = "service") -> None: + """Wait for a service to be healthy.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + response = requests.get(f"{url}/health", timeout=5) + if response.status_code == 200: + print(f"[OK] {service_name} is healthy") + return + except requests.exceptions.RequestException: + pass + time.sleep(2) + raise TimeoutError(f"{service_name} did not become healthy within {timeout}s") + + +def upload_artifact_to_mock_api(artifact_id: str, file_path: Path) -> None: + """Upload an artifact file to the mock API.""" + with open(file_path, "rb") as f: + files = {"file": (file_path.name, f, "application/zip")} + response = requests.post(f"{MOCK_API_URL}/test/upload-artifact/{artifact_id}", files=files, timeout=30) + response.raise_for_status() + print(f"[OK] Uploaded artifact {artifact_id} ({file_path.name})") + + +def send_kafka_message(artifact_id: str, org: str, project: str, features: list[str]) -> None: + """Send a Kafka message to trigger artifact processing.""" + delivery_error = None + + def delivery_callback(err, msg): + nonlocal delivery_error + if err: + delivery_error = err + + producer = Producer({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS, "client.id": "e2e-test-producer"}) + + message = { + "artifact_id": artifact_id, + "organization_id": org, + "project_id": project, + "requested_features": features, + } + + producer.produce( + KAFKA_TOPIC, + key=artifact_id.encode("utf-8"), + value=json.dumps(message).encode("utf-8"), + callback=delivery_callback, + ) + remaining = producer.flush(timeout=10) + + if delivery_error: + raise RuntimeError(f"Kafka message delivery failed: {delivery_error}") + if remaining > 0: + raise RuntimeError(f"Failed to flush {remaining} Kafka messages") + + print(f"[OK] Sent Kafka message for artifact {artifact_id}") + + +def wait_for_processing(artifact_id: str, timeout: int = 120, check_interval: int = 3) -> Dict[str, Any]: + """Wait for artifact processing to complete and return results.""" + start_time = time.time() + last_status = None + + while time.time() - start_time < timeout: + try: + response = requests.get(f"{MOCK_API_URL}/test/results/{artifact_id}", timeout=10) + response.raise_for_status() + results = response.json() + + # Check if processing is complete + # Processing is complete when both metadata is updated AND size analysis file exists + if results.get("artifact_metadata") and results.get("has_size_analysis_file"): + print(f"[OK] Processing completed for {artifact_id}") + return results + + # Show progress + current_status = json.dumps(results, sort_keys=True) + if current_status != last_status: + print(f" Waiting for processing... (results so far: {results})") + last_status = current_status + + except requests.exceptions.RequestException as e: + print(f" Error checking results: {e}") + + time.sleep(check_interval) + + raise TimeoutError(f"Artifact {artifact_id} was not processed within {timeout}s") + + +def get_size_analysis_raw(artifact_id: str) -> Dict[str, Any]: + """Get the raw size analysis JSON for an artifact.""" + response = requests.get(f"{MOCK_API_URL}/test/results/{artifact_id}/size-analysis-raw", timeout=10) + response.raise_for_status() + return response.json() + + +class TestE2EFlow: + """End-to-end tests for full Launchpad service flow.""" + + @classmethod + def setup_class(cls): + """Wait for all services to be ready before running tests.""" + print("\n=== Waiting for services to be ready ===") + wait_for_service(MOCK_API_URL, service_name="Mock Sentry API") + wait_for_service(LAUNCHPAD_URL, service_name="Launchpad") + print("=== All services ready ===\n") + + def test_ios_xcarchive_full_flow(self): + """Test full flow with iOS .xcarchive.zip file.""" + if not IOS_FIXTURE.exists(): + pytest.skip(f"iOS fixture not found: {IOS_FIXTURE}") + + artifact_id = "test-ios-001" + org = "test-org" + project = "test-ios-project" + + print("\n=== Testing iOS .xcarchive.zip E2E flow ===") + + # Step 1: Upload artifact to mock API + upload_artifact_to_mock_api(artifact_id, IOS_FIXTURE) + + # Step 2: Send Kafka message + send_kafka_message(artifact_id, org, project, ["size_analysis"]) + + # Step 3: Wait for processing + results = wait_for_processing(artifact_id, timeout=180) + + # Step 4: Verify results + print("\n=== Verifying results ===") + + # Check artifact metadata was updated + assert results["artifact_metadata"], "Artifact metadata should be updated" + metadata = results["artifact_metadata"] + + # Verify exact metadata values for HackerNews.xcarchive.zip + assert metadata["app_name"] == "HackerNews" + assert metadata["app_id"] == "com.emergetools.hackernews" + assert metadata["build_version"] == "3.8" + assert metadata["build_number"] == 1 + assert metadata["artifact_type"] == 0 # iOS xcarchive + + # Verify iOS-specific nested info + assert "apple_app_info" in metadata + apple_info = metadata["apple_app_info"] + assert apple_info["is_simulator"] is False + assert apple_info["codesigning_type"] == "development" + assert apple_info["build_date"] == "2025-05-19T16:15:12" + assert apple_info["is_code_signature_valid"] is True + assert apple_info["main_binary_uuid"] == "BEB3C0D6-2518-343D-BB6F-FF5581C544E8" + + # Check size analysis was uploaded + assert results["has_size_analysis_file"], "Size analysis file should be uploaded" + + # Verify size analysis contents with exact values + size_analysis = get_size_analysis_raw(artifact_id) + assert size_analysis["download_size"] == 6502319 + + # Verify treemap structure (root size is install size, different from download_size) + treemap = size_analysis["treemap"] + assert treemap["platform"] == "ios" + assert treemap["root"]["name"] == "HackerNews" + assert treemap["root"]["size"] == 9728000 # Install size, larger than download_size + assert treemap["root"]["is_dir"] is True + assert len(treemap["root"]["children"]) > 0 + + # Verify expected insight categories and their structure + insights = size_analysis["insights"] + assert "duplicate_files" in insights + assert insights["duplicate_files"]["total_savings"] > 0 + assert len(insights["duplicate_files"]["groups"]) > 0 + + assert "image_optimization" in insights + assert insights["image_optimization"]["total_savings"] > 0 + assert len(insights["image_optimization"]["optimizable_files"]) > 0 + + assert "main_binary_exported_symbols" in insights + assert insights["main_binary_exported_symbols"]["total_savings"] > 0 + + print("[OK] iOS E2E test passed!") + print(f" - Download size: {size_analysis['download_size']} bytes") + print(f" - Treemap root size: {treemap['root']['size']} bytes") + print(f" - Insight categories: {list(insights.keys())}") + + def test_android_apk_full_flow(self): + """Test full flow with Android .apk file.""" + if not ANDROID_APK_FIXTURE.exists(): + pytest.skip(f"Android APK fixture not found: {ANDROID_APK_FIXTURE}") + + artifact_id = "test-android-apk-001" + org = "test-org" + project = "test-android-project" + + print("\n=== Testing Android .apk E2E flow ===") + + # Step 1: Upload artifact to mock API + upload_artifact_to_mock_api(artifact_id, ANDROID_APK_FIXTURE) + + # Step 2: Send Kafka message + send_kafka_message(artifact_id, org, project, ["size_analysis"]) + + # Step 3: Wait for processing + results = wait_for_processing(artifact_id, timeout=180) + + # Step 4: Verify results + print("\n=== Verifying results ===") + + # Check artifact metadata was updated + assert results["artifact_metadata"], "Artifact metadata should be updated" + metadata = results["artifact_metadata"] + + # Verify exact metadata values for hn.apk + assert metadata["app_name"] == "Hacker News" + assert metadata["app_id"] == "com.emergetools.hackernews" + assert metadata["artifact_type"] == 2 # Android APK + + # Verify Android-specific nested info + assert "android_app_info" in metadata + android_info = metadata["android_app_info"] + assert android_info["has_proguard_mapping"] is False + + # Check size analysis was uploaded + assert results["has_size_analysis_file"], "Size analysis file should be uploaded" + + # Verify size analysis contents with exact values + size_analysis = get_size_analysis_raw(artifact_id) + assert size_analysis["download_size"] == 3670839 + + # Verify treemap structure and root size + treemap = size_analysis["treemap"] + assert treemap["platform"] == "android" + assert treemap["root"]["name"] == "Hacker News" + assert treemap["root"]["size"] == 7886041 + assert treemap["root"]["is_dir"] is True + assert len(treemap["root"]["children"]) == 14 + + # Verify expected insight categories and their structure + insights = size_analysis["insights"] + assert "duplicate_files" in insights + assert insights["duplicate_files"]["total_savings"] == 51709 + assert len(insights["duplicate_files"]["groups"]) > 0 + + assert "multiple_native_library_archs" in insights + assert insights["multiple_native_library_archs"]["total_savings"] == 1891208 + + print("[OK] Android APK E2E test passed!") + print(f" - Download size: {size_analysis['download_size']} bytes") + print(f" - Treemap root size: {treemap['root']['size']} bytes") + print(f" - Insight categories: {list(insights.keys())}") + + def test_android_aab_full_flow(self): + """Test full flow with Android .aab file.""" + if not ANDROID_AAB_FIXTURE.exists(): + pytest.skip(f"Android AAB fixture not found: {ANDROID_AAB_FIXTURE}") + + artifact_id = "test-android-aab-001" + org = "test-org" + project = "test-android-project" + + print("\n=== Testing Android .aab E2E flow ===") + + # Step 1: Upload artifact to mock API + upload_artifact_to_mock_api(artifact_id, ANDROID_AAB_FIXTURE) + + # Step 2: Send Kafka message + send_kafka_message(artifact_id, org, project, ["size_analysis"]) + + # Step 3: Wait for processing + results = wait_for_processing(artifact_id, timeout=180) + + # Step 4: Verify results + print("\n=== Verifying results ===") + + # Check artifact metadata was updated + assert results["artifact_metadata"], "Artifact metadata should be updated" + metadata = results["artifact_metadata"] + + # Verify exact metadata values for hn.aab + assert metadata["app_name"] == "Hacker News" + assert metadata["app_id"] == "com.emergetools.hackernews" + assert metadata["build_version"] == "1.0.2" + assert metadata["build_number"] == 13 + assert metadata["artifact_type"] == 1 # Android AAB + + # Verify Android-specific nested info + assert "android_app_info" in metadata + android_info = metadata["android_app_info"] + assert android_info["has_proguard_mapping"] is True + + # Check size analysis was uploaded + assert results["has_size_analysis_file"], "Size analysis file should be uploaded" + + # Verify size analysis contents + size_analysis = get_size_analysis_raw(artifact_id) + # AAB download size varies based on extracted APKs - verify it's positive + assert size_analysis["download_size"] > 0 + + # Verify treemap structure and root size + treemap = size_analysis["treemap"] + assert treemap["platform"] == "android" + assert treemap["root"]["name"] == "Hacker News" + assert treemap["root"]["size"] == 5932249 + assert treemap["root"]["is_dir"] is True + assert len(treemap["root"]["children"]) == 14 + + # Verify expected insight categories for Android AAB + insights = size_analysis["insights"] + assert "duplicate_files" in insights + assert insights["duplicate_files"]["total_savings"] >= 0 + assert "groups" in insights["duplicate_files"] + + print("[OK] Android AAB E2E test passed!") + print(f" - Download size: {size_analysis['download_size']} bytes") + print(f" - Treemap root size: {treemap['root']['size']} bytes") + print(f" - Insight categories: {list(insights.keys())}") + + def test_launchpad_health_check(self): + """Verify Launchpad service is healthy.""" + response = requests.get(f"{LAUNCHPAD_URL}/health", timeout=10) + assert response.status_code == 200 + data = response.json() + assert data["service"] == "launchpad" + assert data["status"] == "ok" + print("[OK] Launchpad health check passed") + + def test_nonexistent_artifact_error_handling(self): + """Test that processing a non-existent artifact is handled gracefully.""" + artifact_id = "test-nonexistent-artifact" + org = "test-org" + project = "test-project" + + print("\n=== Testing non-existent artifact error handling ===") + + # Don't upload any artifact - just send Kafka message for non-existent one + send_kafka_message(artifact_id, org, project, ["size_analysis"]) + + # Wait a bit for processing attempt + time.sleep(10) + + # Check results - should have error metadata, no size analysis + response = requests.get(f"{MOCK_API_URL}/test/results/{artifact_id}", timeout=10) + response.raise_for_status() + results = response.json() + + # Verify no size analysis was uploaded (artifact download should have failed) + assert not results["has_size_analysis_file"], "Should not have size analysis for non-existent artifact" + + # The artifact metadata may have error information + metadata = results.get("artifact_metadata", {}) + # If error was recorded, it should indicate a download/processing failure + if metadata: + # Check if error fields are present (depends on implementation) + print(f" Metadata received: {metadata}") + + print("[OK] Non-existent artifact handled correctly (no size analysis produced)")