Skip to content

Commit 6d0080c

Browse files
authored
test: add E2E smoke test for the sample (#991)
1. Fix gRPC setup. 1. Add E2E test with subprocess.
1 parent d38eb97 commit 6d0080c

3 files changed

Lines changed: 152 additions & 2 deletions

File tree

samples/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ async def main() -> None:
7373
)
7474
args = parser.parse_args()
7575

76-
config = ClientConfig()
76+
config = ClientConfig(
77+
grpc_channel_factory=grpc.aio.insecure_channel,
78+
)
7779
if args.transport:
7880
config.supported_protocol_bindings = [args.transport]
7981

samples/hello_world_agent.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import argparse
12
import asyncio
23
import contextlib
34
import logging
@@ -257,5 +258,18 @@ async def serve(
257258

258259
if __name__ == '__main__':
259260
logging.basicConfig(level=logging.INFO)
261+
parser = argparse.ArgumentParser(description='Sample A2A agent server')
262+
parser.add_argument('--host', default='127.0.0.1')
263+
parser.add_argument('--port', type=int, default=41241)
264+
parser.add_argument('--grpc-port', type=int, default=50051)
265+
parser.add_argument('--compat-grpc-port', type=int, default=50052)
266+
args = parser.parse_args()
260267
with contextlib.suppress(KeyboardInterrupt):
261-
asyncio.run(serve())
268+
asyncio.run(
269+
serve(
270+
host=args.host,
271+
port=args.port,
272+
grpc_port=args.grpc_port,
273+
compat_grpc_port=args.compat_grpc_port,
274+
)
275+
)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""End-to-end smoke test for `samples/hello_world_agent.py` and `samples/cli.py`.
2+
3+
Boots the sample agent as a subprocess on free ports, then runs the sample CLI
4+
against it once per supported transport, asserting the expected greeting reply
5+
flows through.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import asyncio
11+
import socket
12+
import sys
13+
14+
from pathlib import Path
15+
from typing import TYPE_CHECKING
16+
17+
import httpx
18+
import pytest
19+
import pytest_asyncio
20+
21+
22+
if TYPE_CHECKING:
23+
from collections.abc import AsyncGenerator
24+
25+
26+
REPO_ROOT = Path(__file__).resolve().parents[2]
27+
SAMPLES_DIR = REPO_ROOT / 'samples'
28+
AGENT_SCRIPT = SAMPLES_DIR / 'hello_world_agent.py'
29+
CLI_SCRIPT = SAMPLES_DIR / 'cli.py'
30+
31+
STARTUP_TIMEOUT_S = 30.0
32+
CLI_TIMEOUT_S = 30.0
33+
EXPECTED_REPLY = 'Hello World! Nice to meet you!'
34+
35+
36+
def _free_port() -> int:
37+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
38+
sock.bind(('127.0.0.1', 0))
39+
return sock.getsockname()[1]
40+
41+
42+
async def _wait_for_agent_card(url: str) -> None:
43+
deadline = asyncio.get_running_loop().time() + STARTUP_TIMEOUT_S
44+
async with httpx.AsyncClient(timeout=2.0) as client:
45+
while asyncio.get_running_loop().time() < deadline:
46+
try:
47+
response = await client.get(url)
48+
if response.status_code == 200:
49+
return
50+
except httpx.RequestError:
51+
pass
52+
await asyncio.sleep(0.2)
53+
raise TimeoutError(f'Agent did not become ready at {url}')
54+
55+
56+
@pytest_asyncio.fixture
57+
async def running_sample_agent() -> AsyncGenerator[str, None]:
58+
"""Start `hello_world_agent.py` as a subprocess on free ports."""
59+
host = '127.0.0.1'
60+
http_port = _free_port()
61+
grpc_port = _free_port()
62+
compat_grpc_port = _free_port()
63+
base_url = f'http://{host}:{http_port}'
64+
65+
proc = await asyncio.create_subprocess_exec(
66+
sys.executable,
67+
str(AGENT_SCRIPT),
68+
'--host',
69+
host,
70+
'--port',
71+
str(http_port),
72+
'--grpc-port',
73+
str(grpc_port),
74+
'--compat-grpc-port',
75+
str(compat_grpc_port),
76+
cwd=str(REPO_ROOT),
77+
stdout=asyncio.subprocess.PIPE,
78+
stderr=asyncio.subprocess.STDOUT,
79+
)
80+
81+
try:
82+
await _wait_for_agent_card(f'{base_url}/.well-known/agent-card.json')
83+
yield base_url
84+
finally:
85+
if proc.returncode is None:
86+
proc.terminate()
87+
try:
88+
await asyncio.wait_for(proc.wait(), timeout=10.0)
89+
except asyncio.TimeoutError:
90+
proc.kill()
91+
await proc.wait()
92+
93+
94+
async def _run_cli(base_url: str, transport: str) -> str:
95+
"""Run `cli.py --transport <transport>`, send `hello`, return combined output."""
96+
proc = await asyncio.create_subprocess_exec(
97+
sys.executable,
98+
str(CLI_SCRIPT),
99+
'--url',
100+
base_url,
101+
'--transport',
102+
transport,
103+
cwd=str(REPO_ROOT),
104+
stdin=asyncio.subprocess.PIPE,
105+
stdout=asyncio.subprocess.PIPE,
106+
stderr=asyncio.subprocess.STDOUT,
107+
)
108+
try:
109+
stdout, _ = await asyncio.wait_for(
110+
proc.communicate(b'hello\n/quit\n'),
111+
timeout=CLI_TIMEOUT_S,
112+
)
113+
except asyncio.TimeoutError:
114+
proc.kill()
115+
await proc.wait()
116+
raise
117+
output = stdout.decode('utf-8', errors='replace')
118+
assert proc.returncode == 0, (
119+
f'CLI exited with {proc.returncode} for transport {transport!r}.\n'
120+
f'Output:\n{output}'
121+
)
122+
return output
123+
124+
125+
@pytest.mark.asyncio
126+
@pytest.mark.parametrize('transport', ['JSONRPC', 'HTTP+JSON', 'GRPC'])
127+
async def test_cli_against_sample_agent(
128+
running_sample_agent: str, transport: str
129+
) -> None:
130+
"""The CLI should successfully exchange a greeting over each transport."""
131+
output = await _run_cli(running_sample_agent, transport)
132+
133+
assert 'TASK_STATE_COMPLETED' in output, output
134+
assert EXPECTED_REPLY in output, output

0 commit comments

Comments
 (0)