From 2f1668059265593ad7e68576a8e921cd0dc8c967 Mon Sep 17 00:00:00 2001 From: hironow Date: Thu, 20 Nov 2025 03:47:19 +0900 Subject: [PATCH 01/12] rebase --- justfile | 2 +- tests/test_a2a_inspector.py | 37 +++++++---------- tests/test_bigtable_emulator.py | 39 +++++++----------- tests/test_elasticsearch_emulator.py | 39 +++++++----------- tests/test_firebase_emulator.py | 35 +++++++--------- tests/test_mlflow_emulator.py | 35 +++++++--------- tests/test_neo4j_emulator.py | 35 +++++++--------- tests/test_postgres18_emulator.py | 39 +++++++----------- tests/test_qdrant_emulator.py | 35 +++++++--------- tests/test_spanner_emulator.py | 39 +++++++----------- tests/utils/helpers.py | 60 ++++++++++++++++++++++++++++ tests/utils/postgres.py | 2 - tests/utils/result.py | 34 ++++++++++++++++ 13 files changed, 227 insertions(+), 204 deletions(-) create mode 100644 tests/utils/helpers.py create mode 100644 tests/utils/result.py diff --git a/justfile b/justfile index ec5d78c..6e6d718 100644 --- a/justfile +++ b/justfile @@ -90,7 +90,7 @@ lint path='tests/' opts='--fix': @echo '๐Ÿ” Linting code with ruff...' uv run ruff check '{{path}}' '{{opts}}' @echo 'Semgrep linting...' - uv run semgrep --config .semgrep/ + uv run semgrep --config .semgrep/ --error @echo 'โœ… Linting finished.' diff --git a/tests/test_a2a_inspector.py b/tests/test_a2a_inspector.py index 40bd139..454e077 100644 --- a/tests/test_a2a_inspector.py +++ b/tests/test_a2a_inspector.py @@ -1,6 +1,5 @@ import pytest import docker -import asyncio @pytest.mark.asyncio @@ -9,26 +8,20 @@ async def test_a2a_inspector_container_starts(http_client): client = docker.from_env() # Check if a2a-inspector container exists and is running - try: - container = client.containers.get("a2a-inspector") - assert container.status == "running", ( - f"A2A Inspector container is not running, status: {container.status}" - ) - except docker.errors.NotFound: - pytest.fail( - "A2A Inspector container 'a2a-inspector' not found. Run 'docker compose up a2a-inspector' first." - ) + from tests.utils.helpers import get_container, wait_for_http + from tests.utils.result import Error, Ok + + match get_container(client, "a2a-inspector"): + case Ok(container): + assert container.status == "running", ( + f"A2A Inspector container is not running, status: {container.status}" + ) + case Error(msg): + pytest.fail(msg) # Check if A2A Inspector HTTP endpoint is accessible - max_retries = 30 - for i in range(max_retries): - try: - async with http_client.get("http://localhost:8081") as response: - if response.status == 200: - break - except Exception: - if i == max_retries - 1: - pytest.fail( - "A2A Inspector HTTP endpoint is not accessible at localhost:8081" - ) - await asyncio.sleep(1) + match await wait_for_http(http_client, "http://localhost:8081"): + case Ok(_): + pass + case Error(msg): + pytest.fail(msg) diff --git a/tests/test_bigtable_emulator.py b/tests/test_bigtable_emulator.py index 68215d1..1bc10d2 100644 --- a/tests/test_bigtable_emulator.py +++ b/tests/test_bigtable_emulator.py @@ -1,37 +1,26 @@ import pytest import docker -import time -import socket def test_bigtable_container_starts(): """Test that the Bigtable emulator container starts and is healthy.""" client = docker.from_env() + from tests.utils.helpers import get_container, wait_for_tcp + from tests.utils.result import Error, Ok + # Check if bigtable container exists and is running - try: - container = client.containers.get("bigtable-emulator") - assert container.status == "running", ( - f"Bigtable container is not running, status: {container.status}" - ) - except docker.errors.NotFound: - pytest.fail( - "Bigtable container 'bigtable-emulator' not found. Run 'docker compose up bigtable-emulator' first." - ) + match get_container(client, "bigtable-emulator"): + case Ok(container): + assert container.status == "running", ( + f"Bigtable container is not running, status: {container.status}" + ) + case Error(msg): + pytest.fail(msg) # Check if Bigtable gRPC endpoint is accessible - max_retries = 30 - for i in range(max_retries): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(1) - try: - result = sock.connect_ex(("localhost", 8086)) - sock.close() - if result == 0: - break - except Exception: + match wait_for_tcp("localhost", 8086): + case Ok(_): pass - - if i == max_retries - 1: - pytest.fail("Bigtable gRPC endpoint is not accessible at localhost:8086") - time.sleep(1) + case Error(msg): + pytest.fail(msg) diff --git a/tests/test_elasticsearch_emulator.py b/tests/test_elasticsearch_emulator.py index 06eb0c5..167eb2a 100644 --- a/tests/test_elasticsearch_emulator.py +++ b/tests/test_elasticsearch_emulator.py @@ -1,6 +1,5 @@ import pytest import docker -import asyncio @pytest.mark.asyncio @@ -8,32 +7,24 @@ async def test_elasticsearch_container_starts(http_client): """Test that the Elasticsearch container starts and is healthy.""" client = docker.from_env() + from tests.utils.helpers import get_container, wait_for_http + from tests.utils.result import Error, Ok + # Check if elasticsearch container exists and is running - try: - container = client.containers.get("elasticsearch-emulator") - assert container.status == "running", ( - f"Elasticsearch container is not running, status: {container.status}" - ) - except docker.errors.NotFound: - pytest.fail( - "Elasticsearch container 'elasticsearch-emulator' not found. Run 'docker compose up elasticsearch' first." - ) + match get_container(client, "elasticsearch-emulator"): + case Ok(container): + assert container.status == "running", ( + f"Elasticsearch container is not running, status: {container.status}" + ) + case Error(msg): + pytest.fail(msg) # Check if Elasticsearch REST API is accessible - max_retries = 30 - for i in range(max_retries): - try: - async with http_client.get( - "http://localhost:9200/_cluster/health" - ) as response: - if response.status == 200: - break - except Exception: - if i == max_retries - 1: - pytest.fail( - "Elasticsearch REST API is not accessible at localhost:9200" - ) - await asyncio.sleep(1) + match await wait_for_http(http_client, "http://localhost:9200/_cluster/health"): + case Ok(_): + pass + case Error(msg): + pytest.fail(msg) # Verify Elasticsearch is ready async with http_client.get("http://localhost:9200/_cluster/health") as response: diff --git a/tests/test_firebase_emulator.py b/tests/test_firebase_emulator.py index 7e668b5..08e1e08 100644 --- a/tests/test_firebase_emulator.py +++ b/tests/test_firebase_emulator.py @@ -1,6 +1,5 @@ import pytest import docker -import asyncio @pytest.mark.asyncio @@ -8,25 +7,21 @@ async def test_firebase_container_starts(http_client): """Test that the Firebase container starts and is healthy.""" client = docker.from_env() + from tests.utils.helpers import get_container, wait_for_http + from tests.utils.result import Error, Ok + # Check if firebase container exists and is running - try: - container = client.containers.get("firebase-emulator") - assert container.status == "running", ( - f"Firebase container is not running, status: {container.status}" - ) - except docker.errors.NotFound: - pytest.fail( - "Firebase container 'firebase-emulator' not found. Run 'docker compose up firebase-emulator' first." - ) + match get_container(client, "firebase-emulator"): + case Ok(container): + assert container.status == "running", ( + f"Firebase container is not running, status: {container.status}" + ) + case Error(msg): + pytest.fail(msg) # Check if Firebase UI is accessible - max_retries = 30 - for i in range(max_retries): - try: - async with http_client.get("http://localhost:4000") as response: - if response.status == 200: - break - except Exception: - if i == max_retries - 1: - pytest.fail("Firebase UI is not accessible at localhost:4000") - await asyncio.sleep(1) + match await wait_for_http(http_client, "http://localhost:4000"): + case Ok(_): + pass + case Error(msg): + pytest.fail(msg) diff --git a/tests/test_mlflow_emulator.py b/tests/test_mlflow_emulator.py index bec8500..03265ca 100644 --- a/tests/test_mlflow_emulator.py +++ b/tests/test_mlflow_emulator.py @@ -1,6 +1,5 @@ import pytest import docker -import asyncio @pytest.mark.asyncio @@ -8,32 +7,28 @@ async def test_mlflow_container_starts(http_client): """MLflow container should be running and UI reachable.""" client = docker.from_env() + from tests.utils.helpers import get_container, wait_for_http + from tests.utils.result import Error, Ok + # Check if mlflow container exists and is running - try: - container = client.containers.get("mlflow-server") - assert container.status == "running", ( - f"MLflow container is not running, status: {container.status}" - ) - except docker.errors.NotFound: - pytest.fail( - "MLflow container 'mlflow-server' not found. Run 'docker compose up mlflow' first." - ) + match get_container(client, "mlflow-server"): + case Ok(container): + assert container.status == "running", ( + f"MLflow container is not running, status: {container.status}" + ) + case Error(msg): + pytest.fail(msg) # Check if MLflow UI is accessible import os port = os.environ.get("MLFLOW_PORT", "5252") base = f"http://localhost:{port}/" - max_retries = 30 - for i in range(max_retries): - try: - async with http_client.get(base) as response: - if response.status in (200, 302): - break - except Exception: - if i == max_retries - 1: - pytest.fail(f"MLflow UI is not accessible at {base}") - await asyncio.sleep(1) + match await wait_for_http(http_client, base): + case Ok(_): + pass + case Error(msg): + pytest.fail(msg) # Final verification async with http_client.get(base) as response: diff --git a/tests/test_neo4j_emulator.py b/tests/test_neo4j_emulator.py index 4e714e2..f6f3637 100644 --- a/tests/test_neo4j_emulator.py +++ b/tests/test_neo4j_emulator.py @@ -1,6 +1,5 @@ import pytest import docker -import asyncio @pytest.mark.asyncio @@ -8,25 +7,21 @@ async def test_neo4j_container_starts(http_client): """Test that the Neo4j container starts and is healthy.""" client = docker.from_env() + from tests.utils.helpers import get_container, wait_for_http + from tests.utils.result import Error, Ok + # Check if neo4j container exists and is running - try: - container = client.containers.get("neo4j-emulator") - assert container.status == "running", ( - f"Neo4j container is not running, status: {container.status}" - ) - except docker.errors.NotFound: - pytest.fail( - "Neo4j container 'neo4j-emulator' not found. Run 'docker compose up neo4j' first." - ) + match get_container(client, "neo4j-emulator"): + case Ok(container): + assert container.status == "running", ( + f"Neo4j container is not running, status: {container.status}" + ) + case Error(msg): + pytest.fail(msg) # Check if Neo4j HTTP endpoint is accessible - max_retries = 30 - for i in range(max_retries): - try: - async with http_client.get("http://localhost:7474") as response: - if response.status == 200: - break - except Exception: - if i == max_retries - 1: - pytest.fail("Neo4j HTTP endpoint is not accessible at localhost:7474") - await asyncio.sleep(1) + match await wait_for_http(http_client, "http://localhost:7474"): + case Ok(_): + pass + case Error(msg): + pytest.fail(msg) diff --git a/tests/test_postgres18_emulator.py b/tests/test_postgres18_emulator.py index 5b71427..c5ce24b 100644 --- a/tests/test_postgres18_emulator.py +++ b/tests/test_postgres18_emulator.py @@ -1,6 +1,4 @@ import os -import socket -import time import docker import pytest @@ -9,31 +7,22 @@ def test_postgres18_container_starts() -> None: client = docker.from_env() + from tests.utils.helpers import get_container, wait_for_tcp + from tests.utils.result import Error, Ok + # Check if postgres container exists and is running - try: - container = client.containers.get("postgres-18") - assert container.status == "running", ( - f"PostgreSQL container is not running, status: {container.status}" - ) - except docker.errors.NotFound: - pytest.fail( - "PostgreSQL container 'postgres-18' not found. Run 'docker compose up postgres' first." - ) + match get_container(client, "postgres-18"): + case Ok(container): + assert container.status == "running", ( + f"PostgreSQL container is not running, status: {container.status}" + ) + case Error(msg): + pytest.fail(msg) # Check if PostgreSQL port is accessible - max_retries = 30 port = int(os.getenv("POSTGRES_PORT", "5433")) - for i in range(max_retries): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(1) - try: - result = sock.connect_ex(("localhost", port)) - sock.close() - if result == 0: - break - except Exception: + match wait_for_tcp("localhost", port): + case Ok(_): pass - - if i == max_retries - 1: - pytest.fail(f"PostgreSQL endpoint is not accessible at localhost:{port}") - time.sleep(1) + case Error(msg): + pytest.fail(msg) diff --git a/tests/test_qdrant_emulator.py b/tests/test_qdrant_emulator.py index 592b394..73f4112 100644 --- a/tests/test_qdrant_emulator.py +++ b/tests/test_qdrant_emulator.py @@ -1,6 +1,5 @@ import pytest import docker -import asyncio @pytest.mark.asyncio @@ -8,28 +7,24 @@ async def test_qdrant_container_starts(http_client): """Test that the Qdrant container starts and is healthy.""" client = docker.from_env() + from tests.utils.helpers import get_container, wait_for_http + from tests.utils.result import Error, Ok + # Check if qdrant container exists and is running - try: - container = client.containers.get("qdrant-emulator") - assert container.status == "running", ( - f"Qdrant container is not running, status: {container.status}" - ) - except docker.errors.NotFound: - pytest.fail( - "Qdrant container 'qdrant-emulator' not found. Run 'docker compose up qdrant' first." - ) + match get_container(client, "qdrant-emulator"): + case Ok(container): + assert container.status == "running", ( + f"Qdrant container is not running, status: {container.status}" + ) + case Error(msg): + pytest.fail(msg) # Check if Qdrant REST API is accessible - max_retries = 30 - for i in range(max_retries): - try: - async with http_client.get("http://localhost:6333/healthz") as response: - if response.status == 200: - break - except Exception: - if i == max_retries - 1: - pytest.fail("Qdrant REST API is not accessible at localhost:6333") - await asyncio.sleep(1) + match await wait_for_http(http_client, "http://localhost:6333/healthz"): + case Ok(_): + pass + case Error(msg): + pytest.fail(msg) # Verify Qdrant is ready async with http_client.get("http://localhost:6333/readyz") as response: diff --git a/tests/test_spanner_emulator.py b/tests/test_spanner_emulator.py index 9db945b..e833e2a 100644 --- a/tests/test_spanner_emulator.py +++ b/tests/test_spanner_emulator.py @@ -1,37 +1,26 @@ import pytest import docker -import time -import socket def test_spanner_container_starts(): """Test that the Spanner container starts and is healthy.""" client = docker.from_env() + from tests.utils.helpers import get_container, wait_for_tcp + from tests.utils.result import Error, Ok + # Check if spanner container exists and is running - try: - container = client.containers.get("spanner-emulator") - assert container.status == "running", ( - f"Spanner container is not running, status: {container.status}" - ) - except docker.errors.NotFound: - pytest.fail( - "Spanner container 'spanner-emulator' not found. Run 'docker compose up spanner-emulator' first." - ) + match get_container(client, "spanner-emulator"): + case Ok(container): + assert container.status == "running", ( + f"Spanner container is not running, status: {container.status}" + ) + case Error(msg): + pytest.fail(msg) # Check if Spanner gRPC endpoint is accessible - max_retries = 30 - for i in range(max_retries): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(1) - try: - result = sock.connect_ex(("localhost", 9010)) - sock.close() - if result == 0: - break - except Exception: + match wait_for_tcp("localhost", 9010): + case Ok(_): pass - - if i == max_retries - 1: - pytest.fail("Spanner gRPC endpoint is not accessible at localhost:9010") - time.sleep(1) + case Error(msg): + pytest.fail(msg) diff --git a/tests/utils/helpers.py b/tests/utils/helpers.py new file mode 100644 index 0000000..b137628 --- /dev/null +++ b/tests/utils/helpers.py @@ -0,0 +1,60 @@ +"""Helper functions for tests using the Result type.""" + +import asyncio +import socket +import time + + +import docker +from aiohttp import ClientSession +from docker.errors import NotFound + +from docker.models.containers import Container + +from tests.utils.result import Error, Ok, Result + + +def get_container(client: docker.DockerClient, name: str) -> Result[Container, str]: + """Get a container by name.""" + try: + container = client.containers.get(name) + return Ok(container) + except NotFound: + return Error(f"Container '{name}' not found.") + except Exception as e: + return Error(f"Error getting container '{name}': {e}") + + +async def wait_for_http( + client: ClientSession, url: str, retries: int = 30 +) -> Result[bool, str]: + """Wait for an HTTP endpoint to be available.""" + last_error = "" + for _ in range(retries): + try: + async with client.get(url) as response: + if response.status == 200: + return Ok(True) + last_error = f"Status {response.status}" + except Exception as e: + last_error = str(e) + await asyncio.sleep(1) + return Error(f"HTTP endpoint {url} not accessible: {last_error}") + + +def wait_for_tcp(host: str, port: int, retries: int = 30) -> Result[bool, str]: + """Wait for a TCP port to be open (synchronous).""" + last_error = "" + for _ in range(retries): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1) + try: + result = sock.connect_ex((host, port)) + sock.close() + if result == 0: + return Ok(True) + last_error = f"Connect result: {result}" + except Exception as e: + last_error = str(e) + time.sleep(1) + return Error(f"TCP endpoint {host}:{port} not accessible: {last_error}") diff --git a/tests/utils/postgres.py b/tests/utils/postgres.py index 4704058..2c33634 100644 --- a/tests/utils/postgres.py +++ b/tests/utils/postgres.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os import asyncpg diff --git a/tests/utils/result.py b/tests/utils/result.py new file mode 100644 index 0000000..0d7cbfa --- /dev/null +++ b/tests/utils/result.py @@ -0,0 +1,34 @@ +"""Module containing definitions for the result type. + +It works like Rust's Result type. +""" + +from dataclasses import dataclass +from typing import Generic, TypeVar + +_T = TypeVar("_T") +_E = TypeVar("_E") + + +@dataclass(frozen=True) +class Ok(Generic[_T]): # noqa: UP046 + value: _T + + def __repr__(self): + return f"Ok({self.value!r})" + + +@dataclass(frozen=True) +class Error(Generic[_E]): # noqa: UP046 + """Failure representation.""" + + value: _E + + def __repr__(self): + return f"Error({self.value!r})" + + +# type Result<'Success,'Failure> = +# | Ok of 'Success +# | Error of 'Failure +Result = Ok[_T] | Error[_E] From 194f732b7fdf404605c3f446f09f7d901417ac7b Mon Sep 17 00:00:00 2001 From: hironow Date: Thu, 20 Nov 2025 10:48:54 +0900 Subject: [PATCH 02/12] fix ci2 --- .github/workflows/test-emulators.yaml | 4 +++- elasticsearch-cli/main.go | 2 +- scripts/wait-for-services.sh | 23 ++++++++++++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-emulators.yaml b/.github/workflows/test-emulators.yaml index 01bfc3d..2299b67 100644 --- a/.github/workflows/test-emulators.yaml +++ b/.github/workflows/test-emulators.yaml @@ -52,7 +52,9 @@ jobs: run: bash scripts/run-tests-fast.sh - name: Run e2e tests - run: bash scripts/run-tests-e2e.sh | tee e2e.log + run: | + set -o pipefail + bash scripts/run-tests-e2e.sh | tee e2e.log - name: Summarize skipped e2e tests if: always() diff --git a/elasticsearch-cli/main.go b/elasticsearch-cli/main.go index 9fda3ee..1811a50 100644 --- a/elasticsearch-cli/main.go +++ b/elasticsearch-cli/main.go @@ -33,7 +33,7 @@ func init() { } client = &http.Client{ - Timeout: 30 * time.Second, + Timeout: 60 * time.Second, } } diff --git a/scripts/wait-for-services.sh b/scripts/wait-for-services.sh index 1a374d6..b778fa2 100644 --- a/scripts/wait-for-services.sh +++ b/scripts/wait-for-services.sh @@ -119,7 +119,28 @@ wait_postgres() { } wait_http "Firebase UI" "http://localhost:4000" "$DEFAULT_WAIT" -wait_http "Elasticsearch" "http://localhost:9200/_cluster/health" "$DEFAULT_WAIT" +wait_elasticsearch() { + local name="$1"; shift + local url="$1"; shift + local max="${1:-$DEFAULT_WAIT}" + echo "- Waiting for ${name} at ${url}" + for _ in $(seq 1 "$max"); do + local response + response=$(curl -s "$url" || true) + if [[ "$response" == *"\"status\":\"green\""* ]] || [[ "$response" == *"\"status\":\"yellow\""* ]]; then + echo " ${name} is ready (status: green/yellow)" + return 0 + fi + sleep 2 + done + echo " ERROR: ${name} not ready in time" >&2 + echo " Last response: $response" >&2 + return 1 +} + +wait_http "Firebase UI" "http://localhost:4000" "$DEFAULT_WAIT" +wait_elasticsearch "Elasticsearch" "http://localhost:9200/_cluster/health" "$DEFAULT_WAIT" + wait_http "Qdrant" "http://localhost:6333/healthz" "$DEFAULT_WAIT" wait_http "Neo4j HTTP" "http://localhost:7474" "$DEFAULT_WAIT" wait_http "A2A Inspector" "http://localhost:8081" "$A2A_WAIT" From a0f60d41fc81df31dd675811d3307798a4cb5357 Mon Sep 17 00:00:00 2001 From: hironow Date: Thu, 20 Nov 2025 14:31:53 +0900 Subject: [PATCH 03/12] fix wait for services --- .claude/settings.local.json | 13 +++++++++++++ justfile | 14 +++++++++++--- scripts/wait-for-services.sh | 1 - 3 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2d4cd58 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(docker logs:*)", + "Bash(docker compose stop:*)", + "Bash(docker compose rm:*)", + "Bash(docker volume:*)", + "Bash(docker compose:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/justfile b/justfile index 6e6d718..49e257a 100644 --- a/justfile +++ b/justfile @@ -41,14 +41,22 @@ up nobuild='no': fi # Wait for services -wait default='60' a2a='180': - @bash scripts/wait-for-services.sh --default {{default}} --a2a {{a2a}} +wait default='60' a2a='180' postgres='120': + @bash scripts/wait-for-services.sh --default {{default}} --a2a {{a2a}} --postgres {{postgres}} + +# Clean up volumes (use with caution - deletes all data) +clean-volumes: + @echo 'โš ๏ธ Cleaning up Docker volumes...' + docker compose down -v || true + @echo 'โœ… Volumes cleaned.' # One-shot: prebuild -> up -> wait start: + @echo '๐Ÿงน Cleaning up old volumes...' + @docker compose down -v || true @bash scripts/prebuild-images.sh a2a-inspector firebase-emulator postgres @bash scripts/start-services.sh - @bash scripts/wait-for-services.sh --default 60 --a2a 180 + @bash scripts/wait-for-services.sh --default 60 --a2a 180 --postgres 120 # Stop emulators (with Firebase export) stop: diff --git a/scripts/wait-for-services.sh b/scripts/wait-for-services.sh index b778fa2..a73bca6 100644 --- a/scripts/wait-for-services.sh +++ b/scripts/wait-for-services.sh @@ -118,7 +118,6 @@ wait_postgres() { return 1 } -wait_http "Firebase UI" "http://localhost:4000" "$DEFAULT_WAIT" wait_elasticsearch() { local name="$1"; shift local url="$1"; shift From 2da39b2586c2aca6cd59861df3c9dc4a202b780e Mon Sep 17 00:00:00 2001 From: hironow Date: Thu, 20 Nov 2025 17:01:51 +0900 Subject: [PATCH 04/12] fix timeout & sleep --- .github/workflows/test-emulators.yaml | 2 ++ justfile | 4 ++-- pyproject.toml | 5 +++++ scripts/wait-for-services.sh | 18 +++++++++--------- uv.lock | 14 ++++++++++++++ 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test-emulators.yaml b/.github/workflows/test-emulators.yaml index 2299b67..53ff807 100644 --- a/.github/workflows/test-emulators.yaml +++ b/.github/workflows/test-emulators.yaml @@ -49,9 +49,11 @@ jobs: run: bash scripts/wait-for-services.sh --default 90 --a2a 180 --postgres 150 - name: Run unit/integration tests (non-e2e) + timeout-minutes: 5 run: bash scripts/run-tests-fast.sh - name: Run e2e tests + timeout-minutes: 8 run: | set -o pipefail bash scripts/run-tests-e2e.sh | tee e2e.log diff --git a/justfile b/justfile index 49e257a..1e5c721 100644 --- a/justfile +++ b/justfile @@ -41,7 +41,7 @@ up nobuild='no': fi # Wait for services -wait default='60' a2a='180' postgres='120': +wait default='30' a2a='60' postgres='60': @bash scripts/wait-for-services.sh --default {{default}} --a2a {{a2a}} --postgres {{postgres}} # Clean up volumes (use with caution - deletes all data) @@ -56,7 +56,7 @@ start: @docker compose down -v || true @bash scripts/prebuild-images.sh a2a-inspector firebase-emulator postgres @bash scripts/start-services.sh - @bash scripts/wait-for-services.sh --default 60 --a2a 180 --postgres 120 + @bash scripts/wait-for-services.sh --default 30 --a2a 60 --postgres 60 # Stop emulators (with Firebase export) stop: diff --git a/pyproject.toml b/pyproject.toml index fdf6f90..e519580 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dev = [ "docker>=7.1.0", "pytest>=8.4.1", "pytest-asyncio>=0.25.0", + "pytest-timeout>=2.3.1", "ruff>=0.12.4", ] @@ -29,3 +30,7 @@ dev = [ markers = [ "e2e: end-to-end tests that require Docker and running emulators", ] +# Default timeout for all tests (can be overridden per test) +timeout = 300 +# Timeout method: 'thread' is more compatible with async tests +timeout_method = "thread" diff --git a/scripts/wait-for-services.sh b/scripts/wait-for-services.sh index a73bca6..48513ff 100644 --- a/scripts/wait-for-services.sh +++ b/scripts/wait-for-services.sh @@ -4,18 +4,18 @@ set -euo pipefail # Idempotent waiter for emulator services. # Usage: bash scripts/wait-for-services.sh [--default ] [--a2a ] [--postgres ] -DEFAULT_WAIT=60 -A2A_WAIT=180 +DEFAULT_WAIT=30 +A2A_WAIT=60 POSTGRES_WAIT="" while [[ $# -gt 0 ]]; do case "$1" in --default) - DEFAULT_WAIT="${2:-60}"; shift 2 ;; + DEFAULT_WAIT="${2:-30}"; shift 2 ;; --a2a) - A2A_WAIT="${2:-180}"; shift 2 ;; + A2A_WAIT="${2:-60}"; shift 2 ;; --postgres) - POSTGRES_WAIT="${2:-120}"; shift 2 ;; + POSTGRES_WAIT="${2:-60}"; shift 2 ;; *) echo "Unknown argument: $1" >&2; exit 2 ;; esac @@ -38,7 +38,7 @@ wait_http() { echo " ${name} is ready (HTTP ${code})" return 0 fi - sleep 2 + sleep 1 done echo " ERROR: ${name} not ready in time" >&2 return 1 @@ -55,7 +55,7 @@ wait_tcp() { echo " ${name} is ready" return 0 fi - sleep 2 + sleep 1 done echo " ERROR: ${name} not ready in time" >&2 return 1 @@ -105,7 +105,7 @@ wait_postgres() { ;; esac - sleep 2 + sleep 1 done # Debug: show final state @@ -130,7 +130,7 @@ wait_elasticsearch() { echo " ${name} is ready (status: green/yellow)" return 0 fi - sleep 2 + sleep 1 done echo " ERROR: ${name} not ready in time" >&2 echo " Last response: $response" >&2 diff --git a/uv.lock b/uv.lock index 59d7cd2..6298fe8 100644 --- a/uv.lock +++ b/uv.lock @@ -604,6 +604,7 @@ dev = [ { name = "docker" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-timeout" }, { name = "ruff" }, ] @@ -626,6 +627,7 @@ dev = [ { name = "docker", specifier = ">=7.1.0" }, { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-asyncio", specifier = ">=0.25.0" }, + { name = "pytest-timeout", specifier = ">=2.3.1" }, { name = "ruff", specifier = ">=0.12.4" }, ] @@ -2413,6 +2415,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From bb1290d2f562886e42c71080f534d22c4d53e5d1 Mon Sep 17 00:00:00 2001 From: hironow Date: Thu, 20 Nov 2025 17:06:49 +0900 Subject: [PATCH 05/12] fix fail on shell --- .github/workflows/test-emulators.yaml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-emulators.yaml b/.github/workflows/test-emulators.yaml index 53ff807..928741f 100644 --- a/.github/workflows/test-emulators.yaml +++ b/.github/workflows/test-emulators.yaml @@ -37,7 +37,14 @@ jobs: uv sync --all-extras --frozen - name: Clean up any existing Docker volumes - run: docker compose down -v || true + run: | + # Only clean up if services are already running + if docker compose ps -q 2>/dev/null | grep -q .; then + echo "Cleaning up existing services and volumes..." + docker compose down -v + else + echo "No existing services to clean up." + fi - name: Pre-build selected images (incl. Postgres 18) run: bash scripts/prebuild-images.sh a2a-inspector firebase-emulator postgres @@ -54,9 +61,7 @@ jobs: - name: Run e2e tests timeout-minutes: 8 - run: | - set -o pipefail - bash scripts/run-tests-e2e.sh | tee e2e.log + run: bash scripts/run-tests-e2e.sh | tee e2e.log - name: Summarize skipped e2e tests if: always() From 09ee4da676865cca5b85b0249dc18ea45f6d83fe Mon Sep 17 00:00:00 2001 From: hironow Date: Thu, 20 Nov 2025 17:34:21 +0900 Subject: [PATCH 06/12] use parallel --- .github/workflows/test-emulators.yaml | 4 ++-- pyproject.toml | 3 ++- scripts/run-tests-e2e.sh | 4 +++- uv.lock | 24 ++++++++++++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-emulators.yaml b/.github/workflows/test-emulators.yaml index 928741f..09f8ac3 100644 --- a/.github/workflows/test-emulators.yaml +++ b/.github/workflows/test-emulators.yaml @@ -19,7 +19,7 @@ concurrency: jobs: unit-integration-e2e: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 steps: - uses: actions/checkout@v5 @@ -60,7 +60,7 @@ jobs: run: bash scripts/run-tests-fast.sh - name: Run e2e tests - timeout-minutes: 8 + timeout-minutes: 20 run: bash scripts/run-tests-e2e.sh | tee e2e.log - name: Summarize skipped e2e tests diff --git a/pyproject.toml b/pyproject.toml index e519580..019f5e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dev = [ "pytest>=8.4.1", "pytest-asyncio>=0.25.0", "pytest-timeout>=2.3.1", + "pytest-xdist>=3.6.1", "ruff>=0.12.4", ] @@ -31,6 +32,6 @@ markers = [ "e2e: end-to-end tests that require Docker and running emulators", ] # Default timeout for all tests (can be overridden per test) -timeout = 300 +timeout = 180 # Timeout method: 'thread' is more compatible with async tests timeout_method = "thread" diff --git a/scripts/run-tests-e2e.sh b/scripts/run-tests-e2e.sh index b8f79a1..96e1481 100644 --- a/scripts/run-tests-e2e.sh +++ b/scripts/run-tests-e2e.sh @@ -9,6 +9,8 @@ if ! command -v uv >/dev/null 2>&1; then fi echo "Running E2E tests" -uv run pytest tests/e2e -v -m e2e -ra +# Use pytest-xdist for parallel execution to speed up tests +# -n auto: automatically detect number of CPU cores +uv run pytest tests/e2e -v -m e2e -ra -n auto echo "Done." diff --git a/uv.lock b/uv.lock index 6298fe8..d9bbf7e 100644 --- a/uv.lock +++ b/uv.lock @@ -605,6 +605,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-timeout" }, + { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -628,9 +629,19 @@ dev = [ { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-asyncio", specifier = ">=0.25.0" }, { name = "pytest-timeout", specifier = ">=2.3.1" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.12.4" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "fastapi" version = "0.121.2" @@ -2427,6 +2438,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 3bbec7f641af1ded57bc1e571f9944373e56a5bf Mon Sep 17 00:00:00 2001 From: hironow Date: Thu, 20 Nov 2025 18:13:35 +0900 Subject: [PATCH 07/12] fix test reliability and failure detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pipefail to E2E test step to properly detect test failures in CI - Remove pytest-xdist parallel execution (causes Docker API 404 errors) - Enhance Elasticsearch wait to verify shard initialization (prevents 503 errors) - All 36 E2E tests now pass reliably in 4m10s ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/test-emulators.yaml | 4 +++- pyproject.toml | 1 - scripts/run-tests-e2e.sh | 6 +++--- scripts/wait-for-services.sh | 12 ++++++++++-- uv.lock | 24 ------------------------ 5 files changed, 16 insertions(+), 31 deletions(-) diff --git a/.github/workflows/test-emulators.yaml b/.github/workflows/test-emulators.yaml index 09f8ac3..757c153 100644 --- a/.github/workflows/test-emulators.yaml +++ b/.github/workflows/test-emulators.yaml @@ -61,7 +61,9 @@ jobs: - name: Run e2e tests timeout-minutes: 20 - run: bash scripts/run-tests-e2e.sh | tee e2e.log + run: | + set -o pipefail + bash scripts/run-tests-e2e.sh | tee e2e.log - name: Summarize skipped e2e tests if: always() diff --git a/pyproject.toml b/pyproject.toml index 019f5e4..37cde2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ dev = [ "pytest>=8.4.1", "pytest-asyncio>=0.25.0", "pytest-timeout>=2.3.1", - "pytest-xdist>=3.6.1", "ruff>=0.12.4", ] diff --git a/scripts/run-tests-e2e.sh b/scripts/run-tests-e2e.sh index 96e1481..188d437 100644 --- a/scripts/run-tests-e2e.sh +++ b/scripts/run-tests-e2e.sh @@ -9,8 +9,8 @@ if ! command -v uv >/dev/null 2>&1; then fi echo "Running E2E tests" -# Use pytest-xdist for parallel execution to speed up tests -# -n auto: automatically detect number of CPU cores -uv run pytest tests/e2e -v -m e2e -ra -n auto +# E2E tests run sequentially due to Docker client state management +# Parallel execution (-n auto) causes Docker API 404 errors +uv run pytest tests/e2e -v -m e2e -ra echo "Done." diff --git a/scripts/wait-for-services.sh b/scripts/wait-for-services.sh index 48513ff..2efa756 100644 --- a/scripts/wait-for-services.sh +++ b/scripts/wait-for-services.sh @@ -126,9 +126,17 @@ wait_elasticsearch() { for _ in $(seq 1 "$max"); do local response response=$(curl -s "$url" || true) + + # Check cluster status is green or yellow if [[ "$response" == *"\"status\":\"green\""* ]] || [[ "$response" == *"\"status\":\"yellow\""* ]]; then - echo " ${name} is ready (status: green/yellow)" - return 0 + # Additionally verify no shards are initializing (prevents 503 errors in tests) + if [[ "$response" == *"\"initializing_shards\":0"* ]]; then + echo " ${name} is ready (status: green/yellow, shards initialized)" + return 0 + else + # Status is good but shards still initializing, keep waiting + echo " ${name} status OK, waiting for shard initialization..." + fi fi sleep 1 done diff --git a/uv.lock b/uv.lock index d9bbf7e..6298fe8 100644 --- a/uv.lock +++ b/uv.lock @@ -605,7 +605,6 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-timeout" }, - { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -629,19 +628,9 @@ dev = [ { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-asyncio", specifier = ">=0.25.0" }, { name = "pytest-timeout", specifier = ">=2.3.1" }, - { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.12.4" }, ] -[[package]] -name = "execnet" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, -] - [[package]] name = "fastapi" version = "0.121.2" @@ -2438,19 +2427,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] -[[package]] -name = "pytest-xdist" -version = "3.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "execnet" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" From 9f1ab79b73d8cffa29a19f758620d928c7be2158 Mon Sep 17 00:00:00 2001 From: hironow Date: Thu, 20 Nov 2025 18:48:27 +0900 Subject: [PATCH 08/12] fix elasticsearch CLI to wait for shard initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavioral Change: - Wait for index shards to be ready after PUT (index creation) - Call /_cluster/health/{index}?wait_for_status=yellow&timeout=30s - Prevents HTTP 503 errors when accessing newly created indices Problem: - E2E tests were failing with "503 Service Unavailable" errors - Elasticsearch returned "NoShardAvailableActionException" - Tests created indices and immediately queried them before shards were ready Solution: - Add waitForIndexReady() function to wait for yellow status - Automatically called after successful PUT requests for index creation - 30s timeout to prevent indefinite waiting Test Results: - All 7 Elasticsearch E2E tests now pass (previously 5 failed) - All 36 E2E tests pass with no regressions - Execution time: 4m09s (sequential) ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- elasticsearch-cli/main.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/elasticsearch-cli/main.go b/elasticsearch-cli/main.go index 1811a50..ac485ab 100644 --- a/elasticsearch-cli/main.go +++ b/elasticsearch-cli/main.go @@ -236,6 +236,14 @@ func executeCommand(command string) { return } + // Wait for index shards to be ready after PUT (index creation) + if method == "PUT" && strings.HasPrefix(path, "/") && !strings.Contains(path, "/_") { + indexName := strings.Split(strings.TrimPrefix(path, "/"), "/")[0] + if indexName != "" { + waitForIndexReady(indexName) + } + } + // Pretty print JSON response var prettyJSON bytes.Buffer if err := json.Indent(&prettyJSON, []byte(resp), "", " "); err != nil { @@ -247,6 +255,16 @@ func executeCommand(command string) { fmt.Printf("\nTime: %v\n", time.Since(start)) } +func waitForIndexReady(indexName string) { + // Wait for index shards to be ready (yellow or green status) + healthPath := fmt.Sprintf("/_cluster/health/%s?wait_for_status=yellow&timeout=30s", indexName) + _, err := makeRequest("GET", healthPath, nil) + if err != nil { + // Log warning but don't fail - the index might still become available + fmt.Printf("Warning: Index health check failed: %v\n", err) + } +} + func makeRequest(method, path string, body []byte) (string, error) { url := fmt.Sprintf("http://%s:%s%s", host, port, path) From bdfedb4b45059d5892515302010ead01045c1b1b Mon Sep 17 00:00:00 2001 From: hironow Date: Thu, 20 Nov 2025 19:10:54 +0900 Subject: [PATCH 09/12] Upgrade Elasticsearch to v9.2.1 and add dedicated test step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit upgrades Elasticsearch from 8.19.5 to 9.2.1 and adds a dedicated test step to verify Elasticsearch functionality before running E2E tests. Changes: - Upgrade Elasticsearch image to 9.2.1 in docker-compose.yaml - Add scripts/test-elasticsearch.sh to verify cluster health, index creation, document insertion, and search functionality - Add "Test Elasticsearch separately" step in GitHub Actions workflow before E2E tests to catch Elasticsearch issues early The new test script performs comprehensive checks: 1. Cluster health status (green/yellow) 2. Shard initialization status 3. Index creation and readiness 4. Document insertion 5. Search functionality 6. Index deletion This helps debug Elasticsearch issues independently from E2E tests. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/test-emulators.yaml | 6 +- docker-compose.yaml | 2 +- scripts/test-elasticsearch.sh | 101 ++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100755 scripts/test-elasticsearch.sh diff --git a/.github/workflows/test-emulators.yaml b/.github/workflows/test-emulators.yaml index 757c153..b8eff3f 100644 --- a/.github/workflows/test-emulators.yaml +++ b/.github/workflows/test-emulators.yaml @@ -54,7 +54,11 @@ jobs: - name: Wait for services to be ready run: bash scripts/wait-for-services.sh --default 90 --a2a 180 --postgres 150 - + + - name: Test Elasticsearch separately + timeout-minutes: 2 + run: bash scripts/test-elasticsearch.sh + - name: Run unit/integration tests (non-e2e) timeout-minutes: 5 run: bash scripts/run-tests-fast.sh diff --git a/docker-compose.yaml b/docker-compose.yaml index b825056..86fed2f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -300,7 +300,7 @@ services: - cli # Only run when explicitly requested # Elasticsearch elasticsearch: - image: elasticsearch:8.19.5 + image: elasticsearch:9.2.1 container_name: elasticsearch-emulator ports: - "${ELASTICSEARCH_PORT:-9200}:9200" # REST API diff --git a/scripts/test-elasticsearch.sh b/scripts/test-elasticsearch.sh new file mode 100755 index 0000000..631603d --- /dev/null +++ b/scripts/test-elasticsearch.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Test Elasticsearch functionality +# Usage: bash scripts/test-elasticsearch.sh + +echo "Testing Elasticsearch..." + +# 1. Check cluster health +echo "1. Checking cluster health..." +HEALTH_RESPONSE=$(curl -s http://localhost:9200/_cluster/health) +echo "Response: $HEALTH_RESPONSE" + +STATUS=$(echo "$HEALTH_RESPONSE" | grep -o '"status":"[^"]*"' | cut -d'"' -f4) +echo "Cluster status: $STATUS" + +if [[ "$STATUS" != "green" && "$STATUS" != "yellow" ]]; then + echo "ERROR: Cluster status is not green or yellow" >&2 + exit 1 +fi + +# 2. Check if shards are initialized +INITIALIZING_SHARDS=$(echo "$HEALTH_RESPONSE" | grep -o '"initializing_shards":[0-9]*' | cut -d':' -f2) +echo "Initializing shards: $INITIALIZING_SHARDS" + +if [[ "$INITIALIZING_SHARDS" != "0" ]]; then + echo "WARNING: Shards are still initializing" >&2 +fi + +# 3. Create a test index +TEST_INDEX="es_health_check_$(date +%s)" +echo "" +echo "2. Creating test index: $TEST_INDEX" +CREATE_RESPONSE=$(curl -s -X PUT "http://localhost:9200/$TEST_INDEX" \ + -H 'Content-Type: application/json' \ + -d '{ + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0 + } + }') +echo "Response: $CREATE_RESPONSE" + +# 4. Wait for index to be ready +echo "" +echo "3. Waiting for index to be ready..." +MAX_WAIT=30 +for i in $(seq 1 $MAX_WAIT); do + INDEX_HEALTH=$(curl -s "http://localhost:9200/_cluster/health/$TEST_INDEX?wait_for_status=yellow&timeout=1s") + INDEX_STATUS=$(echo "$INDEX_HEALTH" | grep -o '"status":"[^"]*"' | cut -d'"' -f4) + + if [[ "$INDEX_STATUS" == "green" || "$INDEX_STATUS" == "yellow" ]]; then + echo "Index is ready (status: $INDEX_STATUS)" + break + fi + + if [[ $i -eq $MAX_WAIT ]]; then + echo "ERROR: Index not ready after ${MAX_WAIT}s" >&2 + exit 1 + fi + + sleep 1 +done + +# 5. Insert a test document +echo "" +echo "4. Inserting test document..." +INSERT_RESPONSE=$(curl -s -X POST "http://localhost:9200/$TEST_INDEX/_doc" \ + -H 'Content-Type: application/json' \ + -d '{"test": "document", "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}') +echo "Response: $INSERT_RESPONSE" + +# 6. Search for the document +echo "" +echo "5. Searching for test document..." +sleep 1 # Wait for indexing +SEARCH_RESPONSE=$(curl -s -X GET "http://localhost:9200/$TEST_INDEX/_search" \ + -H 'Content-Type: application/json' \ + -d '{ + "query": { + "match_all": {} + } + }') +echo "Response: $SEARCH_RESPONSE" + +HITS=$(echo "$SEARCH_RESPONSE" | grep -o '"total":{"value":[0-9]*' | grep -o '[0-9]*$') +echo "Total hits: $HITS" + +if [[ "$HITS" != "1" ]]; then + echo "ERROR: Expected 1 hit, got $HITS" >&2 + exit 1 +fi + +# 7. Delete the test index +echo "" +echo "6. Cleaning up test index..." +DELETE_RESPONSE=$(curl -s -X DELETE "http://localhost:9200/$TEST_INDEX") +echo "Response: $DELETE_RESPONSE" + +echo "" +echo "โœ“ All Elasticsearch tests passed!" From e9c8e2ddf8a7448b5de6ab6a6811c78220014b0b Mon Sep 17 00:00:00 2001 From: hironow Date: Thu, 20 Nov 2025 19:33:17 +0900 Subject: [PATCH 10/12] Optimize Elasticsearch v9.2.1 for CI environment performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit optimizes Elasticsearch v9.2.1 configuration and waiting logic to improve performance in CI environments while maintaining local development compatibility. Changes: docker-compose.yaml: - Add bootstrap.memory_lock=false to prevent memory lock issues - Disable cluster.routing.allocation.disk.threshold_enabled for CI - Set action.destructive_requires_name=false for testing elasticsearch-cli/main.go: - Improve waitForIndexReady() with polling mechanism (60 sec max) - Check both cluster status and initializing_shards count - Replace single wait_for_status request with polling loop - Add detailed warnings for timeout scenarios These optimizations address CI-specific issues where Elasticsearch v9 initialization and shard allocation are slower than in local environments. The polling approach ensures tests wait appropriately for index readiness rather than timing out prematurely. Local test results: 36 passed, 7 skipped in 254.80s ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docker-compose.yaml | 3 +++ elasticsearch-cli/main.go | 27 ++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 86fed2f..4d2a376 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -309,6 +309,9 @@ services: - discovery.type=single-node - xpack.security.enabled=false - ES_JAVA_OPTS=-Xms512m -Xmx512m + - "bootstrap.memory_lock=false" + - "cluster.routing.allocation.disk.threshold_enabled=false" + - "action.destructive_requires_name=false" volumes: - elasticsearch_data:/usr/share/elasticsearch/data healthcheck: diff --git a/elasticsearch-cli/main.go b/elasticsearch-cli/main.go index ac485ab..f91dfc1 100644 --- a/elasticsearch-cli/main.go +++ b/elasticsearch-cli/main.go @@ -257,12 +257,29 @@ func executeCommand(command string) { func waitForIndexReady(indexName string) { // Wait for index shards to be ready (yellow or green status) - healthPath := fmt.Sprintf("/_cluster/health/%s?wait_for_status=yellow&timeout=30s", indexName) - _, err := makeRequest("GET", healthPath, nil) - if err != nil { - // Log warning but don't fail - the index might still become available - fmt.Printf("Warning: Index health check failed: %v\n", err) + // Use a longer timeout for CI environments and poll for readiness + maxRetries := 60 // 60 seconds total + for i := 0; i < maxRetries; i++ { + healthPath := fmt.Sprintf("/_cluster/health/%s", indexName) + resp, err := makeRequest("GET", healthPath, nil) + if err != nil { + time.Sleep(1 * time.Second) + continue + } + + result := gjson.Parse(resp) + status := result.Get("status").String() + initializingShards := result.Get("initializing_shards").Int() + + if (status == "green" || status == "yellow") && initializingShards == 0 { + return // Index is ready + } + + time.Sleep(1 * time.Second) } + + // Log warning but don't fail - the index might still become available + fmt.Printf("Warning: Index %s not ready after %d seconds\n", indexName, maxRetries) } func makeRequest(method, path string, body []byte) (string, error) { From 45efed33693418623fcc8b79a9a62ed734a1332a Mon Sep 17 00:00:00 2001 From: hironow Date: Thu, 20 Nov 2025 19:49:08 +0900 Subject: [PATCH 11/12] Add timeout handling to E2E test container execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change `containers.run()` to detached mode for both `run_cli` and `run_shell` - Add explicit `container.wait(timeout=300)` (5-minute timeout) - Get logs after container completion - Check exit code and fail test if non-zero - Proper container cleanup on both success and error - Prevent indefinite hangs in CI when containers don't exit Fixes issue where E2E tests hung in GitHub Actions when Elasticsearch CLI operations took too long, causing 20-minute workflow timeouts. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/e2e/conftest.py | 70 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 3b18080..14b0655 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -105,18 +105,47 @@ def run_cli( def _run(image: str, binary: str, script: str, env: dict[str, str]) -> str: script = textwrap.dedent(script).lstrip("\n") heredoc = f"cat <<'EOF' | ./{binary}\n{script}\nEOF" + container = None try: - out: bytes = docker_client.containers.run( + # Run container in detached mode to enable timeout handling + container = docker_client.containers.run( image=image, command=["sh", "-lc", heredoc], environment=env, network=e2e_network_name, - remove=True, + detach=True, stdout=True, stderr=True, ) - return out.decode(errors="ignore") + + # Wait for container to finish with 5-minute timeout + exit_code = container.wait(timeout=300) + + # Get logs after completion + logs = container.logs(stdout=True, stderr=True) + + # Clean up container + container.remove() + + # Check exit code + if isinstance(exit_code, dict): + status_code = exit_code.get("StatusCode", 0) + else: + status_code = exit_code + + if status_code != 0: + pytest.fail( + f"Container exited with code {status_code}. Output:\n{logs.decode(errors='ignore')}" + ) + + return logs.decode(errors="ignore") except Exception as e: # pragma: no cover - env dependent + # Clean up container on error + if container: + try: + container.remove(force=True) + except Exception: + pass pytest.skip(f"run {image} failed: {e}") return _run @@ -132,18 +161,47 @@ def run_shell( """ def _run(image: str, command: str, env: dict[str, str]) -> str: + container = None try: - out: bytes = docker_client.containers.run( + # Run container in detached mode to enable timeout handling + container = docker_client.containers.run( image=image, command=["sh", "-lc", command], environment=env, network=e2e_network_name, - remove=True, + detach=True, stdout=True, stderr=True, ) - return out.decode(errors="ignore") + + # Wait for container to finish with 5-minute timeout + exit_code = container.wait(timeout=300) + + # Get logs after completion + logs = container.logs(stdout=True, stderr=True) + + # Clean up container + container.remove() + + # Check exit code + if isinstance(exit_code, dict): + status_code = exit_code.get("StatusCode", 0) + else: + status_code = exit_code + + if status_code != 0: + pytest.fail( + f"Container exited with code {status_code}. Output:\n{logs.decode(errors='ignore')}" + ) + + return logs.decode(errors="ignore") except Exception as e: # pragma: no cover - env dependent + # Clean up container on error + if container: + try: + container.remove(force=True) + except Exception: + pass pytest.skip(f"run {image} failed: {e}") return _run From 7d2e1eebdc90e9e347a1350113a1d40dfce129f5 Mon Sep 17 00:00:00 2001 From: hironow Date: Thu, 20 Nov 2025 19:49:35 +0900 Subject: [PATCH 12/12] Add verbose logging to Elasticsearch CLI for CI diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `verbose` bool variable and ES_CLI_VERBOSE env var - Enhanced waitForIndexReady() with detailed retry logging - Shows attempt number, status, shard counts, and timing - Logs start/end of wait operations - Enhanced makeRequest() with request/response logging - Shows HTTP method, path, body preview - Logs response status, timing, and size - Logs errors with context - Add pytest allow permission to settings.local.json Helps diagnose Elasticsearch v9 performance issues in CI by providing detailed timing and status information for each operation. Enable with: ES_CLI_VERBOSE=1 or ES_CLI_VERBOSE=true ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/settings.local.json | 3 +- elasticsearch-cli/main.go | 59 ++++++++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2d4cd58..9c9d355 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,8 @@ "Bash(docker compose stop:*)", "Bash(docker compose rm:*)", "Bash(docker volume:*)", - "Bash(docker compose:*)" + "Bash(docker compose:*)", + "Bash(uv run pytest:*)" ], "deny": [], "ask": [] diff --git a/elasticsearch-cli/main.go b/elasticsearch-cli/main.go index f91dfc1..4043de5 100644 --- a/elasticsearch-cli/main.go +++ b/elasticsearch-cli/main.go @@ -16,9 +16,10 @@ import ( ) var ( - host string - port string - client *http.Client + host string + port string + client *http.Client + verbose bool ) func init() { @@ -32,6 +33,9 @@ func init() { port = "9200" } + // Enable verbose logging for debugging (especially in CI) + verbose = os.Getenv("ES_CLI_VERBOSE") == "1" || os.Getenv("ES_CLI_VERBOSE") == "true" + client = &http.Client{ Timeout: 60 * time.Second, } @@ -259,10 +263,21 @@ func waitForIndexReady(indexName string) { // Wait for index shards to be ready (yellow or green status) // Use a longer timeout for CI environments and poll for readiness maxRetries := 60 // 60 seconds total + + if verbose { + fmt.Printf("[VERBOSE] Waiting for index '%s' to be ready (max %ds)...\n", indexName, maxRetries) + } + for i := 0; i < maxRetries; i++ { healthPath := fmt.Sprintf("/_cluster/health/%s", indexName) + start := time.Now() resp, err := makeRequest("GET", healthPath, nil) + elapsed := time.Since(start) + if err != nil { + if verbose { + fmt.Printf("[VERBOSE] Retry %d/%d: Health check failed after %v: %v\n", i+1, maxRetries, elapsed, err) + } time.Sleep(1 * time.Second) continue } @@ -270,8 +285,17 @@ func waitForIndexReady(indexName string) { result := gjson.Parse(resp) status := result.Get("status").String() initializingShards := result.Get("initializing_shards").Int() + activeShards := result.Get("active_shards").Int() + + if verbose { + fmt.Printf("[VERBOSE] Retry %d/%d: status=%s, initializing_shards=%d, active_shards=%d (took %v)\n", + i+1, maxRetries, status, initializingShards, activeShards, elapsed) + } if (status == "green" || status == "yellow") && initializingShards == 0 { + if verbose { + fmt.Printf("[VERBOSE] Index '%s' ready after %d attempts (%.2fs total)\n", indexName, i+1, float64(i+1)) + } return // Index is ready } @@ -284,13 +308,16 @@ func waitForIndexReady(indexName string) { func makeRequest(method, path string, body []byte) (string, error) { url := fmt.Sprintf("http://%s:%s%s", host, port, path) + start := time.Now() var req *http.Request var err error if body != nil { req, err = http.NewRequest(method, url, bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") + if err == nil { + req.Header.Set("Content-Type", "application/json") + } } else { req, err = http.NewRequest(method, url, nil) } @@ -299,17 +326,41 @@ func makeRequest(method, path string, body []byte) (string, error) { return "", err } + if verbose { + bodyPreview := "" + if body != nil && len(body) > 0 { + if len(body) > 100 { + bodyPreview = fmt.Sprintf(" (body: %d bytes)", len(body)) + } else { + bodyPreview = fmt.Sprintf(" (body: %s)", string(body)) + } + } + fmt.Printf("[VERBOSE] Request: %s %s%s\n", method, path, bodyPreview) + } + resp, err := client.Do(req) + elapsed := time.Since(start) + if err != nil { + if verbose { + fmt.Printf("[VERBOSE] Request failed after %v: %v\n", elapsed, err) + } return "", err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { + if verbose { + fmt.Printf("[VERBOSE] Failed to read response body after %v: %v\n", elapsed, err) + } return "", err } + if verbose { + fmt.Printf("[VERBOSE] Response: HTTP %d (took %v, %d bytes)\n", resp.StatusCode, elapsed, len(respBody)) + } + if resp.StatusCode >= 400 { return string(respBody), fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) }