diff --git a/dev/README.md b/dev/README.md index e8c10764..11a470b9 100644 --- a/dev/README.md +++ b/dev/README.md @@ -1,6 +1,25 @@ -This directory contains tools to aid the developers of the Microsoft 365 Agents SDK for Python. +# Development Tools -### `benchmark` +Development utilities for the Microsoft Agents for Python project. -This folder contains benchmarking utilities built in Python to send concurrent requests -to an agent. \ No newline at end of file +## Contents + +- **[`install.sh`](install.sh)** - Installs testing framework in editable mode +- **[`benchmark/`](benchmark/)** - Performance testing and stress testing tools +- **[`microsoft-agents-testing/`](microsoft-agents-testing/)** - Testing framework package + +## Quick Setup + +```bash +./install.sh +``` + +## Benchmarking + +Performance testing tools with support for concurrent workers and authentication. Requires a running agent instance and Azure Bot Service credentials. + +See [benchmark/README.md](benchmark/README.md) for setup and usage details. + +## Testing Framework + +Provides testing utilities and helpers for Microsoft Agents development. Installed in editable mode for active development. \ No newline at end of file diff --git a/dev/benchmark/__init__.py b/dev/benchmark/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/benchmark/payload.json b/dev/benchmark/payload.json index 28ac2a8c..399023d6 100644 --- a/dev/benchmark/payload.json +++ b/dev/benchmark/payload.json @@ -15,5 +15,6 @@ "id": "user-id-0", "aadObjectId": "00000000-0000-0000-0000-0000000000020" }, - "type": "message" + "type": "message", + "text": "Hello, Bot!" } \ No newline at end of file diff --git a/dev/benchmark/src/main.py b/dev/benchmark/src/main.py index af9e177e..d8a31c83 100644 --- a/dev/benchmark/src/main.py +++ b/dev/benchmark/src/main.py @@ -8,6 +8,7 @@ from .executor import Executor, CoroutineExecutor, ThreadExecutor from .aggregated_results import AggregatedResults from .config import BenchmarkConfig +from .output import output_results LOG_FORMAT = "%(asctime)s: %(message)s" logging.basicConfig(format=LOG_FORMAT, level=logging.INFO, datefmt="%H:%M:%S") @@ -20,13 +21,14 @@ "--payload_path", "-p", default="./payload.json", help="Path to the payload file." ) @click.option("--num_workers", "-n", default=1, help="Number of workers to use.") +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging.") @click.option( "--async_mode", "-a", is_flag=True, help="Run coroutine workers rather than thread workers.", ) -def main(payload_path: str, num_workers: int, async_mode: bool): +def main(payload_path: str, num_workers: int, verbose: bool, async_mode: bool): """Main function to run the benchmark.""" with open(payload_path, "r", encoding="utf-8") as f: @@ -39,6 +41,8 @@ def main(payload_path: str, num_workers: int, async_mode: bool): start_time = datetime.now(timezone.utc).timestamp() results = executor.run(func, num_workers=num_workers) end_time = datetime.now(timezone.utc).timestamp() + if verbose: + output_results(results) agg = AggregatedResults(results) agg.display(start_time, end_time) diff --git a/dev/benchmark/src/output.py b/dev/benchmark/src/output.py new file mode 100644 index 00000000..a0d3d76a --- /dev/null +++ b/dev/benchmark/src/output.py @@ -0,0 +1,12 @@ +from .executor import ExecutionResult + + +def output_results(results: list[ExecutionResult]) -> None: + """Output the results of the benchmark to the console.""" + + for result in results: + status = "Success" if result.success else "Failure" + print( + f"Execution ID: {result.exe_id}, Duration: {result.duration:.4f} seconds, Status: {status}" + ) + print(result.result) diff --git a/dev/install.sh b/dev/install.sh new file mode 100644 index 00000000..512d1d88 --- /dev/null +++ b/dev/install.sh @@ -0,0 +1 @@ +pip install -e ./microsoft-agents-testing/ --config-settings editable_mode=compat \ No newline at end of file diff --git a/dev/microsoft-agents-testing/README.md b/dev/microsoft-agents-testing/README.md new file mode 100644 index 00000000..ef50bf39 --- /dev/null +++ b/dev/microsoft-agents-testing/README.md @@ -0,0 +1,171 @@ +# Microsoft 365 Agents SDK for Python - Testing Framework + +A comprehensive testing framework designed specifically for Microsoft 365 Agents SDK, providing essential utilities and abstractions to streamline integration testing, authentication, and end-to-end agent validation. + +## Why This Package Exists + +Building and testing conversational agents presents unique challenges that standard testing frameworks don't address. This package eliminates these pain points by providing useful abstractions specifically designed for agent testing scenarios. + +## Key Features + +### ๐Ÿ” Authentication Utilities +- **OAuth2 Token Generation**: Generate access tokens using client credentials flow +- **Configuration-Based Auth**: Load credentials from environment variables or config objects +- **MSAL Integration**: Built-in support for Microsoft Authentication Library + +```python +from microsoft_agents.testing import generate_token, generate_token_from_config + +# Generate token directly +token = generate_token( + app_id="your-app-id", + app_secret="your-secret", + tenant_id="your-tenant" +) + +# Or from SDK config +token = generate_token_from_config(sdk_config) +``` + +### ๐Ÿงช Integration Test Framework +- **Pytest Fixtures**: Pre-built fixtures for common test scenarios +- **Environment Abstraction**: Reusable environment setup for different hosting configurations +- **Sample Management**: Base classes for organizing test samples and configurations +- **Application Runners**: Abstract server lifecycle management for integration tests + +```python +from microsoft_agents.testing import Integration, Environment, Sample + +class MyAgentTests(Integration): + _sample_cls = MyAgentSample + _environment_cls = AiohttpEnvironment + + @pytest.mark.asyncio + async def test_conversation_flow(self, agent_client, sample): + # Client and sample are automatically set up via fixtures + response = await agent_client.send_activity("Hello") + assert response is not None +``` + +### ๐Ÿค– Agent Communication Clients +- **AgentClient**: High-level client for sending Activities to agents +- **ResponseClient**: Handle responses from agent services +- **Automatic Token Management**: Clients handle authentication automatically +- **Delivery Mode Support**: Test both standard and `ExpectReplies` delivery patterns + +```python +from microsoft_agents.testing import AgentClient + +client = AgentClient( + agent_url="http://localhost:3978", + cid="conversation-id", + client_id="your-client-id", + tenant_id="your-tenant-id", + client_secret="your-secret" +) + +# Send simple text message +response = await client.send_activity("What's the weather?") + +# Send Activity with ExpectReplies +replies = await client.send_expect_replies( + Activity(type=ActivityTypes.message, text="Hello") +) +``` + +### ๐Ÿ› ๏ธ Testing Utilities +- **Activity Population**: Automatically fill default Activity properties for testing +- **URL Parsing**: Extract host and port from service URLs +- **Configuration Management**: Centralized SDK configuration for tests + +```python +from microsoft_agents.testing import populate_activity, get_host_and_port + +# Populate test activity with defaults +activity = populate_activity( + Activity(text="Hello"), + defaults={"service_url": "http://localhost", "channel_id": "test"} +) + +# Parse service URLs +host, port = get_host_and_port("http://localhost:3978/api/messages") +``` + +## Who Should Use This Package + +- **Agent Developers**: Testing agents built with `microsoft-agents-hosting-core` and related packages +- **QA Engineers**: Writing integration and E2E tests for conversational AI systems +- **DevOps Teams**: Automating agent validation in CI/CD pipelines +- **Sample Authors**: Creating reproducible examples and documentation + +## Integration with CI/CD + +This package is designed for seamless integration into continuous integration pipelines: + +```yaml +# Example: GitHub Actions +- name: Run Agent Integration Tests + run: | + pip install microsoft-agents-testing pytest pytest-asyncio + pytest tests/integration/ -v + env: + CLIENT_ID: ${{ secrets.AGENT_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.AGENT_CLIENT_SECRET }} + TENANT_ID: ${{ secrets.TENANT_ID }} +``` + +## Quick Start Example + +```python +import pytest +from microsoft_agents.testing import Integration, AiohttpEnvironment, Sample +from microsoft_agents.activity import Activity + +class MyAgentSample(Sample): + async def init_app(self): + # Initialize your agent application + self.app = create_my_agent_app(self.env) + + @classmethod + async def get_config(cls): + return {"service_url": "http://localhost:3978"} + +class TestMyAgent(Integration): + _sample_cls = MyAgentSample + _environment_cls = AiohttpEnvironment + + _agent_url = "http://localhost:3978" + _cid = "test-conversation" + + @pytest.mark.asyncio + async def test_greeting(self, agent_client): + response = await agent_client.send_activity("Hello") + assert "Hi there" in response + + @pytest.mark.asyncio + async def test_conversation(self, agent_client): + replies = await agent_client.send_expect_replies("What can you do?") + assert len(replies) > 0 + assert replies[0].type == "message" +``` + +## Related Packages + +This package complements the Microsoft 365 Agents SDK ecosystem: + +- `microsoft-agents-activity`: Activity types and protocols +- `microsoft-agents-hosting-core`: Core hosting framework +- `microsoft-agents-hosting-aiohttp`: aiohttp hosting integration +- `microsoft-agents-authentication-msal`: MSAL authentication + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA). For details, visit [https://cla.opensource.microsoft.com](https://cla.opensource.microsoft.com). + +## License + +MIT + +## Support + +For issues, questions, or contributions, please visit the [GitHub repository](https://github.com/microsoft/Agents-for-python). diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py new file mode 100644 index 00000000..c8364fff --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py @@ -0,0 +1,30 @@ +from .sdk_config import SDKConfig + +from .auth import generate_token, generate_token_from_config + +from .utils import populate_activity, get_host_and_port + +from .integration import ( + Sample, + Environment, + ApplicationRunner, + AgentClient, + ResponseClient, + AiohttpEnvironment, + Integration, +) + +__all__ = [ + "SDKConfig", + "generate_token", + "generate_token_from_config", + "Sample", + "Environment", + "ApplicationRunner", + "AgentClient", + "ResponseClient", + "AiohttpEnvironment", + "Integration", + "populate_activity", + "get_host_and_port", +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py new file mode 100644 index 00000000..3fe2a78f --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py @@ -0,0 +1,3 @@ +from .generate_token import generate_token, generate_token_from_config + +__all__ = ["generate_token", "generate_token_from_config"] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/auth/generate_token.py b/dev/microsoft-agents-testing/microsoft_agents/testing/auth/generate_token.py new file mode 100644 index 00000000..83106639 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/auth/generate_token.py @@ -0,0 +1,51 @@ +import requests + +from microsoft_agents.hosting.core import AgentAuthConfiguration +from microsoft_agents.testing.sdk_config import SDKConfig + + +def generate_token(app_id: str, app_secret: str, tenant_id: str) -> str: + """Generate a token using the provided app credentials. + + :param app_id: Application (client) ID. + :param app_secret: Application client secret. + :param tenant_id: Directory (tenant) ID. + :return: Generated access token as a string. + """ + + authority_endpoint = ( + f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + ) + + res = requests.post( + authority_endpoint, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + data={ + "grant_type": "client_credentials", + "client_id": app_id, + "client_secret": app_secret, + "scope": f"{app_id}/.default", + }, + timeout=10, + ) + return res.json().get("access_token") + + +def generate_token_from_config(sdk_config: SDKConfig) -> str: + """Generates a token using a provided config object. + + :param sdk_config: Configuration dictionary containing connection settings. + :return: Generated access token as a string. + """ + + settings: AgentAuthConfiguration = sdk_config.get_connection() + + app_id = settings.CLIENT_ID + app_secret = settings.CLIENT_SECRET + tenant_id = settings.TENANT_ID + + if not app_id or not app_secret or not tenant_id: + raise ValueError("Incorrect configuration provided for token generation.") + return generate_token(app_id, app_secret, tenant_id) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/__init__.py new file mode 100644 index 00000000..3ad1e376 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/__init__.py @@ -0,0 +1,19 @@ +from .core import ( + AgentClient, + ApplicationRunner, + AiohttpEnvironment, + ResponseClient, + Environment, + Integration, + Sample, +) + +__all__ = [ + "AgentClient", + "ApplicationRunner", + "AiohttpEnvironment", + "ResponseClient", + "Environment", + "Integration", + "Sample", +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/__init__.py new file mode 100644 index 00000000..9c69a2ae --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/__init__.py @@ -0,0 +1,20 @@ +from .application_runner import ApplicationRunner +from .aiohttp import AiohttpEnvironment +from .client import ( + AgentClient, + ResponseClient, +) +from .environment import Environment +from .integration import Integration +from .sample import Sample + + +__all__ = [ + "AgentClient", + "ApplicationRunner", + "AiohttpEnvironment", + "ResponseClient", + "Environment", + "Integration", + "Sample", +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/__init__.py new file mode 100644 index 00000000..82d2d1d0 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/__init__.py @@ -0,0 +1,4 @@ +from .aiohttp_environment import AiohttpEnvironment +from .aiohttp_runner import AiohttpRunner + +__all__ = ["AiohttpEnvironment", "AiohttpRunner"] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_environment.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_environment.py new file mode 100644 index 00000000..7ff83dc0 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_environment.py @@ -0,0 +1,59 @@ +from aiohttp.web import Request, Response, Application + +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import ( + Authorization, + AgentApplication, + TurnState, + MemoryStorage, +) +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.activity import load_configuration_from_env + +from ..application_runner import ApplicationRunner +from ..environment import Environment +from .aiohttp_runner import AiohttpRunner + + +class AiohttpEnvironment(Environment): + """An environment for aiohttp-hosted agents.""" + + async def init_env(self, environ_config: dict) -> None: + environ_config = environ_config or {} + + self.config = load_configuration_from_env(environ_config) + + self.storage = MemoryStorage() + self.connection_manager = MsalConnectionManager(**self.config) + self.adapter = CloudAdapter(connection_manager=self.connection_manager) + self.authorization = Authorization( + self.storage, self.connection_manager, **self.config + ) + + self.agent_application = AgentApplication[TurnState]( + storage=self.storage, + adapter=self.adapter, + authorization=self.authorization, + **self.config + ) + + def create_runner(self, host: str, port: int) -> ApplicationRunner: + + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process(req, agent, adapter) + + APP = Application(middlewares=[jwt_authorization_middleware]) + APP.router.add_post("/api/messages", entry_point) + APP["agent_configuration"] = ( + self.connection_manager.get_default_connection_configuration() + ) + APP["agent_app"] = self.agent_application + APP["adapter"] = self.adapter + + return AiohttpRunner(APP, host, port) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_runner.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_runner.py new file mode 100644 index 00000000..c8fe23c2 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_runner.py @@ -0,0 +1,110 @@ +from typing import Optional +from threading import Thread, Event +import asyncio + +from aiohttp import ClientSession +from aiohttp.web import Application, Request, Response +from aiohttp.web_runner import AppRunner, TCPSite + +from ..application_runner import ApplicationRunner + + +class AiohttpRunner(ApplicationRunner): + """A runner for aiohttp applications.""" + + def __init__(self, app: Application, host: str = "localhost", port: int = 8000): + assert isinstance(app, Application) + super().__init__(app) + + url = f"{host}:{port}" + self._host = host + self._port = port + if "http" not in url: + url = f"http://{url}" + self._url = url + + self._app.router.add_get("/shutdown", self._shutdown_route) + + self._server_thread: Optional[Thread] = None + self._shutdown_event = Event() + self._runner: Optional[AppRunner] = None + self._site: Optional[TCPSite] = None + + @property + def url(self) -> str: + return self._url + + async def _start_server(self) -> None: + assert isinstance(self._app, Application) + + self._runner = AppRunner(self._app) + await self._runner.setup() + self._site = TCPSite(self._runner, self._host, self._port) + await self._site.start() + + # Wait for shutdown signal + while not self._shutdown_event.is_set(): + await asyncio.sleep(0.1) + + # Cleanup + await self._site.stop() + await self._runner.cleanup() + + async def __aenter__(self): + if self._server_thread: + raise RuntimeError("AiohttpRunner is already running.") + + self._shutdown_event.clear() + self._server_thread = Thread( + target=lambda: asyncio.run(self._start_server()), daemon=True + ) + self._server_thread.start() + + # Wait a moment to ensure the server starts + await asyncio.sleep(0.5) + + return self + + async def _stop_server(self): + if not self._server_thread: + raise RuntimeError("AiohttpRunner is not running.") + + try: + async with ClientSession() as session: + async with session.get( + f"http://{self._host}:{self._port}/shutdown" + ) as response: + pass # Just trigger the shutdown + except Exception: + pass # Ignore errors during shutdown request + + # Set shutdown event as fallback + self._shutdown_event.set() + + # Wait for the server thread to finish + self._server_thread.join(timeout=5.0) + self._server_thread = None + + async def _shutdown_route(self, request: Request) -> Response: + """Handle shutdown request by setting the shutdown event""" + self._shutdown_event.set() + return Response(status=200, text="Shutdown initiated") + + async def __aexit__(self, exc_type, exc, tb): + if not self._server_thread: + raise RuntimeError("AiohttpRunner is not running.") + try: + async with ClientSession() as session: + async with session.get( + f"http://{self._host}:{self._port}/shutdown" + ) as response: + pass # Just trigger the shutdown + except Exception: + pass # Ignore errors during shutdown request + + # Set shutdown event as fallback + self._shutdown_event.set() + + # Wait for the server thread to finish + self._server_thread.join(timeout=5.0) + self._server_thread = None diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/application_runner.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/application_runner.py new file mode 100644 index 00000000..ebbc56f9 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/application_runner.py @@ -0,0 +1,42 @@ +import asyncio +from abc import ABC, abstractmethod +from typing import Any, Optional +from threading import Thread + + +class ApplicationRunner(ABC): + """Base class for application runners.""" + + def __init__(self, app: Any): + self._app = app + self._thread: Optional[Thread] = None + + @abstractmethod + async def _start_server(self) -> None: + raise NotImplementedError( + "Start server method must be implemented by subclasses" + ) + + async def _stop_server(self) -> None: + pass + + async def __aenter__(self) -> None: + + if self._thread: + raise RuntimeError("Server is already running") + + def target(): + asyncio.run(self._start_server()) + + self._thread = Thread(target=target, daemon=True) + self._thread.start() + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + + if self._thread: + await self._stop_server() + + self._thread.join() + self._thread = None + else: + raise RuntimeError("Server is not running") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/__init__.py new file mode 100644 index 00000000..1d59411e --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/__init__.py @@ -0,0 +1,7 @@ +from .agent_client import AgentClient +from .response_client import ResponseClient + +__all__ = [ + "AgentClient", + "ResponseClient", +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/agent_client.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/agent_client.py new file mode 100644 index 00000000..73067207 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/agent_client.py @@ -0,0 +1,165 @@ +import json +import asyncio +from typing import Optional, cast + +from aiohttp import ClientSession +from msal import ConfidentialClientApplication + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + DeliveryModes, + ChannelAccount, + ConversationAccount, +) +from microsoft_agents.testing.utils import populate_activity + +_DEFAULT_ACTIVITY_VALUES = { + "service_url": "http://localhost", + "channel_id": "test_channel", + "from_property": ChannelAccount(id="sender"), + "recipient": ChannelAccount(id="recipient"), + "locale": "en-US", +} + + +class AgentClient: + + def __init__( + self, + agent_url: str, + cid: str, + client_id: str, + tenant_id: str, + client_secret: str, + service_url: Optional[str] = None, + default_timeout: float = 5.0, + default_activity_data: Optional[Activity | dict] = None, + ): + self._agent_url = agent_url + self._cid = cid + self._client_id = client_id + self._tenant_id = tenant_id + self._client_secret = client_secret + self._service_url = service_url + self._headers = None + self._default_timeout = default_timeout + + self._client: Optional[ClientSession] = None + + self._default_activity_data: Activity | dict = ( + default_activity_data or _DEFAULT_ACTIVITY_VALUES + ) + + @property + def agent_url(self) -> str: + return self._agent_url + + @property + def service_url(self) -> Optional[str]: + return self._service_url + + async def get_access_token(self) -> str: + + msal_app = ConfidentialClientApplication( + client_id=self._client_id, + client_credential=self._client_secret, + authority=f"https://login.microsoftonline.com/{self._tenant_id}", + ) + + res = msal_app.acquire_token_for_client(scopes=[f"{self._client_id}/.default"]) + token = res.get("access_token") if res else None + if not token: + raise Exception("Could not obtain access token") + return token + + async def _init_client(self) -> None: + if not self._client: + if self._client_secret: + token = await self.get_access_token() + self._headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + else: + self._headers = {"Content-Type": "application/json"} + + self._client = ClientSession( + base_url=self._agent_url, headers=self._headers + ) + + async def send_request(self, activity: Activity, sleep: float = 0) -> str: + + await self._init_client() + assert self._client + + if activity.conversation: + activity.conversation.id = self._cid + else: + activity.conversation = ConversationAccount( + id=self._cid or "" + ) + + if self.service_url: + activity.service_url = self.service_url + + activity = populate_activity(activity, self._default_activity_data) + + async with self._client.post( + "api/messages", + headers=self._headers, + json=activity.model_dump( + by_alias=True, exclude_unset=True, exclude_none=True, mode="json" + ), + ) as response: + content = await response.text() + if not response.ok: + raise Exception(f"Failed to send activity: {response.status}") + await asyncio.sleep(sleep) + return content + + def _to_activity(self, activity_or_text: Activity | str) -> Activity: + if isinstance(activity_or_text, str): + activity = Activity( + type=ActivityTypes.message, + text=activity_or_text, + ) + return activity + else: + return cast(Activity, activity_or_text) + + async def send_activity( + self, + activity_or_text: Activity | str, + sleep: float = 0, + timeout: Optional[float] = None, + ) -> str: + timeout = timeout or self._default_timeout + activity = self._to_activity(activity_or_text) + content = await self.send_request(activity, sleep=sleep) + return content + + async def send_expect_replies( + self, + activity_or_text: Activity | str, + sleep: float = 0, + timeout: Optional[float] = None, + ) -> list[Activity]: + timeout = timeout or self._default_timeout + activity = self._to_activity(activity_or_text) + activity.delivery_mode = DeliveryModes.expect_replies + activity.service_url = ( + activity.service_url or "http://localhost" + ) # temporary fix + + content = await self.send_request(activity, sleep=sleep) + + activities_data = json.loads(content).get("activities", []) + activities = [Activity.model_validate(act) for act in activities_data] + + return activities + + async def close(self) -> None: + if self._client: + await self._client.close() + self._client = None diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/auto_client.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/auto_client.py new file mode 100644 index 00000000..dcea531b --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/auto_client.py @@ -0,0 +1,18 @@ +# from microsoft_agents.activity import Activity + +# from ..agent_client import AgentClient + +# class AutoClient: + +# def __init__(self, agent_client: AgentClient): +# self._agent_client = agent_client + +# async def generate_message(self) -> str: +# pass + +# async def run(self, max_turns: int = 10, time_between_turns: float = 2.0) -> None: + +# for i in range(max_turns): +# await self._agent_client.send_activity( +# Activity(type="message", text=self.generate_message()) +# ) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/response_client.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/response_client.py new file mode 100644 index 00000000..b283efdf --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/response_client.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import sys +from io import StringIO +from threading import Lock +import asyncio + +from aiohttp.web import Application, Request, Response + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, +) + +from ..aiohttp import AiohttpRunner + + +class ResponseClient: + + def __init__( + self, + host: str = "localhost", + port: int = 9873, + ): + self._app: Application = Application() + self._prev_stdout = None + service_endpoint = f"{host}:{port}" + self._host = host + self._port = port + if "http" not in service_endpoint: + service_endpoint = f"http://{service_endpoint}" + self._service_endpoint = service_endpoint + self._activities_list = [] + self._activities_list_lock = Lock() + + self._app.router.add_post( + "/v3/conversations/{path:.*}", self._handle_conversation + ) + + self._app_runner = AiohttpRunner(self._app, host, port) + + @property + def service_endpoint(self) -> str: + return self._service_endpoint + + async def __aenter__(self) -> ResponseClient: + self._prev_stdout = sys.stdout + sys.stdout = StringIO() + + await self._app_runner.__aenter__() + + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + if self._prev_stdout is not None: + sys.stdout = self._prev_stdout + + await self._app_runner.__aexit__(exc_type, exc_val, exc_tb) + + async def _handle_conversation(self, request: Request) -> Response: + try: + data = await request.json() + activity = Activity.model_validate(data) + + # conversation_id = ( + # activity.conversation.id if activity.conversation else None + # ) + + with self._activities_list_lock: + self._activities_list.append(activity) + + if any(map(lambda x: x.type == "streaminfo", activity.entities or [])): + await self._handle_streamed_activity(activity) + return Response(status=200, text="Stream info handled") + else: + if activity.type != ActivityTypes.typing: + await asyncio.sleep(0.1) # Simulate processing delay + return Response(status=200, text="Activity received") + except Exception as e: + return Response(status=500, text=str(e)) + + async def _handle_streamed_activity( + self, activity: Activity, *args, **kwargs + ) -> bool: + raise NotImplementedError("_handle_streamed_activity is not implemented yet.") + + async def pop(self) -> list[Activity]: + with self._activities_list_lock: + activities = self._activities_list[:] + self._activities_list.clear() + return activities diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/environment.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/environment.py new file mode 100644 index 00000000..0aa99f24 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/environment.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod +from typing import Awaitable, Callable + +from microsoft_agents.hosting.core import ( + AgentApplication, + ChannelAdapter, + Connections, + Authorization, + Storage, + TurnState, +) + +from .application_runner import ApplicationRunner + + +class Environment(ABC): + """A sample data object for integration tests.""" + + agent_application: AgentApplication[TurnState] + storage: Storage + adapter: ChannelAdapter + connection_manager: Connections + authorization: Authorization + + config: dict + + driver: Callable[[], Awaitable[None]] + + @abstractmethod + async def init_env(self, environ_config: dict) -> None: + """Initialize the environment.""" + raise NotImplementedError() + + @abstractmethod + def create_runner(self, *args, **kwargs) -> ApplicationRunner: + """Create an application runner for the environment. + + Subclasses may accept additional arguments as needed. + """ + raise NotImplementedError() diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/integration.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/integration.py new file mode 100644 index 00000000..3b459617 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/integration.py @@ -0,0 +1,121 @@ +import pytest + +import os +from typing import ( + Optional, + TypeVar, + Any, + AsyncGenerator, +) + +import aiohttp.web +from dotenv import load_dotenv + +from microsoft_agents.testing.utils import get_host_and_port +from .environment import Environment +from .client import AgentClient, ResponseClient +from .sample import Sample + +T = TypeVar("T", bound=type) +AppT = TypeVar("AppT", bound=aiohttp.web.Application) # for future extension w/ Union + + +class Integration: + """Provides integration test fixtures.""" + + _sample_cls: Optional[type[Sample]] = None + _environment_cls: Optional[type[Environment]] = None + + _config: dict[str, Any] = {} + + _service_url: Optional[str] = None + _agent_url: Optional[str] = None + _cid: Optional[str] = None + _client_id: Optional[str] = None + _tenant_id: Optional[str] = None + _client_secret: Optional[str] = None + + _environment: Environment + _sample: Sample + _agent_client: AgentClient + _response_client: ResponseClient + + @property + def service_url(self) -> str: + return self._service_url or self._config.get("service_url", "") + + @property + def agent_url(self) -> str: + return self._agent_url or self._config.get("agent_url", "") + + @pytest.fixture + async def environment(self): + """Provides the test environment instance.""" + if self._environment_cls: + assert self._sample_cls + environment = self._environment_cls() + await environment.init_env(await self._sample_cls.get_config()) + yield environment + else: + yield None + + @pytest.fixture + async def sample(self, environment): + """Provides the sample instance.""" + if environment: + assert self._sample_cls + sample = self._sample_cls(environment) + await sample.init_app() + # host, port = get_host_and_port(self.agent_url) + app_runner = environment.create_runner(sample.app) + async with app_runner: + yield sample + else: + yield None + + def create_agent_client(self) -> AgentClient: + if not self._config: + self._config = {} + + load_dotenv("./src/tests/.env") + self._config.update( + { + "client_id": os.getenv( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID", "" + ), + "tenant_id": os.getenv( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID", "" + ), + "client_secret": os.getenv( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET", "" + ), + } + ) + + agent_client = AgentClient( + agent_url=self.agent_url, + cid=self._cid or self._config.get("cid", ""), + client_id=self._client_id or self._config.get("client_id", ""), + tenant_id=self._tenant_id or self._config.get("tenant_id", ""), + client_secret=self._client_secret or self._config.get("client_secret", ""), + ) + return agent_client + + @pytest.fixture + async def agent_client( + self, sample, environment + ) -> AsyncGenerator[AgentClient, None]: + agent_client = self.create_agent_client() + yield agent_client + await agent_client.close() + + async def _create_response_client(self) -> ResponseClient: + host, port = get_host_and_port(self.service_url) + assert host and port + return ResponseClient(host=host, port=port) + + @pytest.fixture + async def response_client(self) -> AsyncGenerator[ResponseClient, None]: + """Provides the response client instance.""" + async with await self._create_response_client() as response_client: + yield response_client diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/sample.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/sample.py new file mode 100644 index 00000000..6dde3668 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/sample.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod + +from .environment import Environment + + +class Sample(ABC): + """Base class for all samples.""" + + def __init__(self, environment: Environment, **kwargs): + self.env = environment + + @classmethod + async def get_config(cls) -> dict: + """Retrieve the configuration for the sample.""" + return {} + + @abstractmethod + async def init_app(self): + """Initialize the application for the sample.""" diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/sdk_config.py b/dev/microsoft-agents-testing/microsoft_agents/testing/sdk_config.py new file mode 100644 index 00000000..c1824ae5 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/sdk_config.py @@ -0,0 +1,44 @@ +import os +from copy import deepcopy +from dotenv import load_dotenv, dotenv_values +from typing import Optional + +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.hosting.core import AgentAuthConfiguration + + +class SDKConfig: + """Loads and provides access to SDK configuration from a .env file or environment variables. + + Immutable access to the configuration dictionary is provided via the `config` property. + """ + + def __init__( + self, env_path: Optional[str] = None, load_into_environment: bool = False + ): + """Initializes the SDKConfig by loading configuration from a .env file or environment variables. + + :param env_path: Optional path to the .env file. If None, defaults to '.env' in the current directory. + :param load_into_environment: If True, loads the .env file directly into the configuration dictionary (does NOT load into environment variables). If False, loads the .env file into environment variables first, then loads the configuration from those environment variables. + """ + if load_into_environment: + self._config = load_configuration_from_env( + dotenv_values(env_path) + ) # Load .env file + else: + load_dotenv(env_path) # Load .env file into environment variables + self._config = load_configuration_from_env( + os.environ + ) # Load from environment variables + + @property + def config(self) -> dict: + """Returns the loaded configuration dictionary.""" + return deepcopy(self._config) + + def get_connection( + self, connection_name: str = "SERVICE_CONNECTION" + ) -> AgentAuthConfiguration: + """Creates an AgentAuthConfiguration from a provided config object.""" + data = self._config["CONNECTIONS"][connection_name]["SETTINGS"] + return AgentAuthConfiguration(**data) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/__init__.py new file mode 100644 index 00000000..0c902992 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/__init__.py @@ -0,0 +1,7 @@ +from .populate_activity import populate_activity +from .urls import get_host_and_port + +__all__ = [ + "populate_activity", + "get_host_and_port", +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/populate_activity.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/populate_activity.py new file mode 100644 index 00000000..a6b1c19f --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/populate_activity.py @@ -0,0 +1,16 @@ +from microsoft_agents.activity import Activity + + +def populate_activity(original: Activity, defaults: Activity | dict) -> Activity: + """Populate an Activity object with default values.""" + + if isinstance(defaults, Activity): + defaults = defaults.model_dump(exclude_unset=True) + + new_activity = original.model_copy() + + for key in defaults.keys(): + if getattr(new_activity, key) is None: + setattr(new_activity, key, defaults[key]) + + return new_activity \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/urls.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/urls.py new file mode 100644 index 00000000..d964ebd2 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/urls.py @@ -0,0 +1,10 @@ +from urllib.parse import urlparse + + +def get_host_and_port(url: str) -> tuple[str, int]: + """Extract host and port from a URL.""" + + parsed_url = urlparse(url) + host = parsed_url.hostname or "localhost" + port = parsed_url.port or (443 if parsed_url.scheme == "https" else 80) + return host, port diff --git a/dev/microsoft-agents-testing/pyproject.toml b/dev/microsoft-agents-testing/pyproject.toml new file mode 100644 index 00000000..cf659e6f --- /dev/null +++ b/dev/microsoft-agents-testing/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "microsoft-agents-hosting-core" +dynamic = ["version", "dependencies"] +description = "Core library for Microsoft Agents" +readme = {file = "README.md", content-type = "text/markdown"} +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/microsoft/Agents" diff --git a/dev/microsoft-agents-testing/setup.py b/dev/microsoft-agents-testing/setup.py new file mode 100644 index 00000000..02fb3e84 --- /dev/null +++ b/dev/microsoft-agents-testing/setup.py @@ -0,0 +1,18 @@ +from os import environ +from setuptools import setup + +package_version = environ.get("PackageVersion", "0.0.0") + +setup( + version=package_version, + install_requires=[ + "microsoft-agents-activity", + "microsoft-agents-hosting-core", + "microsoft-agents-authentication-msal", + "microsoft-agents-hosting-aiohttp", + "pyjwt>=2.10.1", + "isodate>=0.6.1", + "azure-core>=1.30.0", + "python-dotenv>=1.1.1", + ], +) diff --git a/dev/microsoft-agents-testing/tests/__init__.py b/dev/microsoft-agents-testing/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/integration/__init__.py b/dev/microsoft-agents-testing/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/integration/core/__init__.py b/dev/microsoft-agents-testing/tests/integration/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/integration/core/_common.py b/dev/microsoft-agents-testing/tests/integration/core/_common.py new file mode 100644 index 00000000..cd22114a --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/_common.py @@ -0,0 +1,15 @@ +from microsoft_agents.testing import ApplicationRunner + + +class SimpleRunner(ApplicationRunner): + async def _start_server(self) -> None: + self._app["running"] = True + + @property + def app(self): + return self._app + + +class OtherSimpleRunner(SimpleRunner): + async def _stop_server(self) -> None: + self._app["running"] = False diff --git a/dev/microsoft-agents-testing/tests/integration/core/client/__init__.py b/dev/microsoft-agents-testing/tests/integration/core/client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/integration/core/client/_common.py b/dev/microsoft-agents-testing/tests/integration/core/client/_common.py new file mode 100644 index 00000000..00b4291f --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/client/_common.py @@ -0,0 +1,10 @@ +class DEFAULTS: + + host = "localhost" + response_port = 9873 + agent_url = f"http://{host}:8000/" + service_url = f"http://{host}:{response_port}" + cid = "test-cid" + client_id = "test-client-id" + tenant_id = "test-tenant-id" + client_secret = "test-client-secret" diff --git a/dev/microsoft-agents-testing/tests/integration/core/client/test_agent_client.py b/dev/microsoft-agents-testing/tests/integration/core/client/test_agent_client.py new file mode 100644 index 00000000..3bc59452 --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/client/test_agent_client.py @@ -0,0 +1,84 @@ +import json + +import pytest +from aioresponses import aioresponses +from msal import ConfidentialClientApplication + +from microsoft_agents.activity import Activity +from microsoft_agents.testing import AgentClient + +from ._common import DEFAULTS + + +class TestAgentClient: + + @pytest.fixture + async def agent_client(self): + client = AgentClient( + agent_url=DEFAULTS.agent_url, + cid=DEFAULTS.cid, + client_id=DEFAULTS.client_id, + tenant_id=DEFAULTS.tenant_id, + client_secret=DEFAULTS.client_secret, + service_url=DEFAULTS.service_url, + ) + yield client + await client.close() + + @pytest.fixture + def aioresponses_mock(self): + with aioresponses() as mocked: + yield mocked + + @pytest.mark.asyncio + async def test_send_activity(self, mocker, agent_client, aioresponses_mock): + mocker.patch.object( + AgentClient, "get_access_token", return_value="mocked_token" + ) + mocker.patch.object( + ConfidentialClientApplication, + "__new__", + return_value=mocker.Mock(spec=ConfidentialClientApplication), + ) + + assert agent_client.agent_url + aioresponses_mock.post( + f"{agent_client.agent_url}api/messages", + payload={"response": "Response from service"}, + ) + + response = await agent_client.send_activity("Hello, World!") + data = json.loads(response) + assert data == {"response": "Response from service"} + + @pytest.mark.asyncio + async def test_send_expect_replies(self, mocker, agent_client, aioresponses_mock): + mocker.patch.object( + AgentClient, "get_access_token", return_value="mocked_token" + ) + mocker.patch.object( + ConfidentialClientApplication, + "__new__", + return_value=mocker.Mock(spec=ConfidentialClientApplication), + ) + + assert agent_client.agent_url + activities = [ + Activity(type="message", text="Response from service"), + Activity(type="message", text="Another response"), + ] + aioresponses_mock.post( + agent_client.agent_url + "api/messages", + payload={ + "activities": [ + activity.model_dump(by_alias=True, exclude_none=True) + for activity in activities + ], + }, + ) + + replies = await agent_client.send_expect_replies("Hello, World!") + assert len(replies) == 2 + assert replies[0].text == "Response from service" + assert replies[1].text == "Another response" + assert replies[0].type == replies[1].type == "message" diff --git a/dev/microsoft-agents-testing/tests/integration/core/client/test_response_client.py b/dev/microsoft-agents-testing/tests/integration/core/client/test_response_client.py new file mode 100644 index 00000000..f5d1ed6d --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/client/test_response_client.py @@ -0,0 +1,45 @@ +import pytest +import asyncio +from aiohttp import ClientSession + +from microsoft_agents.activity import Activity +from microsoft_agents.testing import ResponseClient + +from ._common import DEFAULTS + + +class TestResponseClient: + + @pytest.fixture + async def response_client(self): + async with ResponseClient( + host=DEFAULTS.host, port=DEFAULTS.response_port + ) as client: + yield client + + @pytest.mark.asyncio + async def test_init(self, response_client): + assert response_client.service_endpoint == DEFAULTS.service_url + + @pytest.mark.asyncio + async def test_endpoint(self, response_client): + + activity = Activity(type="message", text="Hello, World!") + + async with ClientSession() as session: + async with session.post( + f"{response_client.service_endpoint}/v3/conversations/test-conv", + json=activity.model_dump(by_alias=True, exclude_none=True), + ) as resp: + assert resp.status == 200 + text = await resp.text() + assert text == "Activity received" + + await asyncio.sleep(0.1) # Give some time for the server to process + + activities = await response_client.pop() + assert len(activities) == 1 + assert activities[0].type == "message" + assert activities[0].text == "Hello, World!" + + assert (await response_client.pop()) == [] diff --git a/dev/microsoft-agents-testing/tests/integration/core/test_application_runner.py b/dev/microsoft-agents-testing/tests/integration/core/test_application_runner.py new file mode 100644 index 00000000..719203b7 --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/test_application_runner.py @@ -0,0 +1,40 @@ +import pytest +from time import sleep + +from ._common import SimpleRunner, OtherSimpleRunner + + +class TestApplicationRunner: + + @pytest.mark.asyncio + async def test_simple_runner(self): + + app = {} + runner = SimpleRunner(app) + async with runner: + sleep(0.1) + assert app["running"] is True + + assert app["running"] is True + + @pytest.mark.asyncio + async def test_other_simple_runner(self): + + app = {} + runner = OtherSimpleRunner(app) + async with runner: + sleep(0.1) + assert app["running"] is True + + assert app["running"] is False + + @pytest.mark.asyncio + async def test_double_start(self): + + app = {} + runner = SimpleRunner(app) + async with runner: + sleep(0.1) + with pytest.raises(RuntimeError): + async with runner: + pass diff --git a/dev/microsoft-agents-testing/tests/integration/core/test_integration_from_sample.py b/dev/microsoft-agents-testing/tests/integration/core/test_integration_from_sample.py new file mode 100644 index 00000000..998c0928 --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/test_integration_from_sample.py @@ -0,0 +1,57 @@ +import pytest +import asyncio +from copy import copy + +from microsoft_agents.testing import ApplicationRunner, Environment, Integration, Sample + +from ._common import SimpleRunner + + +class SimpleEnvironment(Environment): + """A simple implementation of the Environment for testing.""" + + async def init_env(self, environ_config: dict) -> None: + self.config = environ_config + # Initialize other components as needed + + def create_runner(self, *args) -> ApplicationRunner: + return SimpleRunner(copy(self.config)) + + +class SimpleSample(Sample): + """A simple implementation of the Sample for testing.""" + + def __init__(self, environment: Environment, **kwargs): + super().__init__(environment, **kwargs) + self.data = kwargs.get("data", "default_data") + self.other_data = None + + @classmethod + async def get_config(cls) -> dict: + return {"sample_key": "sample_value"} + + async def init_app(self): + await asyncio.sleep(0.1) # Simulate some initialization delay + self.other_data = len(self.env.config) + + @property + def app(self) -> None: + return None + + +class TestIntegrationFromSample(Integration): + _sample_cls = SimpleSample + _environment_cls = SimpleEnvironment + + @pytest.mark.asyncio + async def test_sample_integration(self, sample, environment): + """Test the integration of SimpleSample with SimpleEnvironment.""" + + assert environment.config == {"sample_key": "sample_value"} + + assert sample.env is environment + assert sample.data == "default_data" + assert sample.other_data == 1 + + runner = environment.create_runner() + assert runner.app == {"sample_key": "sample_value"} diff --git a/dev/microsoft-agents-testing/tests/integration/core/test_integration_from_service_url.py b/dev/microsoft-agents-testing/tests/integration/core/test_integration_from_service_url.py new file mode 100644 index 00000000..4262a624 --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/core/test_integration_from_service_url.py @@ -0,0 +1,51 @@ +import pytest +import asyncio +import requests +from aioresponses import aioresponses, CallbackResult + +from microsoft_agents.testing import Integration + + +class TestIntegrationFromURL(Integration): + _agent_url = "http://localhost:8000/" + _service_url = "http://localhost:8001/" + + @pytest.mark.asyncio + async def test_service_url_integration(self, agent_client): + """Test the integration using a service URL.""" + + with aioresponses() as mocked: + + mocked.post( + f"{self.agent_url}api/messages", status=200, body="Service response" + ) + + res = await agent_client.send_activity("Hello, service!") + assert res == "Service response" + + @pytest.mark.asyncio + async def test_service_url_integration_with_response_side_effect( + self, agent_client, response_client + ): + """Test the integration using a service URL.""" + + with aioresponses() as mocked: + + def callback(url, **kwargs): + requests.post( + f"{self.service_url}/v3/conversations/test-conv", + json=kwargs.get("json"), + ) + return CallbackResult(status=200, body="Service response") + + mocked.post(f"{self.agent_url}api/messages", callback=callback) + + res = await agent_client.send_activity("Hello, service!") + assert res == "Service response" + + await asyncio.sleep(1) + + activities = await response_client.pop() + assert len(activities) == 1 + assert activities[0].type == "message" + assert activities[0].text == "Hello, service!" diff --git a/dev/microsoft-agents-testing/tests/manual_test/__init__.py b/dev/microsoft-agents-testing/tests/manual_test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/manual_test/env.TEMPLATE b/dev/microsoft-agents-testing/tests/manual_test/env.TEMPLATE new file mode 100644 index 00000000..01dccc7c --- /dev/null +++ b/dev/microsoft-agents-testing/tests/manual_test/env.TEMPLATE @@ -0,0 +1,3 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=tenant-id \ No newline at end of file diff --git a/dev/microsoft-agents-testing/tests/manual_test/main.py b/dev/microsoft-agents-testing/tests/manual_test/main.py new file mode 100644 index 00000000..7201dfef --- /dev/null +++ b/dev/microsoft-agents-testing/tests/manual_test/main.py @@ -0,0 +1,54 @@ +import os +import asyncio + +from microsoft_agents.testing import ( + AiohttpEnvironment, + AgentClient, +) +from ..samples import QuickstartSample + +from dotenv import load_dotenv + + +async def main(): + + env = AiohttpEnvironment() + await env.init_env(await QuickstartSample.get_config()) + sample = QuickstartSample(env) + await sample.init_app() + + host, port = "localhost", 3978 + + load_dotenv("./src/tests/.env") + config = { + "client_id": os.getenv( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID", "" + ), + "tenant_id": os.getenv( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID", "" + ), + "client_secret": os.getenv( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET", "" + ), + } + + client = AgentClient( + agent_url="http://localhost:3978/", + cid=config.get("cid", ""), + client_id=config.get("client_id", ""), + tenant_id=config.get("tenant_id", ""), + client_secret=config.get("client_secret", ""), + ) + + async with env.create_runner(host, port): + print(f"Server running at http://{host}:{port}/api/messages") + await asyncio.sleep(1) + res = await client.send_expect_replies("Hello, Agent!") + print("\nReply from agent:") + print(res) + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/dev/microsoft-agents-testing/tests/samples/__init__.py b/dev/microsoft-agents-testing/tests/samples/__init__.py new file mode 100644 index 00000000..a77ee72e --- /dev/null +++ b/dev/microsoft-agents-testing/tests/samples/__init__.py @@ -0,0 +1,3 @@ +from .quickstart_sample import QuickstartSample + +__all__ = ["QuickstartSample"] diff --git a/dev/microsoft-agents-testing/tests/samples/quickstart_sample.py b/dev/microsoft-agents-testing/tests/samples/quickstart_sample.py new file mode 100644 index 00000000..26b1fef0 --- /dev/null +++ b/dev/microsoft-agents-testing/tests/samples/quickstart_sample.py @@ -0,0 +1,63 @@ +import re +import os +import sys +import traceback + +from dotenv import load_dotenv + +from microsoft_agents.activity import ConversationUpdateTypes +from microsoft_agents.hosting.core import AgentApplication, TurnContext, TurnState +from microsoft_agents.testing.integration.core import Sample + + +class QuickstartSample(Sample): + """A quickstart sample implementation.""" + + @classmethod + async def get_config(cls) -> dict: + """Retrieve the configuration for the sample.""" + + load_dotenv("./src/tests/.env") + + return { + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID": os.getenv( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID" + ), + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET": os.getenv( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET" + ), + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID": os.getenv( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID" + ), + } + + async def init_app(self): + """Initialize the application for the quickstart sample.""" + + app: AgentApplication[TurnState] = self.env.agent_application + + @app.conversation_update(ConversationUpdateTypes.MEMBERS_ADDED) + async def on_members_added(context: TurnContext, state: TurnState) -> None: + await context.send_activity( + "Welcome to the empty agent! " + "This agent is designed to be a starting point for your own agent development." + ) + + @app.message(re.compile(r"^hello$")) + async def on_hello(context: TurnContext, state: TurnState) -> None: + await context.send_activity("Hello!") + + @app.activity("message") + async def on_message(context: TurnContext, state: TurnState) -> None: + await context.send_activity(f"you said: {context.activity.text}") + + @app.error + async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.")