-
Notifications
You must be signed in to change notification settings - Fork 15
Add E2E tests for hermes_cli adapter #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
AjayThorve
merged 9 commits into
NVIDIA:main
from
dagardner-nv:david-hermes_cli-e2e-tests
Jun 25, 2026
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
e64b722
Test skill
dagardner-nv ec0ea5c
CR Header
dagardner-nv 02b3568
Add a mock api server for e2e tests
dagardner-nv d2bdc2d
New fixtures
dagardner-nv 3bfaf78
Add E2E test to verify artifacts
dagardner-nv 9b9d26b
Refactor tests
dagardner-nv a10f6db
Fix websocket warnings
dagardner-nv 6efe7df
Add docstrings
dagardner-nv f4b2577
Install relay in CI
dagardner-nv File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| --- | ||
| name: python-tests | ||
| description: Python tests for Fabric; use this when writing tests | ||
| author: NVIDIA Corporation and Affiliates | ||
| license: Apache-2.0 | ||
| --- | ||
|
|
||
|
|
||
| # Python Test Style | ||
|
|
||
| - Pytest is used to run tests. | ||
| - Do not add `@pytest.mark.asyncio` to any test. Async tests are automatically detected and run by the async runner; the decorator is unnecessary clutter. | ||
| - Do not add a `-> None` return type annotation to test functions. This is not a common convention in pytest and adds unnecessary verbosity. | ||
| - When mocking a class, do not define a new class. Use `unittest.mock.MagicMock` or `unittest.mock.AsyncMock`, with the `spec` constructor argument when necessary. | ||
| - The name of the mocked class should be prefixed with `mock`, not `fake`. | ||
| - Prefer pytest fixtures over helper methods. | ||
| - Do not repeat fixtures, if a fixture is needed in multiple test files, place it in a `conftest.py` file. | ||
| - When creating a fixture follow this pattern: | ||
| ```python | ||
| @pytest.fixture(name="<fixture_name>"[, scope="<scope>"]) | ||
| def <fixture_name>_fixture() -> <return_type>: | ||
| ... | ||
| ``` | ||
| Only specify the scope argument when the value is something other than "function". | ||
| - Prefer `pytest.mark.parametrize` over creating individual tests for | ||
| different input types. | ||
| - If a fixture is needed for a test, but either does not return a value or the value is not used in the test, use the `@pytest.mark.usefixtures` decorator. | ||
|
|
||
| ## Common Commands | ||
|
|
||
| ```bash | ||
| # Focused test loop | ||
| uv run pytest -k "<pattern>" | ||
|
|
||
| # Run all tests | ||
| uv run pytest | ||
| ``` | ||
|
|
||
| ## References | ||
|
|
||
| - `pyproject.toml` | ||
| - `tests/conftest.py` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,191 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import json | ||
| import threading | ||
| import time | ||
| from collections.abc import Iterator | ||
| from contextlib import contextmanager | ||
|
|
||
| from fastapi import FastAPI, Request | ||
| from fastapi.responses import JSONResponse, StreamingResponse | ||
| import uvicorn | ||
|
|
||
|
|
||
| @contextmanager | ||
| def mock_api_server(port: int) -> Iterator[str]: | ||
| """ | ||
| Context manager for a mock API server. | ||
|
|
||
| Use the /_requests endpoint to inspect captured chat-completion payloads after a test action. | ||
| Use the /_scenario endpoint to configure the server to return a specific status code for subsequent requests. | ||
|
|
||
| Args: | ||
| port (int): The port on which the server will listen. | ||
|
|
||
| Yields: | ||
| str: The base URL of the mock API server. | ||
| """ | ||
|
|
||
| app = FastAPI() | ||
| app.state.requests = [] | ||
| app.state.status_code = 200 | ||
|
|
||
| @app.get("/health") | ||
| def health() -> dict[str, str]: | ||
| return {"status": "ok"} | ||
|
|
||
| @app.get("/v1/models") | ||
| def models() -> dict[str, object]: | ||
| return { | ||
| "object": "list", | ||
| "data": [ | ||
| { | ||
| "id": "fabric-echo", | ||
| "object": "model", | ||
| "created": 0, | ||
| "owned_by": "fabric-test", | ||
| } | ||
| ], | ||
| } | ||
|
|
||
| @app.get("/_requests") | ||
| def requests() -> list[dict[str, object]]: | ||
| """GET this after a test action to inspect captured chat-completion payloads.""" | ||
|
|
||
| return list(app.state.requests) | ||
|
|
||
| @app.post("/_scenario") | ||
| async def scenario(request: Request) -> dict[str, int]: | ||
| """POST JSON such as {"status_code": 429} before a test action to change responses.""" | ||
|
|
||
| payload = await request.json() | ||
| app.state.status_code = int(payload.get("status_code", 200)) | ||
| return {"status_code": app.state.status_code} | ||
|
|
||
| @app.post("/v1/chat/completions") | ||
| async def chat_completions(request: Request): | ||
| payload = await request.json() | ||
| app.state.requests.append(payload) | ||
| if app.state.status_code != 200: | ||
| return JSONResponse( | ||
| status_code=app.state.status_code, | ||
| content={ | ||
| "error": { | ||
| "message": f"configured status {app.state.status_code}", | ||
| "type": "api_error", | ||
| } | ||
| }, | ||
| ) | ||
|
|
||
| messages = payload.get("messages") or [] | ||
| user_messages = [ | ||
| message | ||
| for message in messages | ||
| if isinstance(message, dict) and message.get("role") == "user" | ||
| ] | ||
| latest = user_messages[-1].get("content", "") if user_messages else "" | ||
| content = f"echo user_count={len(user_messages)} latest={latest}" | ||
| if payload.get("stream") is True: | ||
| return StreamingResponse( | ||
| _stream_chat_completion(payload, content), | ||
| media_type="text/event-stream", | ||
| ) | ||
|
|
||
| return JSONResponse( | ||
| { | ||
| "id": "chatcmpl-fabric-test", | ||
| "object": "chat.completion", | ||
| "created": 0, | ||
| "model": payload.get("model", "fabric-echo"), | ||
| "choices": [ | ||
| { | ||
| "index": 0, | ||
| "message": {"role": "assistant", "content": content}, | ||
| "finish_reason": "stop", | ||
| } | ||
| ], | ||
| "usage": { | ||
| "prompt_tokens": 0, | ||
| "completion_tokens": 0, | ||
| "total_tokens": 0, | ||
| }, | ||
| } | ||
| ) | ||
|
|
||
| base_url = f"http://127.0.0.1:{port}" | ||
| config = uvicorn.Config( | ||
| app, | ||
| host="127.0.0.1", | ||
| port=port, | ||
| log_level="warning", | ||
| access_log=False, | ||
| lifespan="off", | ||
| ws="none", | ||
| ) | ||
| server = uvicorn.Server(config) | ||
| thread = threading.Thread(target=server.run, daemon=True) | ||
| thread.start() | ||
|
|
||
| deadline = time.monotonic() + 5 | ||
| while not server.started: | ||
| if not thread.is_alive(): | ||
| raise RuntimeError("mock API server failed to start") | ||
| if time.monotonic() > deadline: | ||
| raise RuntimeError("mock API server did not start within 5 seconds") | ||
| time.sleep(0.01) | ||
|
|
||
| try: | ||
| yield base_url | ||
| finally: | ||
| server.should_exit = True | ||
| thread.join(timeout=5) | ||
|
|
||
|
|
||
| def _stream_chat_completion(payload: dict[str, object], content: str) -> Iterator[str]: | ||
| model = payload.get("model", "fabric-echo") | ||
| chunks = [ | ||
| { | ||
| "id": "chatcmpl-fabric-test", | ||
| "object": "chat.completion.chunk", | ||
| "created": 0, | ||
| "model": model, | ||
| "choices": [ | ||
| { | ||
| "index": 0, | ||
| "delta": {"role": "assistant"}, | ||
| "finish_reason": None, | ||
| } | ||
| ], | ||
| }, | ||
| { | ||
| "id": "chatcmpl-fabric-test", | ||
| "object": "chat.completion.chunk", | ||
| "created": 0, | ||
| "model": model, | ||
| "choices": [ | ||
| { | ||
| "index": 0, | ||
| "delta": {"content": content}, | ||
| "finish_reason": None, | ||
| } | ||
| ], | ||
| }, | ||
| { | ||
| "id": "chatcmpl-fabric-test", | ||
| "object": "chat.completion.chunk", | ||
| "created": 0, | ||
| "model": model, | ||
| "choices": [ | ||
| { | ||
| "index": 0, | ||
| "delta": {}, | ||
| "finish_reason": "stop", | ||
| } | ||
| ], | ||
| }, | ||
| ] | ||
|
|
||
| for chunk in chunks: | ||
| yield f"data: {json.dumps(chunk)}\n\n" | ||
| yield "data: [DONE]\n\n" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| from pathlib import Path | ||
|
|
||
| import yaml | ||
|
|
||
|
|
||
| def update_hermes_cli_relay_base_url(code_review_agent_dir: Path, api_server: str): | ||
| """ | ||
| Update the base URL in the Hermes CLI relay profile. | ||
|
|
||
| Since the api_server uses a random available TCP port, the base_url needs to be updated for each test. | ||
|
|
||
| Args: | ||
| code_review_agent_dir (Path): The path to the code review agent directory. | ||
| api_server (str): The API server URL. | ||
| """ | ||
| profile_path = code_review_agent_dir / "profiles" / "hermes-cli-relay.yaml" | ||
| profile = yaml.safe_load(profile_path.read_text()) | ||
| profile["harness"]["settings"]["base_url"] = f"{api_server}/v1" | ||
| profile_path.write_text(yaml.safe_dump(profile, sort_keys=False)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.