chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,365 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.artifacts.base_artifact_service import ArtifactVersion
|
||||
from google.adk.cli.adk_web_server import RunAgentRequest
|
||||
from google.adk.cli.conformance.adk_web_server_client import AdkWebServerClient
|
||||
from google.adk.events.event import Event
|
||||
from google.adk.sessions.session import Session
|
||||
from google.genai import types
|
||||
import pytest
|
||||
|
||||
|
||||
def test_init_default_values():
|
||||
client = AdkWebServerClient()
|
||||
assert client.base_url == "http://127.0.0.1:8000"
|
||||
assert client.timeout == 30.0
|
||||
|
||||
|
||||
def test_init_custom_values():
|
||||
client = AdkWebServerClient(
|
||||
base_url="https://custom.example.com/", timeout=60.0
|
||||
)
|
||||
assert client.base_url == "https://custom.example.com"
|
||||
assert client.timeout == 60.0
|
||||
|
||||
|
||||
def test_init_strips_trailing_slash():
|
||||
client = AdkWebServerClient(base_url="http://test.com/")
|
||||
assert client.base_url == "http://test.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session():
|
||||
client = AdkWebServerClient()
|
||||
|
||||
# Mock the HTTP response
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"id": "test_session",
|
||||
"app_name": "test_app",
|
||||
"user_id": "test_user",
|
||||
"events": [],
|
||||
"state": {},
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
session = await client.get_session(
|
||||
app_name="test_app", user_id="test_user", session_id="test_session"
|
||||
)
|
||||
|
||||
assert isinstance(session, Session)
|
||||
assert session.id == "test_session"
|
||||
mock_client.get.assert_called_once_with(
|
||||
"/apps/test_app/users/test_user/sessions/test_session"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session():
|
||||
client = AdkWebServerClient()
|
||||
|
||||
# Mock the HTTP response
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"id": "new_session",
|
||||
"app_name": "test_app",
|
||||
"user_id": "test_user",
|
||||
"events": [],
|
||||
"state": {"key": "value"},
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
session = await client.create_session(
|
||||
app_name="test_app", user_id="test_user", state={"key": "value"}
|
||||
)
|
||||
|
||||
assert isinstance(session, Session)
|
||||
assert session.id == "new_session"
|
||||
mock_client.post.assert_called_once_with(
|
||||
"/apps/test_app/users/test_user/sessions",
|
||||
json={"state": {"key": "value"}},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_session():
|
||||
client = AdkWebServerClient()
|
||||
|
||||
# Mock the HTTP response
|
||||
mock_response = MagicMock()
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.delete.return_value = mock_response
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
await client.delete_session(
|
||||
app_name="test_app", user_id="test_user", session_id="test_session"
|
||||
)
|
||||
|
||||
mock_client.delete.assert_called_once_with(
|
||||
"/apps/test_app/users/test_user/sessions/test_session"
|
||||
)
|
||||
mock_response.raise_for_status.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_session():
|
||||
client = AdkWebServerClient()
|
||||
|
||||
# Mock the HTTP response
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"id": "test_session",
|
||||
"app_name": "test_app",
|
||||
"user_id": "test_user",
|
||||
"events": [],
|
||||
"state": {"key": "updated_value", "new_key": "new_value"},
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.patch.return_value = mock_response
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
state_delta = {"key": "updated_value", "new_key": "new_value"}
|
||||
session = await client.update_session(
|
||||
app_name="test_app",
|
||||
user_id="test_user",
|
||||
session_id="test_session",
|
||||
state_delta=state_delta,
|
||||
)
|
||||
|
||||
assert isinstance(session, Session)
|
||||
assert session.id == "test_session"
|
||||
assert session.state == {"key": "updated_value", "new_key": "new_value"}
|
||||
mock_client.patch.assert_called_once_with(
|
||||
"/apps/test_app/users/test_user/sessions/test_session",
|
||||
json={"state_delta": state_delta},
|
||||
)
|
||||
mock_response.raise_for_status.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent():
|
||||
client = AdkWebServerClient()
|
||||
|
||||
# Create sample events
|
||||
event1 = Event(
|
||||
author="test_agent",
|
||||
invocation_id="test_invocation_1",
|
||||
content=types.Content(role="model", parts=[types.Part(text="Hello")]),
|
||||
)
|
||||
event2 = Event(
|
||||
author="test_agent",
|
||||
invocation_id="test_invocation_2",
|
||||
content=types.Content(role="model", parts=[types.Part(text="World")]),
|
||||
)
|
||||
|
||||
# Mock streaming response
|
||||
class MockStreamResponse:
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
async def aiter_lines(self):
|
||||
yield f"data:{json.dumps(event1.model_dump())}"
|
||||
yield "data:" # Empty line should be ignored
|
||||
yield f"data:{json.dumps(event2.model_dump())}"
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
def mock_stream(*_args, **_kwargs):
|
||||
return MockStreamResponse()
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.stream = mock_stream
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
request = RunAgentRequest(
|
||||
app_name="test_app",
|
||||
user_id="test_user",
|
||||
session_id="test_session",
|
||||
new_message=types.Content(
|
||||
role="user", parts=[types.Part(text="Hello")]
|
||||
),
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in client.run_agent(request):
|
||||
events.append(event)
|
||||
|
||||
assert len(events) == 2
|
||||
assert all(isinstance(event, Event) for event in events)
|
||||
assert events[0].invocation_id == "test_invocation_1"
|
||||
assert events[1].invocation_id == "test_invocation_2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_raises_on_streamed_error():
|
||||
client = AdkWebServerClient()
|
||||
|
||||
class MockStreamResponse:
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
async def aiter_lines(self):
|
||||
yield 'data: {"error": "boom"}'
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
def mock_stream(*_args, **_kwargs):
|
||||
return MockStreamResponse()
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.stream = mock_stream
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
request = RunAgentRequest(
|
||||
app_name="test_app",
|
||||
user_id="test_user",
|
||||
session_id="test_session",
|
||||
new_message=types.Content(role="user", parts=[types.Part(text="Hi")]),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
async for _ in client.run_agent(request):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_artifact_version_metadata():
|
||||
client = AdkWebServerClient()
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"version": 2,
|
||||
"canonicalUri": (
|
||||
"artifact://apps/app/users/user/sessions/session/"
|
||||
"artifacts/report/versions/2"
|
||||
),
|
||||
"customMetadata": {"foo": "bar"},
|
||||
"createTime": 123.4,
|
||||
"mimeType": "text/plain",
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
metadata = await client.get_artifact_version_metadata(
|
||||
app_name="app",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
artifact_name="report",
|
||||
version=2,
|
||||
)
|
||||
|
||||
assert isinstance(metadata, ArtifactVersion)
|
||||
assert metadata.version == 2
|
||||
assert metadata.custom_metadata == {"foo": "bar"}
|
||||
mock_client.get.assert_called_once_with(
|
||||
"/apps/app/users/user/sessions/session/artifacts/report/versions/2/metadata"
|
||||
)
|
||||
mock_response.raise_for_status.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_artifact_versions_metadata():
|
||||
client = AdkWebServerClient()
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = [
|
||||
{
|
||||
"version": 0,
|
||||
"canonicalUri": "artifact://.../versions/0",
|
||||
"customMetadata": {},
|
||||
"createTime": 100.0,
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"canonicalUri": "artifact://.../versions/1",
|
||||
"customMetadata": {"foo": "bar"},
|
||||
"createTime": 200.0,
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
]
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
metadata_list = await client.list_artifact_versions_metadata(
|
||||
app_name="app",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
artifact_name="report",
|
||||
)
|
||||
|
||||
assert len(metadata_list) == 2
|
||||
assert all(isinstance(item, ArtifactVersion) for item in metadata_list)
|
||||
assert metadata_list[1].custom_metadata == {"foo": "bar"}
|
||||
mock_client.get.assert_called_once_with(
|
||||
"/apps/app/users/user/sessions/session/artifacts/report/versions/metadata"
|
||||
)
|
||||
mock_response.raise_for_status.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close():
|
||||
client = AdkWebServerClient()
|
||||
|
||||
# Create a mock client to close
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
# Force client creation
|
||||
async with client._get_client():
|
||||
pass
|
||||
|
||||
# Now close should work
|
||||
await client.close()
|
||||
mock_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager():
|
||||
async with AdkWebServerClient() as client:
|
||||
assert isinstance(client, AdkWebServerClient)
|
||||
@@ -0,0 +1,50 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Import-isolation guard for adk_web_server.
|
||||
|
||||
Importing ``adk_web_server`` must not eagerly pull in the Agent Builder agent
|
||||
stack. Doing so reaches ``google.adk.agents`` at import time and breaks
|
||||
downstream consumers that import ``adk_web_server`` while ``google.adk.agents``
|
||||
is still initializing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def test_importing_adk_web_server_does_not_import_agent_builder():
|
||||
# Run in a fresh interpreter so the check is not polluted by modules that
|
||||
# other tests already imported into sys.modules.
|
||||
code = (
|
||||
"import google.adk.cli.adk_web_server\n"
|
||||
"import sys\n"
|
||||
"forbidden = [\n"
|
||||
" 'google.adk.cli.built_in_agents.agent',\n"
|
||||
" 'google.adk.cli.built_in_agents.adk_agent_builder_assistant',\n"
|
||||
"]\n"
|
||||
"loaded = [name for name in forbidden if name in sys.modules]\n"
|
||||
"assert not loaded, loaded\n"
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -0,0 +1,291 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
from google.adk.cli.adk_web_server import AdkWebServer
|
||||
from google.adk.events.event import Event
|
||||
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
||||
import pytest
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
|
||||
class _DummyAgent(BaseAgent):
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(name="dummy_agent")
|
||||
self.sub_agents = []
|
||||
|
||||
|
||||
class _DummyAgentLoader:
|
||||
|
||||
def load_agent(self, app_name: str) -> BaseAgent:
|
||||
return _DummyAgent()
|
||||
|
||||
def list_agents(self) -> list[str]:
|
||||
return ["test_app"]
|
||||
|
||||
def list_agents_detailed(self) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
class _CapturingRunner:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.captured_run_config = None
|
||||
|
||||
async def run_live(
|
||||
self,
|
||||
*,
|
||||
session,
|
||||
live_request_queue,
|
||||
run_config=None,
|
||||
**unused_kwargs,
|
||||
):
|
||||
self.captured_run_config = run_config
|
||||
yield Event(author="runner")
|
||||
|
||||
|
||||
def test_run_live_applies_run_config_query_options():
|
||||
session_service = InMemorySessionService()
|
||||
asyncio.run(
|
||||
session_service.create_session(
|
||||
app_name="test_app",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
state={},
|
||||
)
|
||||
)
|
||||
|
||||
runner = _CapturingRunner()
|
||||
adk_web_server = AdkWebServer(
|
||||
agent_loader=_DummyAgentLoader(),
|
||||
session_service=session_service,
|
||||
memory_service=types.SimpleNamespace(),
|
||||
artifact_service=types.SimpleNamespace(),
|
||||
credential_service=types.SimpleNamespace(),
|
||||
eval_sets_manager=types.SimpleNamespace(),
|
||||
eval_set_results_manager=types.SimpleNamespace(),
|
||||
agents_dir=".",
|
||||
)
|
||||
|
||||
async def _get_runner_async(_self, _app_name: str):
|
||||
return runner
|
||||
|
||||
adk_web_server.get_runner_async = _get_runner_async.__get__(adk_web_server) # pytype: disable=attribute-error
|
||||
|
||||
fast_api_app = adk_web_server.get_fast_api_app(
|
||||
setup_observer=lambda _observer, _server: None,
|
||||
tear_down_observer=lambda _observer, _server: None,
|
||||
)
|
||||
|
||||
client = TestClient(fast_api_app)
|
||||
url = (
|
||||
"/run_live"
|
||||
"?app_name=test_app"
|
||||
"&user_id=user"
|
||||
"&session_id=session"
|
||||
"&modalities=TEXT"
|
||||
"&modalities=AUDIO"
|
||||
"&proactive_audio=true"
|
||||
"&enable_affective_dialog=true"
|
||||
"&enable_session_resumption=true"
|
||||
"&save_live_blob=true"
|
||||
"&explicit_vad_signal=true"
|
||||
)
|
||||
|
||||
with client.websocket_connect(url) as ws:
|
||||
_ = ws.receive_text()
|
||||
|
||||
run_config = runner.captured_run_config
|
||||
assert run_config is not None
|
||||
assert run_config.response_modalities == ["TEXT", "AUDIO"]
|
||||
assert run_config.enable_affective_dialog is True
|
||||
assert run_config.proactivity is not None
|
||||
assert run_config.proactivity.proactive_audio is True
|
||||
assert run_config.session_resumption is not None
|
||||
assert run_config.session_resumption.transparent is True
|
||||
assert run_config.save_live_blob is True
|
||||
assert run_config.explicit_vad_signal is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"query,expected_enable_affective,expected_proactive_audio,"
|
||||
"expected_session_resumption_transparent,expected_save_live_blob,"
|
||||
"expected_explicit_vad_signal"
|
||||
),
|
||||
[
|
||||
("", None, None, None, False, None),
|
||||
("&proactive_audio=true", None, True, None, False, None),
|
||||
("&proactive_audio=false", None, False, None, False, None),
|
||||
("&enable_affective_dialog=true", True, None, None, False, None),
|
||||
("&enable_affective_dialog=false", False, None, None, False, None),
|
||||
("&enable_session_resumption=true", None, None, True, False, None),
|
||||
("&enable_session_resumption=false", None, None, False, False, None),
|
||||
("&save_live_blob=true", None, None, None, True, None),
|
||||
("&save_live_blob=false", None, None, None, False, None),
|
||||
("&explicit_vad_signal=true", None, None, None, False, True),
|
||||
("&explicit_vad_signal=false", None, None, None, False, False),
|
||||
],
|
||||
)
|
||||
def test_run_live_defaults_and_individual_options(
|
||||
query: str,
|
||||
expected_enable_affective: bool | None,
|
||||
expected_proactive_audio: bool | None,
|
||||
expected_session_resumption_transparent: bool | None,
|
||||
expected_save_live_blob: bool,
|
||||
expected_explicit_vad_signal: bool | None,
|
||||
):
|
||||
session_service = InMemorySessionService()
|
||||
asyncio.run(
|
||||
session_service.create_session(
|
||||
app_name="test_app",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
state={},
|
||||
)
|
||||
)
|
||||
|
||||
runner = _CapturingRunner()
|
||||
adk_web_server = AdkWebServer(
|
||||
agent_loader=_DummyAgentLoader(),
|
||||
session_service=session_service,
|
||||
memory_service=types.SimpleNamespace(),
|
||||
artifact_service=types.SimpleNamespace(),
|
||||
credential_service=types.SimpleNamespace(),
|
||||
eval_sets_manager=types.SimpleNamespace(),
|
||||
eval_set_results_manager=types.SimpleNamespace(),
|
||||
agents_dir=".",
|
||||
)
|
||||
|
||||
async def _get_runner_async(_self, _app_name: str):
|
||||
return runner
|
||||
|
||||
adk_web_server.get_runner_async = _get_runner_async.__get__(adk_web_server) # pytype: disable=attribute-error
|
||||
|
||||
fast_api_app = adk_web_server.get_fast_api_app(
|
||||
setup_observer=lambda _observer, _server: None,
|
||||
tear_down_observer=lambda _observer, _server: None,
|
||||
)
|
||||
|
||||
client = TestClient(fast_api_app)
|
||||
url = (
|
||||
"/run_live"
|
||||
"?app_name=test_app"
|
||||
"&user_id=user"
|
||||
"&session_id=session"
|
||||
"&modalities=AUDIO"
|
||||
f"{query}"
|
||||
)
|
||||
|
||||
with client.websocket_connect(url) as ws:
|
||||
_ = ws.receive_text()
|
||||
|
||||
run_config = runner.captured_run_config
|
||||
assert run_config is not None
|
||||
assert run_config.enable_affective_dialog == expected_enable_affective
|
||||
|
||||
if expected_proactive_audio is None:
|
||||
assert run_config.proactivity is None
|
||||
else:
|
||||
assert run_config.proactivity is not None
|
||||
assert run_config.proactivity.proactive_audio is expected_proactive_audio
|
||||
|
||||
if expected_session_resumption_transparent is None:
|
||||
assert run_config.session_resumption is None
|
||||
else:
|
||||
assert run_config.session_resumption is not None
|
||||
assert (
|
||||
run_config.session_resumption.transparent
|
||||
is expected_session_resumption_transparent
|
||||
)
|
||||
assert run_config.save_live_blob is expected_save_live_blob
|
||||
assert run_config.explicit_vad_signal is expected_explicit_vad_signal
|
||||
|
||||
|
||||
_WS_BASE_URL = (
|
||||
"/run_live"
|
||||
"?app_name=test_app"
|
||||
"&user_id=user"
|
||||
"&session_id=session"
|
||||
"&modalities=AUDIO"
|
||||
)
|
||||
|
||||
|
||||
def _build_ws_client():
|
||||
"""Build a TestClient wired to a capturing runner."""
|
||||
session_service = InMemorySessionService()
|
||||
asyncio.run(
|
||||
session_service.create_session(
|
||||
app_name="test_app",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
state={},
|
||||
)
|
||||
)
|
||||
|
||||
runner = _CapturingRunner()
|
||||
adk_web_server = AdkWebServer(
|
||||
agent_loader=_DummyAgentLoader(),
|
||||
session_service=session_service,
|
||||
memory_service=types.SimpleNamespace(),
|
||||
artifact_service=types.SimpleNamespace(),
|
||||
credential_service=types.SimpleNamespace(),
|
||||
eval_sets_manager=types.SimpleNamespace(),
|
||||
eval_set_results_manager=types.SimpleNamespace(),
|
||||
agents_dir=".",
|
||||
)
|
||||
|
||||
async def _get_runner_async(_self, _app_name: str):
|
||||
return runner
|
||||
|
||||
adk_web_server.get_runner_async = _get_runner_async.__get__(adk_web_server) # pytype: disable=attribute-error
|
||||
|
||||
fast_api_app = adk_web_server.get_fast_api_app(
|
||||
setup_observer=lambda _observer, _server: None,
|
||||
tear_down_observer=lambda _observer, _server: None,
|
||||
)
|
||||
return TestClient(fast_api_app)
|
||||
|
||||
|
||||
def test_run_live_rejects_disallowed_origin():
|
||||
client = _build_ws_client()
|
||||
with pytest.raises(WebSocketDisconnect) as exc_info:
|
||||
with client.websocket_connect(
|
||||
_WS_BASE_URL,
|
||||
headers={"origin": "https://evil.com"},
|
||||
) as ws:
|
||||
ws.receive_text()
|
||||
assert exc_info.value.code == 1008
|
||||
|
||||
|
||||
def test_run_live_allows_matching_origin():
|
||||
client = _build_ws_client()
|
||||
with client.websocket_connect(
|
||||
_WS_BASE_URL,
|
||||
headers={"origin": "http://testserver"},
|
||||
) as ws:
|
||||
_ = ws.receive_text()
|
||||
|
||||
|
||||
def test_run_live_allows_no_origin_header():
|
||||
"""Non-browser clients (curl, wscat, SDKs) send no Origin header."""
|
||||
client = _build_ws_client()
|
||||
with client.websocket_connect(_WS_BASE_URL) as ws:
|
||||
_ = ws.receive_text()
|
||||
@@ -0,0 +1,156 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from google.adk.cli.fast_api import get_fast_api_app
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(tmp_path):
|
||||
"""Client with a temporary agents directory."""
|
||||
app = get_fast_api_app(
|
||||
agents_dir=str(tmp_path),
|
||||
web=True,
|
||||
session_service_uri="",
|
||||
artifact_service_uri="",
|
||||
memory_service_uri="",
|
||||
allow_origins=["*"],
|
||||
a2a=False,
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_list_tests_empty(test_client):
|
||||
response = test_client.get("/dev/apps/test_app/tests")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
def test_create_test(test_client, tmp_path):
|
||||
# Create agent dir so it exists
|
||||
agent_dir = tmp_path / "test_app"
|
||||
agent_dir.mkdir()
|
||||
|
||||
payload = {"session_data": {"events": []}}
|
||||
|
||||
response = test_client.put(
|
||||
"/dev/apps/test_app/tests/my_test.json", json=payload
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "file": "my_test.json"}
|
||||
|
||||
# Verify file exists
|
||||
assert (agent_dir / "tests" / "my_test.json").exists()
|
||||
|
||||
|
||||
def test_list_tests_not_empty(test_client, tmp_path):
|
||||
agent_dir = tmp_path / "test_app"
|
||||
tests_dir = agent_dir / "tests"
|
||||
tests_dir.mkdir(parents=True)
|
||||
(tests_dir / "test1.json").write_text("{}")
|
||||
(tests_dir / "test2.json").write_text("{}")
|
||||
|
||||
response = test_client.get("/dev/apps/test_app/tests")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == ["test1.json", "test2.json"]
|
||||
|
||||
|
||||
def test_delete_test(test_client, tmp_path):
|
||||
agent_dir = tmp_path / "test_app"
|
||||
tests_dir = agent_dir / "tests"
|
||||
tests_dir.mkdir(parents=True)
|
||||
test_file = tests_dir / "test1.json"
|
||||
test_file.write_text("{}")
|
||||
|
||||
response = test_client.delete("/dev/apps/test_app/tests/test1.json")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
assert not test_file.exists()
|
||||
|
||||
|
||||
def test_get_test_content(test_client, tmp_path):
|
||||
agent_dir = tmp_path / "test_app"
|
||||
tests_dir = agent_dir / "tests"
|
||||
tests_dir.mkdir(parents=True)
|
||||
test_file = tests_dir / "test_get.json"
|
||||
test_file.write_text('{"foo": "bar"}')
|
||||
|
||||
response = test_client.get("/dev/apps/test_app/tests/test_get.json")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"foo": "bar"}
|
||||
|
||||
|
||||
def test_get_test_content_not_found(test_client):
|
||||
response = test_client.get("/dev/apps/test_app/tests/non_existent.json")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_rebuild_tests(test_client):
|
||||
with patch("google.adk.cli.dev_server.asyncio.to_thread") as mock_to_thread:
|
||||
mock_to_thread.return_value = None
|
||||
response = test_client.post("/dev/apps/test_app/tests/rebuild", json={})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
mock_to_thread.assert_called_once()
|
||||
|
||||
|
||||
def test_rebuild_single_test(test_client):
|
||||
with patch("google.adk.cli.dev_server.asyncio.to_thread") as mock_to_thread:
|
||||
mock_to_thread.return_value = None
|
||||
response = test_client.post(
|
||||
"/dev/apps/test_app/tests/rebuild?test_name=my_test.json", json={}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
mock_to_thread.assert_called_once()
|
||||
args, kwargs = mock_to_thread.call_args
|
||||
assert args[1].endswith("tests/my_test.json")
|
||||
|
||||
|
||||
def test_run_tests(test_client):
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.stdout.readline = AsyncMock(
|
||||
side_effect=[b"line1\n", b"line2\n", b""]
|
||||
)
|
||||
mock_process.wait = AsyncMock(return_value=0)
|
||||
|
||||
with patch(
|
||||
"google.adk.cli.dev_server.asyncio.create_subprocess_exec",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_create_subprocess:
|
||||
mock_create_subprocess.return_value = mock_process
|
||||
|
||||
response = test_client.post("/dev/apps/test_app/tests/run", json={})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/plain; charset=utf-8"
|
||||
# Read stream
|
||||
content = response.content
|
||||
assert b"line1\n" in content
|
||||
assert b"line2\n" in content
|
||||
@@ -0,0 +1,312 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
from click.testing import CliRunner
|
||||
from google.adk.cli.cli_tools_click import _apply_feature_overrides
|
||||
from google.adk.cli.cli_tools_click import feature_options
|
||||
from google.adk.features._feature_registry import _FEATURE_OVERRIDES
|
||||
from google.adk.features._feature_registry import _WARNED_FEATURES
|
||||
from google.adk.features._feature_registry import FeatureName
|
||||
from google.adk.features._feature_registry import is_feature_enabled
|
||||
from google.adk.features._feature_registry import temporary_feature_override
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_feature_overrides():
|
||||
"""Reset feature overrides and warnings before/after each test."""
|
||||
_FEATURE_OVERRIDES.clear()
|
||||
_WARNED_FEATURES.clear()
|
||||
yield
|
||||
_FEATURE_OVERRIDES.clear()
|
||||
_WARNED_FEATURES.clear()
|
||||
|
||||
|
||||
class TestApplyFeatureOverrides:
|
||||
"""Tests for _apply_feature_overrides helper function."""
|
||||
|
||||
def test_single_feature(self):
|
||||
"""Single feature name is applied correctly."""
|
||||
_apply_feature_overrides(enable_features=("JSON_SCHEMA_FOR_FUNC_DECL",))
|
||||
assert is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
|
||||
def test_comma_separated_features(self):
|
||||
"""Comma-separated feature names are applied correctly."""
|
||||
_apply_feature_overrides(
|
||||
enable_features=("JSON_SCHEMA_FOR_FUNC_DECL,PROGRESSIVE_SSE_STREAMING",)
|
||||
)
|
||||
assert is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
assert is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
|
||||
|
||||
def test_multiple_flag_values(self):
|
||||
"""Multiple --enable_features flags are applied correctly."""
|
||||
_apply_feature_overrides(
|
||||
enable_features=(
|
||||
"JSON_SCHEMA_FOR_FUNC_DECL",
|
||||
"PROGRESSIVE_SSE_STREAMING",
|
||||
)
|
||||
)
|
||||
assert is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
assert is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
|
||||
|
||||
def test_whitespace_handling(self):
|
||||
"""Whitespace around feature names is stripped."""
|
||||
_apply_feature_overrides(
|
||||
enable_features=(" JSON_SCHEMA_FOR_FUNC_DECL , COMPUTER_USE ",)
|
||||
)
|
||||
assert is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
assert is_feature_enabled(FeatureName.COMPUTER_USE)
|
||||
|
||||
def test_empty_string_ignored(self):
|
||||
"""Empty strings in the list are ignored."""
|
||||
_apply_feature_overrides(enable_features=("",))
|
||||
# No error should be raised
|
||||
|
||||
def test_unknown_feature_warns(self, capsys):
|
||||
"""Unknown feature names emit a warning."""
|
||||
_apply_feature_overrides(enable_features=("UNKNOWN_FEATURE_XYZ",))
|
||||
captured = capsys.readouterr()
|
||||
assert "WARNING" in captured.err
|
||||
assert "UNKNOWN_FEATURE_XYZ" in captured.err
|
||||
assert "Valid names are:" in captured.err
|
||||
|
||||
def test_single_disable_feature(self):
|
||||
"""Single feature name is disabled correctly."""
|
||||
# First enable a feature
|
||||
_apply_feature_overrides(enable_features=("JSON_SCHEMA_FOR_FUNC_DECL",))
|
||||
assert is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
|
||||
# Then disable it
|
||||
_apply_feature_overrides(disable_features=("JSON_SCHEMA_FOR_FUNC_DECL",))
|
||||
assert not is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
|
||||
def test_comma_separated_disable_features(self):
|
||||
"""Comma-separated feature names are disabled correctly."""
|
||||
# First enable features
|
||||
_apply_feature_overrides(
|
||||
enable_features=("JSON_SCHEMA_FOR_FUNC_DECL,PROGRESSIVE_SSE_STREAMING",)
|
||||
)
|
||||
|
||||
# Then disable them
|
||||
_apply_feature_overrides(
|
||||
disable_features=(
|
||||
"JSON_SCHEMA_FOR_FUNC_DECL,PROGRESSIVE_SSE_STREAMING",
|
||||
)
|
||||
)
|
||||
assert not is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
assert not is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
|
||||
|
||||
def test_disable_overrides_enable(self):
|
||||
"""Disable is applied after enable, so disable wins for same feature."""
|
||||
_apply_feature_overrides(
|
||||
enable_features=("JSON_SCHEMA_FOR_FUNC_DECL",),
|
||||
disable_features=("JSON_SCHEMA_FOR_FUNC_DECL",),
|
||||
)
|
||||
# disable_features is processed after enable_features
|
||||
assert not is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
|
||||
def test_enable_and_disable_different_features(self):
|
||||
"""Enable and disable can be used together for different features."""
|
||||
# First enable a feature that we'll disable
|
||||
_apply_feature_overrides(enable_features=("PROGRESSIVE_SSE_STREAMING",))
|
||||
|
||||
_apply_feature_overrides(
|
||||
enable_features=("JSON_SCHEMA_FOR_FUNC_DECL",),
|
||||
disable_features=("PROGRESSIVE_SSE_STREAMING",),
|
||||
)
|
||||
assert is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
assert not is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
|
||||
|
||||
|
||||
class TestFeatureOptionsDecorator:
|
||||
"""Tests for feature_options decorator."""
|
||||
|
||||
def test_decorator_adds_enable_features_option(self):
|
||||
"""Decorator adds --enable_features option to command."""
|
||||
|
||||
@click.command()
|
||||
@feature_options()
|
||||
def test_cmd():
|
||||
pass
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(test_cmd, ["--help"])
|
||||
assert "--enable_features" in result.output
|
||||
|
||||
def test_enable_features_applied_before_command(self):
|
||||
"""Features are enabled before the command function runs."""
|
||||
feature_was_enabled = []
|
||||
|
||||
@click.command()
|
||||
@feature_options()
|
||||
def test_cmd():
|
||||
feature_was_enabled.append(
|
||||
is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
runner.invoke(
|
||||
test_cmd,
|
||||
["--enable_features=JSON_SCHEMA_FOR_FUNC_DECL"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert feature_was_enabled == [True]
|
||||
|
||||
def test_multiple_enable_features_flags(self):
|
||||
"""Multiple --enable_features flags work correctly."""
|
||||
enabled_features = []
|
||||
|
||||
@click.command()
|
||||
@feature_options()
|
||||
def test_cmd():
|
||||
enabled_features.append(
|
||||
is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
)
|
||||
enabled_features.append(
|
||||
is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
runner.invoke(
|
||||
test_cmd,
|
||||
[
|
||||
"--enable_features=JSON_SCHEMA_FOR_FUNC_DECL",
|
||||
"--enable_features=PROGRESSIVE_SSE_STREAMING",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert enabled_features == [True, True]
|
||||
|
||||
def test_comma_separated_enable_features(self):
|
||||
"""Comma-separated feature names work correctly."""
|
||||
enabled_features = []
|
||||
|
||||
@click.command()
|
||||
@feature_options()
|
||||
def test_cmd():
|
||||
enabled_features.append(
|
||||
is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
)
|
||||
enabled_features.append(
|
||||
is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
runner.invoke(
|
||||
test_cmd,
|
||||
[
|
||||
"--enable_features=JSON_SCHEMA_FOR_FUNC_DECL,PROGRESSIVE_SSE_STREAMING"
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert enabled_features == [True, True]
|
||||
|
||||
def test_no_enable_features_flag(self):
|
||||
"""Command works without --enable_features flag."""
|
||||
enabled_features = []
|
||||
|
||||
with temporary_feature_override(
|
||||
FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, False
|
||||
):
|
||||
|
||||
@click.command()
|
||||
@feature_options()
|
||||
def test_cmd():
|
||||
enabled_features.append(
|
||||
is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(test_cmd, [], catch_exceptions=False)
|
||||
assert result.exit_code == 0
|
||||
assert enabled_features == [False]
|
||||
|
||||
def test_preserves_function_metadata(self):
|
||||
"""Decorator preserves the wrapped function's metadata."""
|
||||
|
||||
@click.command()
|
||||
@feature_options()
|
||||
def my_test_command():
|
||||
"""My docstring."""
|
||||
pass
|
||||
|
||||
# The callback should have preserved metadata
|
||||
assert (
|
||||
"my_test_command" in my_test_command.name
|
||||
or my_test_command.callback.__name__ == "my_test_command"
|
||||
)
|
||||
|
||||
def test_decorator_adds_disable_features_option(self):
|
||||
"""Decorator adds --disable_features option to command."""
|
||||
|
||||
@click.command()
|
||||
@feature_options()
|
||||
def test_cmd():
|
||||
pass
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(test_cmd, ["--help"])
|
||||
assert "--disable_features" in result.output
|
||||
|
||||
def test_disable_features_applied_before_command(self):
|
||||
"""Features are disabled before the command function runs."""
|
||||
# First enable the feature via override
|
||||
_apply_feature_overrides(enable_features=("JSON_SCHEMA_FOR_FUNC_DECL",))
|
||||
|
||||
feature_was_disabled = []
|
||||
|
||||
@click.command()
|
||||
@feature_options()
|
||||
def test_cmd():
|
||||
feature_was_disabled.append(
|
||||
not is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
runner.invoke(
|
||||
test_cmd,
|
||||
["--disable_features=JSON_SCHEMA_FOR_FUNC_DECL"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert feature_was_disabled == [True]
|
||||
|
||||
def test_enable_and_disable_together(self):
|
||||
"""Both --enable_features and --disable_features work together."""
|
||||
feature_states = []
|
||||
|
||||
@click.command()
|
||||
@feature_options()
|
||||
def test_cmd():
|
||||
feature_states.append(
|
||||
is_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL)
|
||||
)
|
||||
feature_states.append(
|
||||
is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING)
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
runner.invoke(
|
||||
test_cmd,
|
||||
[
|
||||
"--enable_features=JSON_SCHEMA_FOR_FUNC_DECL",
|
||||
"--disable_features=PROGRESSIVE_SSE_STREAMING",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
# JSON_SCHEMA_FOR_FUNC_DECL should be enabled
|
||||
# PROGRESSIVE_SSE_STREAMING should be disabled
|
||||
assert feature_states == [True, False]
|
||||
@@ -0,0 +1,178 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests to check if any Click options and method parameters mismatch."""
|
||||
|
||||
import inspect
|
||||
from typing import MutableMapping
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from google.adk.cli.cli_tools_click import cli_api_server
|
||||
from google.adk.cli.cli_tools_click import cli_create_cmd
|
||||
from google.adk.cli.cli_tools_click import cli_deploy_agent_engine
|
||||
from google.adk.cli.cli_tools_click import cli_deploy_cloud_run
|
||||
from google.adk.cli.cli_tools_click import cli_deploy_gke
|
||||
from google.adk.cli.cli_tools_click import cli_eval
|
||||
from google.adk.cli.cli_tools_click import cli_run
|
||||
from google.adk.cli.cli_tools_click import cli_web
|
||||
from google.adk.cli.cli_tools_click import deploy
|
||||
from google.adk.cli.cli_tools_click import main
|
||||
|
||||
|
||||
def _get_command_by_name(
|
||||
commands: MutableMapping[str, click.Command], name
|
||||
) -> Optional[click.Command]:
|
||||
"""Return the command object with the given name from a commands dict."""
|
||||
return next((cmd for cmd in commands.values() if cmd.name == name), None)
|
||||
|
||||
|
||||
def _get_click_options(command) -> set[str]:
|
||||
"""Extract Click option names from a command."""
|
||||
options = []
|
||||
for param in command.params:
|
||||
if isinstance(param, (click.Option, click.Argument)):
|
||||
options.append(param.name)
|
||||
return set(options)
|
||||
|
||||
|
||||
def _get_method_parameters(func) -> set[str]:
|
||||
"""Extract parameter names from a method signature."""
|
||||
sig = inspect.signature(func)
|
||||
return set(sig.parameters.keys())
|
||||
|
||||
|
||||
def _check_options_in_parameters(
|
||||
command,
|
||||
func,
|
||||
command_name,
|
||||
ignore_params: Optional[set[str]] = None,
|
||||
):
|
||||
"""Check if all Click options are present in method parameters."""
|
||||
click_options = _get_click_options(command)
|
||||
method_params = _get_method_parameters(func)
|
||||
|
||||
if ignore_params:
|
||||
click_options -= ignore_params
|
||||
method_params -= ignore_params
|
||||
|
||||
option_only = click_options - method_params
|
||||
parameter_only = method_params - click_options
|
||||
|
||||
assert click_options == method_params, f"""\
|
||||
Click options and method parameters do not match for command: `{command_name}`.
|
||||
Click options: {click_options}
|
||||
Method parameters: {method_params}
|
||||
Options only: {option_only}
|
||||
Parameters only: {parameter_only}
|
||||
"""
|
||||
|
||||
|
||||
def test_adk_create():
|
||||
"""Test that cli_create_cmd has all required parameters."""
|
||||
create_command = _get_command_by_name(main.commands, "create")
|
||||
|
||||
assert create_command is not None, "Create command not found"
|
||||
_check_options_in_parameters(
|
||||
create_command, cli_create_cmd.callback, "create"
|
||||
)
|
||||
|
||||
|
||||
def test_adk_run():
|
||||
"""Test that cli_run has all required parameters."""
|
||||
run_command = _get_command_by_name(main.commands, "run")
|
||||
|
||||
assert run_command is not None, "Run command not found"
|
||||
_check_options_in_parameters(
|
||||
run_command,
|
||||
cli_run.callback,
|
||||
"run",
|
||||
ignore_params={"verbose", "enable_features", "disable_features"},
|
||||
)
|
||||
|
||||
|
||||
def test_adk_eval():
|
||||
"""Test that cli_eval has all required parameters."""
|
||||
eval_command = _get_command_by_name(main.commands, "eval")
|
||||
|
||||
assert eval_command is not None, "Eval command not found"
|
||||
_check_options_in_parameters(
|
||||
eval_command,
|
||||
cli_eval.callback,
|
||||
"eval",
|
||||
ignore_params={"enable_features", "disable_features"},
|
||||
)
|
||||
|
||||
|
||||
def test_adk_web():
|
||||
"""Test that cli_web has all required parameters."""
|
||||
web_command = _get_command_by_name(main.commands, "web")
|
||||
|
||||
assert web_command is not None, "Web command not found"
|
||||
_check_options_in_parameters(
|
||||
web_command,
|
||||
cli_web.callback,
|
||||
"web",
|
||||
ignore_params={"verbose", "enable_features", "disable_features"},
|
||||
)
|
||||
|
||||
|
||||
def test_adk_api_server():
|
||||
"""Test that cli_api_server has all required parameters."""
|
||||
api_server_command = _get_command_by_name(main.commands, "api_server")
|
||||
|
||||
assert api_server_command is not None, "API server command not found"
|
||||
_check_options_in_parameters(
|
||||
api_server_command,
|
||||
cli_api_server.callback,
|
||||
"api_server",
|
||||
ignore_params={"verbose", "enable_features", "disable_features"},
|
||||
)
|
||||
|
||||
|
||||
def test_adk_deploy_cloud_run():
|
||||
"""Test that cli_deploy_cloud_run has all required parameters."""
|
||||
cloud_run_command = _get_command_by_name(deploy.commands, "cloud_run")
|
||||
|
||||
assert cloud_run_command is not None, "Cloud Run deploy command not found"
|
||||
_check_options_in_parameters(
|
||||
cloud_run_command,
|
||||
cli_deploy_cloud_run.callback,
|
||||
"deploy cloud_run",
|
||||
ignore_params={"verbose", "ctx"},
|
||||
)
|
||||
|
||||
|
||||
def test_adk_deploy_agent_engine():
|
||||
"""Test that cli_deploy_agent_engine has all required parameters."""
|
||||
agent_engine_command = _get_command_by_name(deploy.commands, "agent_engine")
|
||||
|
||||
assert (
|
||||
agent_engine_command is not None
|
||||
), "Agent Engine deploy command not found"
|
||||
_check_options_in_parameters(
|
||||
agent_engine_command,
|
||||
cli_deploy_agent_engine.callback,
|
||||
"deploy agent_engine",
|
||||
)
|
||||
|
||||
|
||||
def test_adk_deploy_gke():
|
||||
"""Test that cli_deploy_gke has all required parameters."""
|
||||
gke_command = _get_command_by_name(deploy.commands, "gke")
|
||||
|
||||
assert gke_command is not None, "GKE deploy command not found"
|
||||
_check_options_in_parameters(
|
||||
gke_command, cli_deploy_gke.callback, "deploy gke"
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for CORS configuration with regex prefix support."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.artifacts.base_artifact_service import BaseArtifactService
|
||||
from google.adk.auth.credential_service.base_credential_service import BaseCredentialService
|
||||
from google.adk.cli.adk_web_server import _parse_cors_origins
|
||||
from google.adk.cli.adk_web_server import AdkWebServer
|
||||
from google.adk.cli.utils.base_agent_loader import BaseAgentLoader
|
||||
from google.adk.evaluation.eval_set_results_manager import EvalSetResultsManager
|
||||
from google.adk.evaluation.eval_sets_manager import EvalSetsManager
|
||||
from google.adk.memory.base_memory_service import BaseMemoryService
|
||||
from google.adk.sessions.base_session_service import BaseSessionService
|
||||
import pytest
|
||||
|
||||
|
||||
class MockAgentLoader:
|
||||
"""Mock agent loader for testing."""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def load_agent(self, app_name):
|
||||
del self, app_name
|
||||
return mock.MagicMock()
|
||||
|
||||
def list_agents(self):
|
||||
del self
|
||||
return ["test_app"]
|
||||
|
||||
def list_agents_detailed(self):
|
||||
del self
|
||||
return []
|
||||
|
||||
|
||||
def create_adk_web_server():
|
||||
"""Create an AdkWebServer instance for testing."""
|
||||
return AdkWebServer(
|
||||
agent_loader=MockAgentLoader(),
|
||||
session_service=mock.create_autospec(BaseSessionService, instance=True),
|
||||
memory_service=mock.create_autospec(BaseMemoryService, instance=True),
|
||||
artifact_service=mock.create_autospec(BaseArtifactService, instance=True),
|
||||
credential_service=mock.create_autospec(
|
||||
BaseCredentialService, instance=True
|
||||
),
|
||||
eval_sets_manager=mock.create_autospec(EvalSetsManager, instance=True),
|
||||
eval_set_results_manager=mock.create_autospec(
|
||||
EvalSetResultsManager, instance=True
|
||||
),
|
||||
agents_dir=".",
|
||||
)
|
||||
|
||||
|
||||
def _get_cors_middleware(app):
|
||||
"""Extract CORSMiddleware from app's middleware stack.
|
||||
|
||||
Returns:
|
||||
The CORSMiddleware instance, or None if not found.
|
||||
"""
|
||||
for middleware in app.user_middleware:
|
||||
if middleware.cls.__name__ == "CORSMiddleware":
|
||||
return middleware
|
||||
return None
|
||||
|
||||
|
||||
CORS_ORIGINS_TEST_CASES = [
|
||||
# Literal origins only
|
||||
(
|
||||
["https://example.com", "https://test.com"],
|
||||
["https://example.com", "https://test.com"],
|
||||
None,
|
||||
),
|
||||
# Regex patterns only
|
||||
(
|
||||
[
|
||||
"regex:https://.*\\.example\\.com",
|
||||
"regex:https://.*\\.test\\.com",
|
||||
],
|
||||
[],
|
||||
"https://.*\\.example\\.com|https://.*\\.test\\.com",
|
||||
),
|
||||
# Mixed literal and regex
|
||||
(
|
||||
[
|
||||
"https://example.com",
|
||||
"regex:https://.*\\.subdomain\\.com",
|
||||
"https://test.com",
|
||||
"regex:https://tenant-.*\\.myapp\\.com",
|
||||
],
|
||||
["https://example.com", "https://test.com"],
|
||||
"https://.*\\.subdomain\\.com|https://tenant-.*\\.myapp\\.com",
|
||||
),
|
||||
# Wildcard origin
|
||||
(["*"], ["*"], None),
|
||||
# Single regex
|
||||
(
|
||||
["regex:https://.*\\.example\\.com"],
|
||||
[],
|
||||
"https://.*\\.example\\.com",
|
||||
),
|
||||
]
|
||||
|
||||
CORS_ORIGINS_TEST_IDS = [
|
||||
"literal_only",
|
||||
"regex_only",
|
||||
"mixed",
|
||||
"wildcard",
|
||||
"single_regex",
|
||||
]
|
||||
|
||||
|
||||
class TestParseCorsOrigins:
|
||||
"""Tests for the _parse_cors_origins helper function."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allow_origins,expected_literal,expected_regex",
|
||||
CORS_ORIGINS_TEST_CASES,
|
||||
ids=CORS_ORIGINS_TEST_IDS,
|
||||
)
|
||||
def test_parse_cors_origins(
|
||||
self, allow_origins, expected_literal, expected_regex
|
||||
):
|
||||
"""Test parsing of allow_origins into literal and regex components."""
|
||||
literal_origins, combined_regex = _parse_cors_origins(allow_origins)
|
||||
assert literal_origins == expected_literal
|
||||
assert combined_regex == expected_regex
|
||||
|
||||
|
||||
class TestCorsMiddlewareConfiguration:
|
||||
"""Tests for CORS middleware configuration in AdkWebServer."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allow_origins,expected_literal,expected_regex",
|
||||
CORS_ORIGINS_TEST_CASES,
|
||||
ids=CORS_ORIGINS_TEST_IDS,
|
||||
)
|
||||
def test_cors_middleware_configuration(
|
||||
self, allow_origins, expected_literal, expected_regex
|
||||
):
|
||||
"""Test CORS middleware is configured correctly with various origin types."""
|
||||
server = create_adk_web_server()
|
||||
app = server.get_fast_api_app(
|
||||
allow_origins=allow_origins,
|
||||
setup_observer=lambda _o, _s: None,
|
||||
tear_down_observer=lambda _o, _s: None,
|
||||
)
|
||||
|
||||
cors_middleware = _get_cors_middleware(app)
|
||||
assert cors_middleware is not None
|
||||
assert cors_middleware.kwargs["allow_origins"] == expected_literal
|
||||
assert cors_middleware.kwargs["allow_origin_regex"] == expected_regex
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allow_origins",
|
||||
[None, []],
|
||||
ids=["none", "empty_list"],
|
||||
)
|
||||
def test_cors_middleware_not_added_when_no_origins(self, allow_origins):
|
||||
"""Test that no CORS middleware is added when allow_origins is None or empty."""
|
||||
server = create_adk_web_server()
|
||||
app = server.get_fast_api_app(
|
||||
allow_origins=allow_origins,
|
||||
setup_observer=lambda _o, _s: None,
|
||||
tear_down_observer=lambda _o, _s: None,
|
||||
)
|
||||
|
||||
cors_middleware = _get_cors_middleware(app)
|
||||
assert cors_middleware is None
|
||||
@@ -0,0 +1,196 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for DNS-rebinding protection in _OriginCheckMiddleware."""
|
||||
|
||||
from google.adk.cli.api_server import _is_loopback_address
|
||||
from google.adk.cli.api_server import _is_request_origin_allowed
|
||||
import pytest
|
||||
|
||||
|
||||
class TestIsLoopbackAddress:
|
||||
"""Unit tests for _is_loopback_address."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host",
|
||||
[
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"127.0.0.1:8000",
|
||||
"localhost:8000",
|
||||
"[::1]:8000",
|
||||
"127.1.2.3", # any 127.x.x.x is loopback
|
||||
],
|
||||
)
|
||||
def test_loopback_hosts(self, host: str):
|
||||
assert _is_loopback_address(host), f"{host!r} should be loopback"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host",
|
||||
[
|
||||
"evil.com",
|
||||
"127.evil.com",
|
||||
"0.0.0.0",
|
||||
"192.168.1.1",
|
||||
"10.0.0.1",
|
||||
"128.0.0.1",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_non_loopback_hosts(self, host: str):
|
||||
assert not _is_loopback_address(host), f"{host!r} should NOT be loopback"
|
||||
|
||||
|
||||
class TestDnsRebindingProtection:
|
||||
"""Tests that DNS-rebinding attacks are blocked when server is on loopback."""
|
||||
|
||||
def _make_scope(
|
||||
self, server_host: str = "127.0.0.1", host_header: str = "127.0.0.1:8000"
|
||||
) -> dict:
|
||||
"""Build a minimal ASGI scope for testing."""
|
||||
return {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"server": (server_host, 8000),
|
||||
"headers": [
|
||||
(b"host", host_header.encode()),
|
||||
],
|
||||
"scheme": "http",
|
||||
}
|
||||
|
||||
# --- DNS rebinding scenarios (should be BLOCKED) ---
|
||||
|
||||
def test_dns_rebinding_evil_origin_loopback_server_no_configured_origins(
|
||||
self,
|
||||
):
|
||||
"""Attacker page (evil.com) DNS-rebinds to 127.0.0.1 and sends a POST.
|
||||
|
||||
Browser sends Origin: http://evil.com, Host: evil.com.
|
||||
Server is bound to 127.0.0.1.
|
||||
No explicit allow-origins configured.
|
||||
Expected: BLOCKED.
|
||||
"""
|
||||
scope = self._make_scope(
|
||||
server_host="127.0.0.1", host_header="evil.com:8000"
|
||||
)
|
||||
result = _is_request_origin_allowed(
|
||||
origin="http://evil.com",
|
||||
scope=scope,
|
||||
allowed_literal_origins=[],
|
||||
allowed_origin_regex=None,
|
||||
has_configured_allowed_origins=False,
|
||||
)
|
||||
assert (
|
||||
not result
|
||||
), "DNS-rebinding from evil.com should be blocked on loopback server"
|
||||
|
||||
def test_dns_rebinding_127_evil_origin(self):
|
||||
"""Origin header host starts with '127.' but is a hostname (127.evil.com)."""
|
||||
scope = self._make_scope(
|
||||
server_host="127.0.0.1", host_header="127.evil.com:8000"
|
||||
)
|
||||
result = _is_request_origin_allowed(
|
||||
origin="http://127.evil.com",
|
||||
scope=scope,
|
||||
allowed_literal_origins=[],
|
||||
allowed_origin_regex=None,
|
||||
has_configured_allowed_origins=False,
|
||||
)
|
||||
assert not result
|
||||
|
||||
def test_dns_rebinding_localhost_server(self):
|
||||
"""Same attack, server bound as 'localhost'."""
|
||||
scope = self._make_scope(server_host="localhost", host_header="evil.com")
|
||||
result = _is_request_origin_allowed(
|
||||
origin="http://evil.com",
|
||||
scope=scope,
|
||||
allowed_literal_origins=[],
|
||||
allowed_origin_regex=None,
|
||||
has_configured_allowed_origins=False,
|
||||
)
|
||||
assert not result
|
||||
|
||||
def test_dns_rebinding_ipv6_loopback_server(self):
|
||||
"""Same attack, server bound to ::1."""
|
||||
scope = self._make_scope(server_host="::1", host_header="evil.com")
|
||||
result = _is_request_origin_allowed(
|
||||
origin="http://evil.com",
|
||||
scope=scope,
|
||||
allowed_literal_origins=[],
|
||||
allowed_origin_regex=None,
|
||||
has_configured_allowed_origins=False,
|
||||
)
|
||||
assert not result
|
||||
|
||||
# --- Legitimate same-origin requests (should be ALLOWED) ---
|
||||
|
||||
def test_same_origin_localhost_allowed(self):
|
||||
"""Legitimate browser request from localhost UI to localhost server."""
|
||||
scope = self._make_scope(
|
||||
server_host="127.0.0.1", host_header="127.0.0.1:8000"
|
||||
)
|
||||
result = _is_request_origin_allowed(
|
||||
origin="http://127.0.0.1:8000",
|
||||
scope=scope,
|
||||
allowed_literal_origins=[],
|
||||
allowed_origin_regex=None,
|
||||
has_configured_allowed_origins=False,
|
||||
)
|
||||
assert result, "Same-origin localhost request should be allowed"
|
||||
|
||||
def test_same_origin_localhost_named(self):
|
||||
"""Browser opens http://localhost:8000 -> requests to localhost:8000."""
|
||||
scope = self._make_scope(
|
||||
server_host="127.0.0.1", host_header="localhost:8000"
|
||||
)
|
||||
result = _is_request_origin_allowed(
|
||||
origin="http://localhost:8000",
|
||||
scope=scope,
|
||||
allowed_literal_origins=[],
|
||||
allowed_origin_regex=None,
|
||||
has_configured_allowed_origins=False,
|
||||
)
|
||||
assert result
|
||||
|
||||
# --- Explicit allow-origins configured (allow-list bypasses DNS guard) ---
|
||||
|
||||
def test_explicit_allowlist_overrides_dns_rebinding_guard(self):
|
||||
"""If the developer explicitly allows evil.com, it should be permitted."""
|
||||
scope = self._make_scope(server_host="127.0.0.1", host_header="evil.com")
|
||||
result = _is_request_origin_allowed(
|
||||
origin="http://evil.com",
|
||||
scope=scope,
|
||||
allowed_literal_origins=["http://evil.com"],
|
||||
allowed_origin_regex=None,
|
||||
has_configured_allowed_origins=True,
|
||||
)
|
||||
assert result, "Explicitly allowed origin should still pass"
|
||||
|
||||
# --- Non-loopback server (protection does not apply) ---
|
||||
|
||||
def test_non_loopback_server_no_dns_guard(self):
|
||||
"""Server bound to 0.0.0.0 — DNS guard must not interfere with same-origin check."""
|
||||
scope = self._make_scope(
|
||||
server_host="0.0.0.0", host_header="example.com:8000"
|
||||
)
|
||||
result = _is_request_origin_allowed(
|
||||
origin="http://example.com:8000",
|
||||
scope=scope,
|
||||
allowed_literal_origins=[],
|
||||
allowed_origin_regex=None,
|
||||
has_configured_allowed_origins=False,
|
||||
)
|
||||
assert result, "Same-origin on public server should be allowed"
|
||||
Executable
+3504
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Path-traversal containment tests for Agent Builder file tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.cli.built_in_agents.tools.delete_files import delete_files
|
||||
from google.adk.cli.built_in_agents.tools.read_files import read_files
|
||||
from google.adk.cli.built_in_agents.tools.write_files import write_files
|
||||
from google.adk.cli.built_in_agents.utils.resolve_root_directory import resolve_file_path
|
||||
import pytest
|
||||
|
||||
|
||||
def _tool_context(root: Path) -> mock.MagicMock:
|
||||
tool_context = mock.MagicMock()
|
||||
tool_context._invocation_context.session.state = {"root_directory": str(root)}
|
||||
return tool_context
|
||||
|
||||
|
||||
def test_resolve_file_path_allows_path_within_root(tmp_path):
|
||||
resolved = resolve_file_path(
|
||||
"sub/dir/file.txt", {"root_directory": str(tmp_path)}
|
||||
)
|
||||
assert resolved == (tmp_path / "sub" / "dir" / "file.txt").resolve()
|
||||
|
||||
|
||||
def test_resolve_file_path_allows_dot(tmp_path):
|
||||
resolved = resolve_file_path(".", {"root_directory": str(tmp_path)})
|
||||
assert resolved == tmp_path.resolve()
|
||||
|
||||
|
||||
def test_resolve_file_path_allows_interior_dotdot_within_root(tmp_path):
|
||||
resolved = resolve_file_path(
|
||||
"sub/../file.txt", {"root_directory": str(tmp_path)}
|
||||
)
|
||||
assert resolved == (tmp_path / "file.txt").resolve()
|
||||
|
||||
|
||||
def test_resolve_file_path_allows_absolute_within_root(tmp_path):
|
||||
target = tmp_path / "nested" / "ok.txt"
|
||||
resolved = resolve_file_path(str(target), {"root_directory": str(tmp_path)})
|
||||
assert resolved == target.resolve()
|
||||
|
||||
|
||||
def test_resolve_file_path_rejects_relative_traversal(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
resolve_file_path("../../escape.txt", {"root_directory": str(tmp_path)})
|
||||
|
||||
|
||||
def test_resolve_file_path_rejects_absolute_outside_root(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
resolve_file_path("/etc/passwd", {"root_directory": str(tmp_path)})
|
||||
|
||||
|
||||
async def test_write_files_blocks_relative_traversal(
|
||||
tmp_path, tmp_path_factory
|
||||
):
|
||||
outside = tmp_path_factory.mktemp("outside")
|
||||
payload = os.path.relpath(outside / "pwned.txt", tmp_path)
|
||||
|
||||
result = await write_files(
|
||||
files={payload: "PWNED"}, tool_context=_tool_context(tmp_path)
|
||||
)
|
||||
|
||||
assert not result["success"]
|
||||
assert not (outside / "pwned.txt").exists()
|
||||
|
||||
|
||||
async def test_write_files_blocks_absolute_outside_root(
|
||||
tmp_path, tmp_path_factory
|
||||
):
|
||||
outside = tmp_path_factory.mktemp("outside")
|
||||
target = outside / "abs.txt"
|
||||
|
||||
result = await write_files(
|
||||
files={str(target): "PWNED"}, tool_context=_tool_context(tmp_path)
|
||||
)
|
||||
|
||||
assert not result["success"]
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
async def test_write_files_allows_path_within_root(tmp_path):
|
||||
result = await write_files(
|
||||
files={"sub/ok.txt": "hello"}, tool_context=_tool_context(tmp_path)
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
assert (tmp_path / "sub" / "ok.txt").read_text() == "hello"
|
||||
|
||||
|
||||
async def test_read_files_blocks_relative_traversal(tmp_path, tmp_path_factory):
|
||||
outside = tmp_path_factory.mktemp("outside")
|
||||
secret = outside / "secret.txt"
|
||||
secret.write_text("TOKEN=abc")
|
||||
payload = os.path.relpath(secret, tmp_path)
|
||||
|
||||
result = await read_files(
|
||||
file_paths=[payload], tool_context=_tool_context(tmp_path)
|
||||
)
|
||||
|
||||
assert not result["success"]
|
||||
assert all(
|
||||
"TOKEN=abc" not in info.get("content", "")
|
||||
for info in result["files"].values()
|
||||
)
|
||||
|
||||
|
||||
async def test_delete_files_blocks_relative_traversal(
|
||||
tmp_path, tmp_path_factory
|
||||
):
|
||||
outside = tmp_path_factory.mktemp("outside")
|
||||
victim = outside / "victim.txt"
|
||||
victim.write_text("bye")
|
||||
payload = os.path.relpath(victim, tmp_path)
|
||||
|
||||
result = await delete_files(
|
||||
file_paths=[payload],
|
||||
tool_context=_tool_context(tmp_path),
|
||||
confirm_deletion=True,
|
||||
)
|
||||
|
||||
assert not result["success"]
|
||||
assert victim.exists()
|
||||
@@ -0,0 +1,244 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.cli import service_registry
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_services():
|
||||
"""Mock all service implementation classes to avoid real instantiation."""
|
||||
with (
|
||||
patch(
|
||||
"google.adk.sessions.vertex_ai_session_service.VertexAiSessionService"
|
||||
) as mock_vertex_session,
|
||||
patch(
|
||||
"google.adk.sessions.database_session_service.DatabaseSessionService"
|
||||
) as mock_db_session,
|
||||
patch(
|
||||
"google.adk.sessions.sqlite_session_service.SqliteSessionService"
|
||||
) as mock_sqlite_session,
|
||||
patch(
|
||||
"google.adk.artifacts.gcs_artifact_service.GcsArtifactService"
|
||||
) as mock_gcs_artifact,
|
||||
patch(
|
||||
"google.adk.memory.vertex_ai_rag_memory_service.VertexAiRagMemoryService"
|
||||
) as mock_rag_memory,
|
||||
patch(
|
||||
"google.adk.memory.vertex_ai_memory_bank_service.VertexAiMemoryBankService"
|
||||
) as mock_agentengine_memory,
|
||||
):
|
||||
yield {
|
||||
"vertex_session": mock_vertex_session,
|
||||
"db_session": mock_db_session,
|
||||
"sqlite_session": mock_sqlite_session,
|
||||
"gcs_artifact": mock_gcs_artifact,
|
||||
"rag_memory": mock_rag_memory,
|
||||
"agentengine_memory": mock_agentengine_memory,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry():
|
||||
from google.adk.cli.service_registry import get_service_registry
|
||||
|
||||
return get_service_registry()
|
||||
|
||||
|
||||
# Session Service Tests
|
||||
def test_create_session_service_sqlite(registry, mock_services):
|
||||
registry.create_session_service("sqlite:///test.db")
|
||||
mock_services["sqlite_session"].assert_called_once_with(db_path="test.db")
|
||||
|
||||
|
||||
def test_create_session_service_sqlite_ignores_unsupported_kwargs(
|
||||
registry, mock_services, caplog
|
||||
):
|
||||
"""Test that SqliteSessionService ignores unsupported kwargs and logs warning."""
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
registry.create_session_service(
|
||||
"sqlite:///test.db", pool_size=10, agents_dir="foo"
|
||||
)
|
||||
|
||||
# SqliteSessionService should only receive db_path, not pool_size
|
||||
mock_services["sqlite_session"].assert_called_once_with(db_path="test.db")
|
||||
|
||||
# Verify warning was logged about ignored kwargs
|
||||
assert (
|
||||
"SqliteSessionService does not support additional kwargs" in caplog.text
|
||||
)
|
||||
assert "pool_size" in caplog.text
|
||||
|
||||
|
||||
def test_create_session_service_postgresql(registry, mock_services):
|
||||
registry.create_session_service("postgresql://user:pass@host/db")
|
||||
mock_services["db_session"].assert_called_once_with(
|
||||
db_url="postgresql://user:pass@host/db"
|
||||
)
|
||||
|
||||
|
||||
@patch("google.adk.cli.utils.envs.load_dotenv_for_agent")
|
||||
def test_create_session_service_agentengine_short(
|
||||
mock_load_dotenv, registry, mock_services, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "test-project")
|
||||
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
|
||||
registry.create_session_service(
|
||||
"agentengine://123", agents_dir="/path/to/agents"
|
||||
)
|
||||
mock_services["vertex_session"].assert_called_once_with(
|
||||
project="test-project", location="us-central1", agent_engine_id="123"
|
||||
)
|
||||
mock_load_dotenv.assert_called_once_with("", "/path/to/agents")
|
||||
|
||||
|
||||
def test_create_session_service_agentengine_full(registry, mock_services):
|
||||
uri = "agentengine://projects/p/locations/l/reasoningEngines/123"
|
||||
registry.create_session_service(uri, agents_dir="/path/to/agents")
|
||||
mock_services["vertex_session"].assert_called_once_with(
|
||||
project="p", location="l", agent_engine_id="123"
|
||||
)
|
||||
|
||||
|
||||
# Artifact Service Tests
|
||||
def test_create_artifact_service_gcs(registry, mock_services):
|
||||
registry.create_artifact_service(
|
||||
"gs://my-bucket/path/prefix", agents_dir="foo", other_kwarg="bar"
|
||||
)
|
||||
mock_services["gcs_artifact"].assert_called_once_with(
|
||||
bucket_name="my-bucket", other_kwarg="bar"
|
||||
)
|
||||
|
||||
|
||||
def test_file_artifact_factory_normalizes_windows_file_uri(monkeypatch):
|
||||
monkeypatch.setattr(service_registry, "os", SimpleNamespace(name="nt"))
|
||||
mocked_url2pathname = mock.Mock(return_value=r"C:\tmp\adk artifacts")
|
||||
monkeypatch.setattr(service_registry, "url2pathname", mocked_url2pathname)
|
||||
|
||||
registry = service_registry.ServiceRegistry()
|
||||
service_registry._register_builtin_services(registry)
|
||||
|
||||
with mock.patch(
|
||||
"google.adk.artifacts.file_artifact_service.FileArtifactService"
|
||||
) as mock_file_artifact_service:
|
||||
registry.create_artifact_service("file:///C:/tmp/adk%20artifacts")
|
||||
|
||||
mocked_url2pathname.assert_called_once_with("/C:/tmp/adk artifacts")
|
||||
mock_file_artifact_service.assert_called_once_with(
|
||||
root_dir=Path(r"C:\tmp\adk artifacts")
|
||||
)
|
||||
|
||||
|
||||
def test_file_artifact_factory_rejects_non_local_authority():
|
||||
registry = service_registry.ServiceRegistry()
|
||||
service_registry._register_builtin_services(registry)
|
||||
|
||||
with pytest.raises(ValueError, match="local filesystem"):
|
||||
registry.create_artifact_service("file://example.com/tmp/adk_artifacts")
|
||||
|
||||
|
||||
# Memory Service Tests
|
||||
@patch("google.adk.cli.utils.envs.load_dotenv_for_agent")
|
||||
def test_create_memory_service_rag(
|
||||
mock_load_dotenv, registry, mock_services, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "test-project")
|
||||
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
|
||||
registry.create_memory_service(
|
||||
"rag://corpus-123", agents_dir="/path/to/agents"
|
||||
)
|
||||
mock_services["rag_memory"].assert_called_once_with(
|
||||
rag_corpus=(
|
||||
"projects/test-project/locations/us-central1/ragCorpora/corpus-123"
|
||||
)
|
||||
)
|
||||
mock_load_dotenv.assert_called_once_with("", "/path/to/agents")
|
||||
|
||||
|
||||
@patch("google.adk.cli.utils.envs.load_dotenv_for_agent")
|
||||
def test_create_memory_service_agentengine_short(
|
||||
mock_load_dotenv, registry, mock_services, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "test-project")
|
||||
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
|
||||
registry.create_memory_service(
|
||||
"agentengine://456", agents_dir="/path/to/agents"
|
||||
)
|
||||
mock_services["agentengine_memory"].assert_called_once_with(
|
||||
project="test-project", location="us-central1", agent_engine_id="456"
|
||||
)
|
||||
mock_load_dotenv.assert_called_once_with("", "/path/to/agents")
|
||||
|
||||
|
||||
def test_create_memory_service_agentengine_full(registry, mock_services):
|
||||
uri = "agentengine://projects/p/locations/l/reasoningEngines/456"
|
||||
registry.create_memory_service(uri, agents_dir="/path/to/agents")
|
||||
mock_services["agentengine_memory"].assert_called_once_with(
|
||||
project="p", location="l", agent_engine_id="456"
|
||||
)
|
||||
|
||||
|
||||
def test_create_memory_service_memory(registry):
|
||||
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
|
||||
|
||||
memory_service = registry.create_memory_service("memory://")
|
||||
assert isinstance(memory_service, InMemoryMemoryService)
|
||||
|
||||
|
||||
# Task Store Tests
|
||||
def test_create_task_store_memory(registry):
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
|
||||
task_store = registry._create_task_store_service("memory://")
|
||||
assert isinstance(task_store, InMemoryTaskStore)
|
||||
|
||||
|
||||
@patch("sqlalchemy.ext.asyncio.create_async_engine")
|
||||
@patch("a2a.server.tasks.DatabaseTaskStore")
|
||||
def test_create_task_store_postgresql(
|
||||
mock_db_task_store, mock_create_engine, registry
|
||||
):
|
||||
mock_engine = mock_create_engine.return_value
|
||||
registry._create_task_store_service("postgresql+asyncpg://user:pass@host/db")
|
||||
mock_create_engine.assert_called_once_with(
|
||||
"postgresql+asyncpg://user:pass@host/db"
|
||||
)
|
||||
mock_db_task_store.assert_called_once_with(engine=mock_engine)
|
||||
|
||||
|
||||
# General Tests
|
||||
def test_unsupported_scheme(registry, mock_services):
|
||||
session_service = registry.create_session_service("unsupported://foo")
|
||||
artifact_service = registry.create_artifact_service("unsupported://foo")
|
||||
memory_service = registry.create_memory_service("unsupported://foo")
|
||||
assert session_service is None
|
||||
assert artifact_service is None
|
||||
assert memory_service is None
|
||||
with pytest.raises(ValueError, match="Unsupported A2A task store URI scheme"):
|
||||
registry._create_task_store_service("unsupported://foo")
|
||||
for service in [
|
||||
"vertex_session",
|
||||
"db_session",
|
||||
"gcs_artifact",
|
||||
"rag_memory",
|
||||
"agentengine_memory",
|
||||
]:
|
||||
mock_services[service].assert_not_called()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.cli.utils import agent_loader
|
||||
from google.adk.cli.utils.agent_change_handler import AgentChangeEventHandler
|
||||
from google.adk.cli.utils.shared_value import SharedValue
|
||||
import pytest
|
||||
from watchdog.events import FileModifiedEvent
|
||||
|
||||
|
||||
class TestAgentChangeEventHandler:
|
||||
"""Unit tests for AgentChangeEventHandler file extension filtering."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent_loader(self):
|
||||
"""Create a mock AgentLoader constrained to the public API."""
|
||||
return mock.create_autospec(
|
||||
agent_loader.AgentLoader, instance=True, spec_set=True
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def handler(self, mock_agent_loader):
|
||||
"""Create an AgentChangeEventHandler with mocked dependencies."""
|
||||
runners_to_clean = set()
|
||||
current_app_name_ref = SharedValue(value="test_agent")
|
||||
return AgentChangeEventHandler(
|
||||
agent_loader=mock_agent_loader,
|
||||
runners_to_clean=runners_to_clean,
|
||||
current_app_name_ref=current_app_name_ref,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"file_path",
|
||||
[
|
||||
pytest.param("/path/to/agent.py", id="python_file"),
|
||||
pytest.param("/path/to/config.yaml", id="yaml_file"),
|
||||
pytest.param("/path/to/config.yml", id="yml_file"),
|
||||
],
|
||||
)
|
||||
def test_on_modified_triggers_reload_for_supported_extensions(
|
||||
self, handler, mock_agent_loader, file_path
|
||||
):
|
||||
"""Verify that .py, .yaml, and .yml files trigger agent reload."""
|
||||
event = FileModifiedEvent(src_path=file_path)
|
||||
|
||||
handler.on_modified(event)
|
||||
|
||||
mock_agent_loader.remove_agent_from_cache.assert_called_once_with(
|
||||
"test_agent"
|
||||
)
|
||||
assert (
|
||||
"test_agent" in handler.runners_to_clean
|
||||
), f"Expected 'test_agent' in runners_to_clean for {file_path}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"file_path",
|
||||
[
|
||||
pytest.param("/path/to/file.json", id="json_file"),
|
||||
pytest.param("/path/to/file.txt", id="txt_file"),
|
||||
pytest.param("/path/to/file.md", id="markdown_file"),
|
||||
pytest.param("/path/to/file.toml", id="toml_file"),
|
||||
pytest.param("/path/to/.gitignore", id="gitignore_file"),
|
||||
pytest.param("/path/to/file", id="no_extension"),
|
||||
],
|
||||
)
|
||||
def test_on_modified_ignores_unsupported_extensions(
|
||||
self, handler, mock_agent_loader, file_path
|
||||
):
|
||||
"""Verify that non-py/yaml/yml files do not trigger reload."""
|
||||
event = FileModifiedEvent(src_path=file_path)
|
||||
|
||||
handler.on_modified(event)
|
||||
|
||||
mock_agent_loader.remove_agent_from_cache.assert_not_called()
|
||||
assert not handler.runners_to_clean, (
|
||||
f"Expected runners_to_clean to be empty for {file_path}, "
|
||||
f"got {handler.runners_to_clean}"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,527 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for utilities in cli."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
import types
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Tuple
|
||||
from unittest import mock
|
||||
|
||||
import click
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
from google.adk.apps.app import App
|
||||
from google.adk.artifacts.file_artifact_service import FileArtifactService
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService
|
||||
import google.adk.cli.cli as cli
|
||||
from google.adk.cli.utils.local_storage import PerAgentFileArtifactService
|
||||
from google.adk.cli.utils.service_factory import create_artifact_service_from_options
|
||||
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
||||
import pytest
|
||||
|
||||
|
||||
# Helpers
|
||||
class _Recorder:
|
||||
"""Callable that records every invocation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> None:
|
||||
self.calls.append((args, kwargs))
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Silence click output in every test."""
|
||||
monkeypatch.setattr(click, "echo", lambda *a, **k: None)
|
||||
monkeypatch.setattr(click, "secho", lambda *a, **k: None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_types_and_runner(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Replace google.genai.types and Runner with lightweight fakes."""
|
||||
|
||||
# Dummy Part / Content
|
||||
class _Part:
|
||||
|
||||
def __init__(self, text: str | None = "") -> None:
|
||||
self.text = text
|
||||
|
||||
class _Content:
|
||||
|
||||
def __init__(self, role: str, parts: List[_Part]) -> None:
|
||||
self.role = role
|
||||
self.parts = parts
|
||||
|
||||
monkeypatch.setattr(cli.types, "Part", _Part)
|
||||
monkeypatch.setattr(cli.types, "Content", _Content)
|
||||
|
||||
# Fake Runner yielding a single assistant echo
|
||||
class _FakeRunner:
|
||||
|
||||
def __init__(self, *a: Any, **k: Any) -> None:
|
||||
...
|
||||
|
||||
async def run_async(self, *a: Any, **k: Any):
|
||||
message = a[2] if len(a) >= 3 else k["new_message"]
|
||||
text = message.parts[0].text if message.parts else ""
|
||||
response = _Content("assistant", [_Part(f"echo:{text}")])
|
||||
ev = types.SimpleNamespace(
|
||||
author="assistant",
|
||||
content=response,
|
||||
node_info=None,
|
||||
long_running_tool_ids=[],
|
||||
)
|
||||
yield ev
|
||||
|
||||
async def close(self, *a: Any, **k: Any) -> None:
|
||||
...
|
||||
|
||||
monkeypatch.setattr(cli, "Runner", _FakeRunner)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_agent(tmp_path: Path):
|
||||
"""Create a minimal importable agent package and patch importlib."""
|
||||
|
||||
parent_dir = tmp_path / "agents"
|
||||
parent_dir.mkdir()
|
||||
agent_dir = parent_dir / "fake_agent"
|
||||
agent_dir.mkdir()
|
||||
# __init__.py exposes root_agent with .name
|
||||
(agent_dir / "__init__.py").write_text(dedent("""
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
class FakeAgent(BaseAgent):
|
||||
def __init__(self, name):
|
||||
super().__init__(name=name)
|
||||
|
||||
root_agent = FakeAgent(name="fake_root")
|
||||
"""))
|
||||
|
||||
return parent_dir, "fake_agent"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_app_agent(tmp_path: Path):
|
||||
"""Create an agent package that exposes an App."""
|
||||
|
||||
parent_dir = tmp_path / "agents"
|
||||
parent_dir.mkdir()
|
||||
agent_dir = parent_dir / "fake_app_agent"
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "__init__.py").write_text(dedent("""
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
from google.adk.apps.app import App
|
||||
class FakeAgent(BaseAgent):
|
||||
def __init__(self, name):
|
||||
super().__init__(name=name)
|
||||
|
||||
root_agent = FakeAgent(name="fake_root")
|
||||
app = App(name="custom_cli_app", root_agent=root_agent)
|
||||
"""))
|
||||
|
||||
return parent_dir, "fake_app_agent", "custom_cli_app"
|
||||
|
||||
|
||||
# _run_input_file
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_input_file_outputs(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_input_file should echo user & assistant messages and return a populated session."""
|
||||
recorder: List[str] = []
|
||||
|
||||
def _echo(msg: str) -> None:
|
||||
recorder.append(msg)
|
||||
|
||||
monkeypatch.setattr(click, "echo", _echo)
|
||||
|
||||
input_json = {
|
||||
"state": {"foo": "bar"},
|
||||
"queries": ["hello world"],
|
||||
}
|
||||
input_path = tmp_path / "input.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
artifact_service = InMemoryArtifactService()
|
||||
session_service = InMemorySessionService()
|
||||
credential_service = InMemoryCredentialService()
|
||||
dummy_root = BaseAgent(name="root")
|
||||
|
||||
session = await cli.run_input_file(
|
||||
app_name="app",
|
||||
user_id="user",
|
||||
agent_or_app=dummy_root,
|
||||
artifact_service=artifact_service,
|
||||
session_service=session_service,
|
||||
credential_service=credential_service,
|
||||
input_path=str(input_path),
|
||||
)
|
||||
|
||||
assert session.state["foo"] == "bar"
|
||||
assert any("[user]:" in line for line in recorder)
|
||||
assert any("[assistant]:" in line for line in recorder)
|
||||
|
||||
|
||||
# _run_cli (input_file branch)
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_with_input_file(fake_agent, tmp_path: Path) -> None:
|
||||
"""run_cli should process an input file without raising and without saving."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
input_json = {"state": {}, "queries": ["ping"]}
|
||||
input_path = tmp_path / "in.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_loads_services_module(
|
||||
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_cli should load custom services from the agents directory."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
input_json = {"state": {}, "queries": ["ping"]}
|
||||
input_path = tmp_path / "input.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
loaded_dirs: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
cli, "load_services_module", lambda path: loaded_dirs.append(path)
|
||||
)
|
||||
|
||||
agent_root = parent_dir / folder_name
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
)
|
||||
|
||||
assert loaded_dirs == [str(agent_root.resolve())]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_app_uses_app_name_for_sessions(
|
||||
fake_app_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_cli should honor the App-provided name when creating sessions."""
|
||||
parent_dir, folder_name, app_name = fake_app_agent
|
||||
created_app_names: List[str] = []
|
||||
|
||||
class _SpySessionService(InMemorySessionService):
|
||||
|
||||
async def create_session(self, *, app_name: str, **kwargs: Any) -> Any:
|
||||
created_app_names.append(app_name)
|
||||
return await super().create_session(app_name=app_name, **kwargs)
|
||||
|
||||
spy_session_service = _SpySessionService()
|
||||
|
||||
def _session_factory(**_: Any) -> InMemorySessionService:
|
||||
return spy_session_service
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli, "create_session_service_from_options", _session_factory
|
||||
)
|
||||
|
||||
input_json = {"state": {}, "queries": ["ping"]}
|
||||
input_path = tmp_path / "input_app.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
)
|
||||
|
||||
assert created_app_names
|
||||
assert all(name == app_name for name in created_app_names)
|
||||
|
||||
|
||||
# _run_cli (interactive + save session branch)
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_save_session(
|
||||
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_cli should save a session file when save_session=True."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
|
||||
# Simulate user typing 'exit' followed by session id 'sess123'
|
||||
responses = iter(["exit", "sess123"])
|
||||
monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(responses))
|
||||
|
||||
session_file = Path(parent_dir) / folder_name / "sess123.session.json"
|
||||
if session_file.exists():
|
||||
session_file.unlink()
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=None,
|
||||
saved_session_file=None,
|
||||
save_session=True,
|
||||
)
|
||||
|
||||
assert session_file.exists()
|
||||
data = json.loads(session_file.read_text())
|
||||
# The saved JSON should at least contain id and events keys
|
||||
assert "id" in data and "events" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_artifact_service_isolates_artifacts_per_agent(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Each agent's artifacts should land in its own .adk/artifacts folder."""
|
||||
(tmp_path / "agent_a").mkdir()
|
||||
(tmp_path / "agent_b").mkdir()
|
||||
service = create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(service, PerAgentFileArtifactService)
|
||||
|
||||
# Touching each agent provisions its own per-agent .adk/artifacts folder.
|
||||
for app_name in ("agent_a", "agent_b"):
|
||||
await service.list_artifact_keys(
|
||||
app_name=app_name, user_id="user", session_id="session"
|
||||
)
|
||||
|
||||
assert (tmp_path / "agent_a" / ".adk" / "artifacts").exists()
|
||||
assert (tmp_path / "agent_b" / ".adk" / "artifacts").exists()
|
||||
assert not (tmp_path / ".adk").exists()
|
||||
|
||||
|
||||
def test_create_artifact_service_respects_memory_uri(tmp_path: Path) -> None:
|
||||
"""Service factory should honor memory:// URIs."""
|
||||
service = create_artifact_service_from_options(
|
||||
base_dir=tmp_path, artifact_service_uri="memory://"
|
||||
)
|
||||
assert isinstance(service, InMemoryArtifactService)
|
||||
|
||||
|
||||
def test_create_artifact_service_accepts_file_uri(tmp_path: Path) -> None:
|
||||
"""Service factory should allow custom local roots via file:// URIs."""
|
||||
custom_root = tmp_path / "custom_artifacts"
|
||||
service = create_artifact_service_from_options(
|
||||
base_dir=tmp_path, artifact_service_uri=custom_root.as_uri()
|
||||
)
|
||||
assert isinstance(service, FileArtifactService)
|
||||
assert service.root_dir == custom_root
|
||||
assert custom_root.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_accepts_memory_scheme(
|
||||
fake_agent, tmp_path: Path
|
||||
) -> None:
|
||||
"""run_cli should allow configuring in-memory services via memory:// URIs."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
input_json = {"state": {}, "queries": []}
|
||||
input_path = tmp_path / "noop.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
session_service_uri="memory://",
|
||||
artifact_service_uri="memory://",
|
||||
memory_service_uri="memory://",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_invalid_memory_uri_surfaces_value_error(
|
||||
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_cli should let ValueError propagate for invalid memory service URIs."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
input_json = {"state": {}, "queries": []}
|
||||
input_path = tmp_path / "invalid_memory_uri.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
def _raise_invalid_memory_uri(
|
||||
*,
|
||||
base_dir: Path | str,
|
||||
memory_service_uri: str | None = None,
|
||||
) -> object:
|
||||
del base_dir, memory_service_uri
|
||||
raise ValueError("Unsupported memory service URI: unknown://x")
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli, "create_memory_service_from_options", _raise_invalid_memory_uri
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported memory service URI"):
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
memory_service_uri="unknown://x",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_passes_memory_service_to_input_file(
|
||||
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_cli should construct and pass the configured memory service."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
input_json = {"state": {}, "queries": []}
|
||||
input_path = tmp_path / "memory_input.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
memory_service_sentinel = object()
|
||||
captured_factory_args: dict[str, Any] = {}
|
||||
captured_memory_service: dict[str, Any] = {}
|
||||
|
||||
def _memory_factory(
|
||||
*,
|
||||
base_dir: Path | str,
|
||||
memory_service_uri: str | None = None,
|
||||
) -> object:
|
||||
captured_factory_args["base_dir"] = base_dir
|
||||
captured_factory_args["memory_service_uri"] = memory_service_uri
|
||||
return memory_service_sentinel
|
||||
|
||||
async def _run_input_file(
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
agent_or_app: BaseAgent | App,
|
||||
artifact_service: Any,
|
||||
session_service: Any,
|
||||
credential_service: InMemoryCredentialService,
|
||||
input_path: str,
|
||||
memory_service: Any = None,
|
||||
) -> object:
|
||||
del app_name, user_id, agent_or_app, artifact_service
|
||||
del session_service, credential_service, input_path
|
||||
captured_memory_service["value"] = memory_service
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli, "create_memory_service_from_options", _memory_factory
|
||||
)
|
||||
monkeypatch.setattr(cli, "run_input_file", _run_input_file)
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
memory_service_uri="memory://",
|
||||
)
|
||||
|
||||
assert Path(captured_factory_args["base_dir"]) == parent_dir.resolve()
|
||||
assert captured_factory_args["memory_service_uri"] == "memory://"
|
||||
assert captured_memory_service["value"] is memory_service_sentinel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_loads_dotenv_before_memory_service_creation(
|
||||
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_cli should load agent .env values before creating memory service."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
input_json = {"state": {}, "queries": []}
|
||||
input_path = tmp_path / "dotenv_order_input.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
call_order: list[str] = []
|
||||
|
||||
def _load_dotenv_for_agent(agent_name: str, agents_dir: str) -> None:
|
||||
del agent_name, agents_dir
|
||||
call_order.append("load_dotenv")
|
||||
|
||||
def _memory_factory(
|
||||
*,
|
||||
base_dir: Path | str,
|
||||
memory_service_uri: str | None = None,
|
||||
) -> object:
|
||||
del base_dir, memory_service_uri
|
||||
call_order.append("create_memory")
|
||||
return object()
|
||||
|
||||
monkeypatch.setenv("ADK_DISABLE_LOAD_DOTENV", "0")
|
||||
monkeypatch.setattr(cli.envs, "load_dotenv_for_agent", _load_dotenv_for_agent)
|
||||
monkeypatch.setattr(
|
||||
cli, "create_memory_service_from_options", _memory_factory
|
||||
)
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
memory_service_uri="memory://",
|
||||
)
|
||||
|
||||
assert "create_memory" in call_order
|
||||
assert "load_dotenv" in call_order
|
||||
assert call_order.index("load_dotenv") < call_order.index("create_memory")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_interactively_whitespace_and_exit(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_interactively should skip blank input, echo once, then exit."""
|
||||
# make a session that belongs to dummy agent
|
||||
session_service = InMemorySessionService()
|
||||
sess = await session_service.create_session(app_name="dummy", user_id="u")
|
||||
artifact_service = InMemoryArtifactService()
|
||||
credential_service = InMemoryCredentialService()
|
||||
root_agent = BaseAgent(name="root")
|
||||
|
||||
# fake user input: blank -> 'hello' -> 'exit'
|
||||
answers = iter([" ", "hello", "exit"])
|
||||
monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(answers))
|
||||
|
||||
# capture assisted echo
|
||||
echoed: list[str] = []
|
||||
monkeypatch.setattr(click, "echo", lambda msg: echoed.append(msg))
|
||||
|
||||
await cli.run_interactively(
|
||||
root_agent, artifact_service, sess, session_service, credential_service
|
||||
)
|
||||
|
||||
# verify: assistant echoed once with 'echo:hello'
|
||||
assert any("echo:hello" in m for m in echoed)
|
||||
@@ -0,0 +1,560 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for utilities in cli_create."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Tuple
|
||||
|
||||
import click
|
||||
import google.adk.cli.cli_create as cli_create
|
||||
from google.adk.cli.utils import _onboarding
|
||||
from google.adk.cli.utils import gcp_utils
|
||||
import pytest
|
||||
|
||||
|
||||
# Helpers
|
||||
class _Recorder:
|
||||
"""A callable object that records every invocation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: D401
|
||||
self.calls.append((args, kwargs))
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Silence click output in every test."""
|
||||
monkeypatch.setattr(click, "echo", lambda *a, **k: None)
|
||||
monkeypatch.setattr(click, "secho", lambda *a, **k: None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def agent_folder(tmp_path: Path) -> Path:
|
||||
"""Return a temporary path that will hold generated agent sources."""
|
||||
return tmp_path / "agent"
|
||||
|
||||
|
||||
# _generate_files
|
||||
def test_generate_files_with_api_key(agent_folder: Path) -> None:
|
||||
"""Files should be created with the API-key backend and correct .env flags."""
|
||||
cli_create._generate_files(
|
||||
str(agent_folder),
|
||||
google_api_key="dummy-key",
|
||||
model="gemini-2.5-flash",
|
||||
type="code",
|
||||
)
|
||||
|
||||
env_content = (agent_folder / ".env").read_text()
|
||||
assert "GOOGLE_API_KEY=dummy-key" in env_content
|
||||
assert "GOOGLE_GENAI_USE_ENTERPRISE=0" in env_content
|
||||
assert (agent_folder / ".gitignore").read_text() == ".env\n"
|
||||
assert (agent_folder / "agent.py").exists()
|
||||
assert (agent_folder / "__init__.py").exists()
|
||||
|
||||
|
||||
def test_generate_files_with_gcp(agent_folder: Path) -> None:
|
||||
"""Files should be created with Vertex AI backend and correct .env flags."""
|
||||
cli_create._generate_files(
|
||||
str(agent_folder),
|
||||
google_cloud_project="proj",
|
||||
google_cloud_region="us-central1",
|
||||
model="gemini-2.5-flash",
|
||||
type="code",
|
||||
)
|
||||
|
||||
env_content = (agent_folder / ".env").read_text()
|
||||
assert "GOOGLE_CLOUD_PROJECT=proj" in env_content
|
||||
assert "GOOGLE_CLOUD_LOCATION=us-central1" in env_content
|
||||
assert "GOOGLE_GENAI_USE_ENTERPRISE=1" in env_content
|
||||
|
||||
|
||||
def test_generate_files_with_express_mode(agent_folder: Path) -> None:
|
||||
"""Files should be created with Vertex AI backend when both project and API key are present (Express Mode)."""
|
||||
cli_create._generate_files(
|
||||
str(agent_folder),
|
||||
google_api_key="express-api-key",
|
||||
google_cloud_project="express-project-id",
|
||||
google_cloud_region="us-central1",
|
||||
model="gemini-2.5-flash",
|
||||
type="code",
|
||||
)
|
||||
|
||||
env_content = (agent_folder / ".env").read_text()
|
||||
assert "GOOGLE_GENAI_USE_ENTERPRISE=1" in env_content
|
||||
assert "GOOGLE_API_KEY=express-api-key" in env_content
|
||||
assert "GOOGLE_CLOUD_PROJECT=express-project-id" in env_content
|
||||
|
||||
|
||||
def test_generate_files_overwrite(agent_folder: Path) -> None:
|
||||
"""Existing files should be overwritten when generating again."""
|
||||
agent_folder.mkdir(parents=True, exist_ok=True)
|
||||
(agent_folder / ".env").write_text("OLD")
|
||||
|
||||
cli_create._generate_files(
|
||||
str(agent_folder),
|
||||
google_api_key="new-key",
|
||||
model="gemini-2.5-flash",
|
||||
type="code",
|
||||
)
|
||||
|
||||
assert "GOOGLE_API_KEY=new-key" in (agent_folder / ".env").read_text()
|
||||
|
||||
|
||||
def test_generate_files_permission_error(
|
||||
monkeypatch: pytest.MonkeyPatch, agent_folder: Path
|
||||
) -> None:
|
||||
"""PermissionError raised by os.makedirs should propagate."""
|
||||
monkeypatch.setattr(
|
||||
os, "makedirs", lambda *a, **k: (_ for _ in ()).throw(PermissionError())
|
||||
)
|
||||
with pytest.raises(PermissionError):
|
||||
cli_create._generate_files(
|
||||
str(agent_folder), model="gemini-2.5-flash", type="code"
|
||||
)
|
||||
|
||||
|
||||
def test_generate_files_no_params(agent_folder: Path) -> None:
|
||||
"""No backend parameters → minimal .env file is generated."""
|
||||
cli_create._generate_files(
|
||||
str(agent_folder), model="gemini-2.5-flash", type="code"
|
||||
)
|
||||
|
||||
env_content = (agent_folder / ".env").read_text()
|
||||
for key in (
|
||||
"GOOGLE_API_KEY",
|
||||
"GOOGLE_CLOUD_PROJECT",
|
||||
"GOOGLE_CLOUD_LOCATION",
|
||||
"GOOGLE_GENAI_USE_ENTERPRISE",
|
||||
):
|
||||
assert key not in env_content
|
||||
|
||||
|
||||
def test_generate_files_appends_dotenv_to_existing_gitignore(
|
||||
agent_folder: Path,
|
||||
) -> None:
|
||||
"""Existing .gitignore entries should be preserved."""
|
||||
agent_folder.mkdir(parents=True, exist_ok=True)
|
||||
(agent_folder / ".gitignore").write_text("__pycache__")
|
||||
|
||||
cli_create._generate_files(
|
||||
str(agent_folder), model="gemini-2.0-flash-001", type="code"
|
||||
)
|
||||
|
||||
assert (agent_folder / ".gitignore").read_text() == "__pycache__\n.env\n"
|
||||
|
||||
|
||||
def test_generate_files_appends_dotenv_to_existing_gitignore_with_newline(
|
||||
agent_folder: Path,
|
||||
) -> None:
|
||||
"""Existing .gitignore entries ending in a newline should not cause extra blank lines."""
|
||||
agent_folder.mkdir(parents=True, exist_ok=True)
|
||||
(agent_folder / ".gitignore").write_text("__pycache__\n")
|
||||
|
||||
cli_create._generate_files(
|
||||
str(agent_folder), model="gemini-2.0-flash-001", type="code"
|
||||
)
|
||||
|
||||
assert (agent_folder / ".gitignore").read_text() == "__pycache__\n.env\n"
|
||||
|
||||
|
||||
def test_generate_files_does_not_duplicate_dotenv_gitignore_entry(
|
||||
agent_folder: Path,
|
||||
) -> None:
|
||||
"""Existing .env ignore entries should not be duplicated."""
|
||||
agent_folder.mkdir(parents=True, exist_ok=True)
|
||||
(agent_folder / ".gitignore").write_text("__pycache__\n.env\n")
|
||||
|
||||
cli_create._generate_files(
|
||||
str(agent_folder), model="gemini-2.0-flash-001", type="code"
|
||||
)
|
||||
|
||||
assert (agent_folder / ".gitignore").read_text() == "__pycache__\n.env\n"
|
||||
|
||||
|
||||
# run_cmd
|
||||
def test_run_cmd_overwrite_reject(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""User rejecting overwrite should trigger click.Abort."""
|
||||
agent_name = "agent"
|
||||
agent_dir = tmp_path / agent_name
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "dummy.txt").write_text("dummy")
|
||||
|
||||
monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(click, "confirm", lambda *a, **k: False)
|
||||
|
||||
with pytest.raises(click.Abort):
|
||||
cli_create.run_cmd(
|
||||
agent_name,
|
||||
model="gemini-2.5-flash",
|
||||
google_api_key=None,
|
||||
google_cloud_project=None,
|
||||
google_cloud_region=None,
|
||||
type="code",
|
||||
)
|
||||
|
||||
|
||||
def test_run_cmd_invalid_app_name(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Invalid app names should be rejected before creating any files."""
|
||||
monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path))
|
||||
|
||||
with pytest.raises(click.BadParameter, match="Invalid app name"):
|
||||
cli_create.run_cmd(
|
||||
"invalid name",
|
||||
model="gemini-2.5-flash",
|
||||
google_api_key=None,
|
||||
google_cloud_project=None,
|
||||
google_cloud_region=None,
|
||||
type="code",
|
||||
)
|
||||
|
||||
|
||||
def test_run_cmd_with_type_config(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""run_cmd with --type=config should generate YAML config file."""
|
||||
agent_name = "test_agent"
|
||||
|
||||
monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path))
|
||||
|
||||
cli_create.run_cmd(
|
||||
agent_name,
|
||||
model="gemini-2.5-flash",
|
||||
google_api_key="test-key",
|
||||
google_cloud_project=None,
|
||||
google_cloud_region=None,
|
||||
type="config",
|
||||
)
|
||||
|
||||
agent_dir = tmp_path / agent_name
|
||||
assert agent_dir.exists()
|
||||
|
||||
# Should create root_agent.yaml instead of agent.py
|
||||
yaml_file = agent_dir / "root_agent.yaml"
|
||||
assert yaml_file.exists()
|
||||
assert not (agent_dir / "agent.py").exists()
|
||||
|
||||
# Check YAML content
|
||||
yaml_content = yaml_file.read_text()
|
||||
assert "name: root_agent" in yaml_content
|
||||
assert "model: gemini-2.5-flash" in yaml_content
|
||||
assert "description: A helpful assistant for user questions." in yaml_content
|
||||
|
||||
# Should create empty __init__.py
|
||||
init_file = agent_dir / "__init__.py"
|
||||
assert init_file.exists()
|
||||
assert init_file.read_text().strip() == ""
|
||||
|
||||
# Should still create .env file
|
||||
env_file = agent_dir / ".env"
|
||||
assert env_file.exists()
|
||||
assert "GOOGLE_API_KEY=test-key" in env_file.read_text()
|
||||
assert (agent_dir / ".gitignore").read_text() == ".env\n"
|
||||
|
||||
|
||||
# Prompt helpers
|
||||
def test_prompt_for_google_cloud(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Prompt should return the project input."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "test-proj")
|
||||
assert _onboarding.prompt_for_google_cloud(None) == "test-proj"
|
||||
|
||||
|
||||
def test_prompt_for_google_cloud_region(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Prompt should return the region input."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "asia-northeast1")
|
||||
assert _onboarding.prompt_for_google_cloud_region(None) == "asia-northeast1"
|
||||
|
||||
|
||||
def test_prompt_for_google_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Prompt should return the API-key input."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "api-key")
|
||||
assert _onboarding.prompt_for_google_api_key(None) == "api-key"
|
||||
|
||||
|
||||
def test_prompt_for_model_gemini(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Selecting option '1' should return the default Gemini model string."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "1")
|
||||
assert cli_create._prompt_for_model() == "gemini-3.5-flash"
|
||||
|
||||
|
||||
def test_prompt_for_model_other(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Selecting option '2' should return placeholder and call secho."""
|
||||
called: Dict[str, bool] = {}
|
||||
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "2")
|
||||
|
||||
def _fake_secho(*_a: Any, **_k: Any) -> None:
|
||||
called["secho"] = True
|
||||
|
||||
monkeypatch.setattr(click, "secho", _fake_secho)
|
||||
assert cli_create._prompt_for_model() == "<FILL_IN_MODEL>"
|
||||
assert called.get("secho") is True
|
||||
|
||||
|
||||
# Backend selection helper
|
||||
def test_prompt_to_choose_backend_api(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Choosing API-key backend returns (api_key, None, None)."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "1")
|
||||
monkeypatch.setattr(
|
||||
_onboarding, "prompt_for_google_api_key", lambda _v: "api-key"
|
||||
)
|
||||
|
||||
auth_info = _onboarding.prompt_to_choose_backend(None, None, None)
|
||||
assert isinstance(auth_info, _onboarding.GoogleAIAuth)
|
||||
assert auth_info.api_key == "api-key"
|
||||
|
||||
|
||||
def test_prompt_to_choose_backend_vertex(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Choosing Vertex backend returns (None, project, region)."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "2")
|
||||
monkeypatch.setattr(_onboarding, "prompt_for_google_cloud", lambda _v: "proj")
|
||||
monkeypatch.setattr(
|
||||
_onboarding, "prompt_for_google_cloud_region", lambda _v: "region"
|
||||
)
|
||||
|
||||
auth_info = _onboarding.prompt_to_choose_backend(None, None, None)
|
||||
assert isinstance(auth_info, _onboarding.VertexAIAuth)
|
||||
assert auth_info.project_id == "proj"
|
||||
assert auth_info.region == "region"
|
||||
|
||||
|
||||
def test_prompt_to_choose_backend_login(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Choosing Login with Google returns (api_key, project, region) from handler."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "3")
|
||||
monkeypatch.setattr(
|
||||
_onboarding,
|
||||
"handle_login_with_google",
|
||||
lambda: _onboarding.ExpressModeAuth(
|
||||
api_key="api-key", project_id="proj", region="region"
|
||||
),
|
||||
)
|
||||
|
||||
auth_info = _onboarding.prompt_to_choose_backend(None, None, None)
|
||||
assert isinstance(auth_info, _onboarding.ExpressModeAuth)
|
||||
assert auth_info.api_key == "api-key"
|
||||
assert auth_info.project_id == "proj"
|
||||
assert auth_info.region == "region"
|
||||
|
||||
|
||||
def test_handle_login_with_google_existing_express(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Handler should return existing Express project if found."""
|
||||
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
gcp_utils,
|
||||
"retrieve_express_project",
|
||||
lambda: {"api_key": "key", "project_id": "proj", "region": "us-central1"},
|
||||
)
|
||||
|
||||
auth_info = _onboarding.handle_login_with_google()
|
||||
assert isinstance(auth_info, _onboarding.ExpressModeAuth)
|
||||
assert auth_info.api_key == "key"
|
||||
assert auth_info.project_id == "proj"
|
||||
assert auth_info.region == "us-central1"
|
||||
|
||||
|
||||
def test_handle_login_with_google_select_gcp_project(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Handler should prompt for project selection if no Express project found."""
|
||||
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
|
||||
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
gcp_utils, "list_gcp_projects", lambda limit: [("p1", "Project 1")]
|
||||
)
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: 1)
|
||||
monkeypatch.setattr(
|
||||
_onboarding, "prompt_for_google_cloud_region", lambda _v: "us-east1"
|
||||
)
|
||||
|
||||
auth_info = _onboarding.handle_login_with_google()
|
||||
assert isinstance(auth_info, _onboarding.VertexAIAuth)
|
||||
assert auth_info.project_id == "p1"
|
||||
assert auth_info.region == "us-east1"
|
||||
|
||||
|
||||
def test_handle_login_with_google_manual_project(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Handler should allow manual project ID entry when '0' is selected."""
|
||||
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
|
||||
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
gcp_utils, "list_gcp_projects", lambda limit: [("p1", "Project 1")]
|
||||
)
|
||||
prompts = iter([0, "manual-proj", "us-east1"])
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
|
||||
|
||||
auth_info = _onboarding.handle_login_with_google()
|
||||
assert isinstance(auth_info, _onboarding.VertexAIAuth)
|
||||
assert auth_info.project_id == "manual-proj"
|
||||
assert auth_info.region == "us-east1"
|
||||
|
||||
|
||||
def test_handle_login_with_google_option_1(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""User selects 1, enters project ID and region."""
|
||||
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
|
||||
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
|
||||
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
|
||||
prompts = iter(["1", "test-proj", "us-east1"])
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
|
||||
|
||||
auth_info = _onboarding.handle_login_with_google()
|
||||
assert isinstance(auth_info, _onboarding.VertexAIAuth)
|
||||
assert auth_info.project_id == "test-proj"
|
||||
assert auth_info.region == "us-east1"
|
||||
|
||||
|
||||
def test_handle_login_with_google_option_2(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""User selects 2, goes through express sign up."""
|
||||
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
|
||||
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
|
||||
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
|
||||
monkeypatch.setattr(gcp_utils, "check_express_eligibility", lambda: True)
|
||||
monkeypatch.setattr(click, "confirm", lambda *a, **k: True)
|
||||
prompts = iter(["2", "1"])
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
|
||||
monkeypatch.setattr(
|
||||
gcp_utils,
|
||||
"sign_up_express",
|
||||
lambda location="us-central1": {
|
||||
"api_key": "new-key",
|
||||
"project_id": "new-proj",
|
||||
"region": location,
|
||||
},
|
||||
)
|
||||
|
||||
auth_info = _onboarding.handle_login_with_google()
|
||||
assert isinstance(auth_info, _onboarding.ExpressModeAuth)
|
||||
assert auth_info.api_key == "new-key"
|
||||
assert auth_info.project_id == "new-proj"
|
||||
assert auth_info.region == "us-central1"
|
||||
|
||||
|
||||
def test_handle_login_with_google_option_2_unset_project(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""User selects 2, goes through express sign up, and unsets existing gcloud project."""
|
||||
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
|
||||
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
|
||||
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
|
||||
monkeypatch.setattr(gcp_utils, "check_express_eligibility", lambda: True)
|
||||
|
||||
confirms = iter([True, True])
|
||||
monkeypatch.setattr(click, "confirm", lambda *a, **k: next(confirms))
|
||||
|
||||
prompts = iter(["2", "1"])
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
|
||||
|
||||
monkeypatch.setattr(
|
||||
gcp_utils,
|
||||
"sign_up_express",
|
||||
lambda location="us-central1": {
|
||||
"api_key": "new-key",
|
||||
"project_id": "new-proj",
|
||||
"region": location,
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_onboarding, "get_gcp_project_from_gcloud", lambda: "old-proj"
|
||||
)
|
||||
|
||||
called = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd == ["gcloud", "config", "unset", "project"]:
|
||||
called["unset"] = True
|
||||
return subprocess.CompletedProcess(args=cmd, returncode=0)
|
||||
raise ValueError(f"Unexpected command: {cmd}")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
auth_info = _onboarding.handle_login_with_google()
|
||||
assert isinstance(auth_info, _onboarding.ExpressModeAuth)
|
||||
assert auth_info.api_key == "new-key"
|
||||
assert auth_info.project_id == "new-proj"
|
||||
assert auth_info.region == "us-central1"
|
||||
assert called.get("unset") is True
|
||||
|
||||
|
||||
def test_handle_login_with_google_option_3(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""User selects 3, aborts."""
|
||||
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
|
||||
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "3")
|
||||
with pytest.raises(click.Abort):
|
||||
_onboarding.handle_login_with_google()
|
||||
|
||||
|
||||
# prompt_str
|
||||
def test_prompt_str_non_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""prompt_str should retry until a non-blank string is provided."""
|
||||
responses = iter(["", " ", "valid"])
|
||||
monkeypatch.setattr(click, "prompt", lambda *_a, **_k: next(responses))
|
||||
assert _onboarding.prompt_str("dummy") == "valid"
|
||||
|
||||
|
||||
# gcloud fallback helpers
|
||||
def test_get_gcp_project_from_gcloud_fail(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Failure of gcloud project lookup should return empty string."""
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *_a, **_k: (_ for _ in ()).throw(FileNotFoundError()),
|
||||
)
|
||||
assert _onboarding.get_gcp_project_from_gcloud() == ""
|
||||
|
||||
|
||||
def test_get_gcp_region_from_gcloud_fail(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""CalledProcessError should result in empty region string."""
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *_a, **_k: (_ for _ in ()).throw(
|
||||
subprocess.CalledProcessError(1, "gcloud")
|
||||
),
|
||||
)
|
||||
assert _onboarding.get_gcp_region_from_gcloud() == ""
|
||||
@@ -0,0 +1,714 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for utilities in cli_deploy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Tuple
|
||||
from unittest import mock
|
||||
|
||||
import click
|
||||
from click.testing import CliRunner
|
||||
import pytest
|
||||
|
||||
import src.google.adk.cli.cli_deploy as cli_deploy
|
||||
import src.google.adk.cli.cli_tools_click as cli_tools_click
|
||||
|
||||
|
||||
# Helpers
|
||||
class _Recorder:
|
||||
"""A callable object that records every invocation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> None:
|
||||
self.calls.append((args, kwargs))
|
||||
|
||||
def get_last_call_args(self) -> Tuple[Any, ...]:
|
||||
"""Returns the positional arguments of the last call."""
|
||||
if not self.calls:
|
||||
raise IndexError("No calls have been recorded.")
|
||||
return self.calls[-1][0]
|
||||
|
||||
def get_last_call_kwargs(self) -> Dict[str, Any]:
|
||||
"""Returns the keyword arguments of the last call."""
|
||||
if not self.calls:
|
||||
raise IndexError("No calls have been recorded.")
|
||||
return self.calls[-1][1]
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Suppress click.echo to keep test output clean."""
|
||||
monkeypatch.setattr(click, "echo", lambda *a, **k: None)
|
||||
monkeypatch.setattr(click, "secho", lambda *a, **k: None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reload_cli_deploy():
|
||||
"""Reload cli_deploy before each test."""
|
||||
importlib.reload(cli_deploy)
|
||||
yield # This allows the test to run after the module has been reloaded.
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def agent_dir(tmp_path: Path) -> Callable[[bool, bool], Path]:
|
||||
"""
|
||||
Return a factory that creates a dummy agent directory tree.
|
||||
"""
|
||||
|
||||
def _factory(include_requirements: bool, include_env: bool) -> Path:
|
||||
base = tmp_path / "agent"
|
||||
base.mkdir()
|
||||
(base / "agent.py").write_text(
|
||||
"# dummy agent\nroot_agent = 'dummy_agent'\n"
|
||||
)
|
||||
(base / "__init__.py").touch()
|
||||
if include_requirements:
|
||||
(base / "requirements.txt").write_text("pytest\n")
|
||||
if include_env:
|
||||
(base / ".env").write_text('TEST_VAR="test_value"\n')
|
||||
return base
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
# _resolve_project
|
||||
def test_resolve_project_with_option() -> None:
|
||||
"""It should return the explicit project value untouched."""
|
||||
assert cli_deploy._resolve_project("my-project") == "my-project"
|
||||
|
||||
|
||||
def test_resolve_project_from_gcloud(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""It should fall back to `gcloud config get-value project` when no value supplied."""
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *a, **k: types.SimpleNamespace(stdout="gcp-proj\n"),
|
||||
)
|
||||
|
||||
with mock.patch("click.echo") as mocked_echo:
|
||||
assert cli_deploy._resolve_project(None) == "gcp-proj"
|
||||
mocked_echo.assert_called_once()
|
||||
|
||||
|
||||
def test_resolve_project_from_gcloud_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""It should raise an exception if the gcloud command fails."""
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
mock.Mock(side_effect=subprocess.CalledProcessError(1, "cmd", "err")),
|
||||
)
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
cli_deploy._resolve_project(None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"adk_version, session_uri, artifact_uri, memory_uri, use_local_storage, "
|
||||
"expected",
|
||||
[
|
||||
(
|
||||
"1.3.0",
|
||||
"sqlite://s",
|
||||
"gs://a",
|
||||
"rag://m",
|
||||
None,
|
||||
(
|
||||
"--session_service_uri=sqlite://s --artifact_service_uri=gs://a"
|
||||
" --memory_service_uri=rag://m"
|
||||
),
|
||||
),
|
||||
(
|
||||
"1.2.5",
|
||||
"sqlite://s",
|
||||
"gs://a",
|
||||
"rag://m",
|
||||
None,
|
||||
(
|
||||
"--session_service_uri=sqlite://s --artifact_service_uri=gs://a"
|
||||
" --memory_service_uri=rag://m"
|
||||
),
|
||||
),
|
||||
(
|
||||
"0.5.0",
|
||||
"sqlite://s",
|
||||
"gs://a",
|
||||
"rag://m",
|
||||
None,
|
||||
(
|
||||
"--session_service_uri=sqlite://s --artifact_service_uri=gs://a"
|
||||
" --memory_service_uri=rag://m"
|
||||
),
|
||||
),
|
||||
(
|
||||
"1.3.0",
|
||||
"sqlite://s",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"--session_service_uri=sqlite://s",
|
||||
),
|
||||
(
|
||||
"1.3.0",
|
||||
None,
|
||||
"gs://a",
|
||||
"rag://m",
|
||||
None,
|
||||
"--artifact_service_uri=gs://a --memory_service_uri=rag://m",
|
||||
),
|
||||
(
|
||||
"1.2.0",
|
||||
None,
|
||||
"gs://a",
|
||||
None,
|
||||
None,
|
||||
"--artifact_service_uri=gs://a",
|
||||
),
|
||||
(
|
||||
"1.21.0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
"--no_use_local_storage",
|
||||
),
|
||||
(
|
||||
"1.21.0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
True,
|
||||
"--use_local_storage",
|
||||
),
|
||||
(
|
||||
"1.21.0",
|
||||
"sqlite://s",
|
||||
"gs://a",
|
||||
None,
|
||||
False,
|
||||
"--session_service_uri=sqlite://s --artifact_service_uri=gs://a",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_service_option_by_adk_version(
|
||||
adk_version: str,
|
||||
session_uri: str | None,
|
||||
artifact_uri: str | None,
|
||||
memory_uri: str | None,
|
||||
use_local_storage: bool | None,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""It should return the correct service URI flags for a given ADK version."""
|
||||
actual = cli_deploy._get_service_option_by_adk_version(
|
||||
adk_version=adk_version,
|
||||
session_uri=session_uri,
|
||||
artifact_uri=artifact_uri,
|
||||
memory_uri=memory_uri,
|
||||
use_local_storage=use_local_storage,
|
||||
)
|
||||
assert actual.rstrip() == expected.rstrip()
|
||||
|
||||
|
||||
def test_print_agent_engine_url() -> None:
|
||||
"""It should print the correct URL for a fully-qualified resource name."""
|
||||
with mock.patch("click.secho") as mocked_secho:
|
||||
cli_deploy._print_agent_engine_url(
|
||||
"projects/my-project/locations/us-central1/reasoningEngines/123456"
|
||||
)
|
||||
mocked_secho.assert_called_once()
|
||||
call_args = mocked_secho.call_args[0][0]
|
||||
assert "my-project" in call_args
|
||||
assert "us-central1" in call_args
|
||||
assert "123456" in call_args
|
||||
assert "playground" in call_args
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_requirements", [True, False])
|
||||
def test_to_agent_engine_happy_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: Callable[[bool, bool], Path],
|
||||
include_requirements: bool,
|
||||
) -> None:
|
||||
"""Tests the happy path for the `to_agent_engine` function."""
|
||||
rmtree_recorder = _Recorder()
|
||||
monkeypatch.setattr(shutil, "rmtree", rmtree_recorder)
|
||||
create_recorder = _Recorder()
|
||||
|
||||
fake_vertexai = types.ModuleType("vertexai")
|
||||
|
||||
class _FakeAgentEngines:
|
||||
|
||||
def create(self, **kwargs: Any) -> Any:
|
||||
create_recorder(**kwargs)
|
||||
return types.SimpleNamespace(
|
||||
api_resource=types.SimpleNamespace(
|
||||
name="projects/p/locations/l/reasoningEngines/e"
|
||||
)
|
||||
)
|
||||
|
||||
def update(self, *, name: str, config: Dict[str, Any]) -> None:
|
||||
del name
|
||||
del config
|
||||
|
||||
class _FakeVertexClient:
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
del args
|
||||
del kwargs
|
||||
self.agent_engines = _FakeAgentEngines()
|
||||
|
||||
fake_vertexai.Client = _FakeVertexClient
|
||||
monkeypatch.setitem(sys.modules, "vertexai", fake_vertexai)
|
||||
src_dir = agent_dir(include_requirements, False)
|
||||
tmp_dir = src_dir.parent / "tmp"
|
||||
cli_deploy.to_agent_engine(
|
||||
agent_folder=str(src_dir),
|
||||
temp_folder="tmp",
|
||||
trace_to_cloud=True,
|
||||
project="my-gcp-project",
|
||||
region="us-central1",
|
||||
display_name="My Test Agent",
|
||||
description="A test agent.",
|
||||
adk_version="1.2.0",
|
||||
)
|
||||
agent_file = tmp_dir / "Dockerfile"
|
||||
assert agent_file.is_file()
|
||||
assert len(create_recorder.calls) == 1
|
||||
assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_dir)
|
||||
|
||||
requirements_file = tmp_dir / "agents" / "agent" / "requirements.txt"
|
||||
assert requirements_file.is_file()
|
||||
assert (
|
||||
"google-cloud-aiplatform[adk,agent_engines]"
|
||||
in requirements_file.read_text()
|
||||
)
|
||||
|
||||
|
||||
def test_to_agent_engine_raises_when_explicit_config_file_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: Callable[[bool, bool], Path],
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""It should fail with a clear error when --agent_engine_config_file is missing."""
|
||||
monkeypatch.setattr(shutil, "rmtree", lambda *a, **k: None)
|
||||
src_dir = agent_dir(False, False)
|
||||
missing_config = tmp_path / "no_such_agent_engine_config.json"
|
||||
expected_abs = str(missing_config.resolve())
|
||||
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
cli_deploy.to_agent_engine(
|
||||
agent_folder=str(src_dir),
|
||||
temp_folder="tmp",
|
||||
trace_to_cloud=True,
|
||||
project="my-gcp-project",
|
||||
region="us-central1",
|
||||
display_name="My Test Agent",
|
||||
description="A test agent.",
|
||||
agent_engine_config_file=str(missing_config),
|
||||
adk_version="1.2.0",
|
||||
)
|
||||
|
||||
assert "Agent Platform config file not found" in str(exc_info.value)
|
||||
assert expected_abs in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_requirements", [True, False])
|
||||
def test_to_gke_happy_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: Callable[[bool, bool], Path],
|
||||
tmp_path: Path,
|
||||
include_requirements: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Tests the happy path for the `to_gke` function.
|
||||
"""
|
||||
src_dir = agent_dir(include_requirements, False)
|
||||
run_recorder = _Recorder()
|
||||
rmtree_recorder = _Recorder()
|
||||
|
||||
def mock_subprocess_run(*args, **kwargs):
|
||||
run_recorder(*args, **kwargs)
|
||||
command_list = args[0]
|
||||
if command_list and command_list[0:2] == ["kubectl", "apply"]:
|
||||
fake_stdout = "deployment.apps/gke-svc created\nservice/gke-svc created"
|
||||
return types.SimpleNamespace(stdout=fake_stdout)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", mock_subprocess_run)
|
||||
monkeypatch.setattr(shutil, "rmtree", rmtree_recorder)
|
||||
|
||||
cli_deploy.to_gke(
|
||||
agent_folder=str(src_dir),
|
||||
project="gke-proj",
|
||||
region="us-east1",
|
||||
cluster_name="my-gke-cluster",
|
||||
service_name="gke-svc",
|
||||
app_name="agent",
|
||||
temp_folder=str(tmp_path),
|
||||
port=9090,
|
||||
trace_to_cloud=False,
|
||||
otel_to_cloud=False,
|
||||
with_ui=True,
|
||||
log_level="debug",
|
||||
adk_version="1.2.0",
|
||||
allow_origins=["http://localhost:3000", "https://my-app.com"],
|
||||
session_service_uri="sqlite:///",
|
||||
artifact_service_uri="gs://gke-bucket",
|
||||
)
|
||||
|
||||
dockerfile_path = tmp_path / "Dockerfile"
|
||||
assert dockerfile_path.is_file()
|
||||
dockerfile_content = dockerfile_path.read_text()
|
||||
assert "CMD adk api_server --with_ui --port=9090" in dockerfile_content
|
||||
assert 'RUN pip install "google-adk[a2a]==1.2.0"' in dockerfile_content
|
||||
|
||||
assert len(run_recorder.calls) == 3, "Expected 3 subprocess calls"
|
||||
|
||||
build_args = run_recorder.calls[0][0][0]
|
||||
expected_build_args = [
|
||||
"gcloud",
|
||||
"builds",
|
||||
"submit",
|
||||
"--tag",
|
||||
"gcr.io/gke-proj/gke-svc",
|
||||
"--verbosity",
|
||||
"debug",
|
||||
str(tmp_path),
|
||||
]
|
||||
assert build_args == expected_build_args
|
||||
|
||||
creds_args = run_recorder.calls[1][0][0]
|
||||
expected_creds_args = [
|
||||
"gcloud",
|
||||
"container",
|
||||
"clusters",
|
||||
"get-credentials",
|
||||
"my-gke-cluster",
|
||||
"--region",
|
||||
"us-east1",
|
||||
"--project",
|
||||
"gke-proj",
|
||||
]
|
||||
assert creds_args == expected_creds_args
|
||||
|
||||
assert (
|
||||
"--allow_origins=http://localhost:3000,https://my-app.com"
|
||||
in dockerfile_content
|
||||
)
|
||||
|
||||
apply_args = run_recorder.calls[2][0][0]
|
||||
expected_apply_args = ["kubectl", "apply", "-f", str(tmp_path)]
|
||||
assert apply_args == expected_apply_args
|
||||
|
||||
deployment_yaml_path = tmp_path / "deployment.yaml"
|
||||
assert deployment_yaml_path.is_file()
|
||||
yaml_content = deployment_yaml_path.read_text()
|
||||
|
||||
assert "kind: Deployment" in yaml_content
|
||||
assert "kind: Service" in yaml_content
|
||||
assert "name: gke-svc" in yaml_content
|
||||
assert "image: gcr.io/gke-proj/gke-svc" in yaml_content
|
||||
assert f"containerPort: 9090" in yaml_content
|
||||
assert f"targetPort: 9090" in yaml_content
|
||||
assert "type: ClusterIP" in yaml_content
|
||||
|
||||
# 4. Verify cleanup
|
||||
assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_path)
|
||||
|
||||
|
||||
# _validate_agent_import tests
|
||||
class TestValidateAgentImport:
|
||||
"""Tests for the _validate_agent_import function."""
|
||||
|
||||
def test_skips_config_agents(self, tmp_path: Path) -> None:
|
||||
"""Config agents should skip validation."""
|
||||
# This should not raise even with no agent.py file
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=True
|
||||
)
|
||||
|
||||
def test_raises_on_missing_agent_module(self, tmp_path: Path) -> None:
|
||||
"""Should raise when agent.py is missing."""
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
assert "Agent module not found" in str(exc_info.value)
|
||||
|
||||
def test_raises_on_missing_export(self, tmp_path: Path) -> None:
|
||||
"""Should raise when the expected export is missing."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("some_other_var = 'hello'\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
assert "does not export 'root_agent'" in str(exc_info.value)
|
||||
assert "some_other_var" in str(exc_info.value)
|
||||
|
||||
def test_success_with_root_agent_export(self, tmp_path: Path) -> None:
|
||||
"""Should succeed when root_agent is exported."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("root_agent = 'my_agent'\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
# Should not raise
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
|
||||
def test_success_with_app_export(self, tmp_path: Path) -> None:
|
||||
"""Should succeed when app is exported."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("app = 'my_app'\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
# Should not raise
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "app", is_config_agent=False
|
||||
)
|
||||
|
||||
def test_success_with_relative_imports(self, tmp_path: Path) -> None:
|
||||
"""Should succeed when agent.py uses relative imports."""
|
||||
(tmp_path / "helper.py").write_text("VALUE = 'my_agent'\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
(tmp_path / "agent.py").write_text(
|
||||
"from .helper import VALUE\n\nroot_agent = VALUE\n"
|
||||
)
|
||||
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
|
||||
def test_raises_on_import_error(self, tmp_path: Path) -> None:
|
||||
"""Should raise with helpful message on ImportError."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("from nonexistent_module import something\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
assert "Failed to import agent module" in str(exc_info.value)
|
||||
assert "nonexistent_module" in str(exc_info.value)
|
||||
|
||||
def test_raises_on_basellm_import_error(self, tmp_path: Path) -> None:
|
||||
"""Should provide specific guidance for BaseLlm import errors."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text(
|
||||
"from google.adk.models.base_llm import NonexistentBaseLlm\n"
|
||||
)
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
assert "BaseLlm-related error" in str(exc_info.value)
|
||||
assert "custom LLM" in str(exc_info.value)
|
||||
|
||||
def test_raises_on_syntax_error(self, tmp_path: Path) -> None:
|
||||
"""Should raise on syntax errors in agent.py."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("def invalid syntax here:\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
assert "Error while loading agent module" in str(exc_info.value)
|
||||
|
||||
def test_cleans_up_sys_modules(self, tmp_path: Path) -> None:
|
||||
"""Should clean up sys.modules after validation."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("root_agent = 'my_agent'\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
module_name = tmp_path.name
|
||||
agent_module_key = f"{module_name}.agent"
|
||||
|
||||
# Ensure module is not in sys.modules before
|
||||
assert module_name not in sys.modules
|
||||
assert agent_module_key not in sys.modules
|
||||
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
|
||||
# Ensure module is cleaned up after
|
||||
assert module_name not in sys.modules
|
||||
assert agent_module_key not in sys.modules
|
||||
|
||||
def test_restores_sys_path(self, tmp_path: Path) -> None:
|
||||
"""Should restore sys.path after validation."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("root_agent = 'my_agent'\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
original_path = sys.path.copy()
|
||||
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
|
||||
assert sys.path == original_path
|
||||
|
||||
|
||||
def test_to_agent_engine_triggers_onboarding(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: Callable[[bool, bool], Path],
|
||||
) -> None:
|
||||
"""It should trigger onboarding when credentials are missing."""
|
||||
mock_handle_login = mock.Mock(
|
||||
return_value=cli_deploy._onboarding.ExpressModeAuth(
|
||||
api_key="fake_api_key",
|
||||
project_id="fake_project",
|
||||
region="fake_region",
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli_deploy._onboarding, "handle_login_with_google", mock_handle_login
|
||||
)
|
||||
|
||||
# Mock subprocess.run so `gcloud config get-value project` returns no
|
||||
# default project; otherwise `_resolve_project` would populate `project`
|
||||
# and suppress the onboarding flow this test is exercising.
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *a, **k: types.SimpleNamespace(stdout="\n"),
|
||||
)
|
||||
|
||||
fake_vertexai = types.ModuleType("vertexai")
|
||||
mock_client = mock.Mock()
|
||||
fake_vertexai.Client = mock.Mock(return_value=mock_client)
|
||||
|
||||
mock_agent_engines = mock.Mock()
|
||||
mock_client.agent_engines = mock_agent_engines
|
||||
|
||||
mock_agent_engines.create.return_value = types.SimpleNamespace(
|
||||
api_resource=types.SimpleNamespace(
|
||||
name="projects/p/locations/l/reasoningEngines/e"
|
||||
)
|
||||
)
|
||||
mock_agent_engines.delete.return_value = None
|
||||
mock_agent_engines.update.return_value = None
|
||||
|
||||
monkeypatch.setitem(sys.modules, "vertexai", fake_vertexai)
|
||||
|
||||
src_dir = agent_dir(False, False)
|
||||
|
||||
cli_deploy.to_agent_engine(
|
||||
agent_folder=str(src_dir),
|
||||
trace_to_cloud=True,
|
||||
)
|
||||
|
||||
mock_handle_login.assert_called_once()
|
||||
|
||||
# Verify vertexai.Client was initialized with correct args
|
||||
fake_vertexai.Client.assert_called_once()
|
||||
kwargs = fake_vertexai.Client.call_args.kwargs
|
||||
assert kwargs.get("project") == "fake_project"
|
||||
assert kwargs.get("location") == "fake_region"
|
||||
assert "api_key" not in kwargs or kwargs.get("api_key") is None
|
||||
|
||||
|
||||
def test_cli_deploy_agent_engine_trigger_sources(tmp_path: Path):
|
||||
"""Tests that --trigger_sources is passed to to_agent_engine."""
|
||||
agent_dir = tmp_path / "my_agent"
|
||||
agent_dir.mkdir()
|
||||
runner = CliRunner()
|
||||
with mock.patch(
|
||||
"src.google.adk.cli.cli_deploy.to_agent_engine"
|
||||
) as mock_to_agent_engine:
|
||||
result = runner.invoke(
|
||||
cli_tools_click.main,
|
||||
[
|
||||
"deploy",
|
||||
"agent_engine",
|
||||
"--trigger_sources=pubsub,eventarc",
|
||||
str(agent_dir),
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_to_agent_engine.assert_called_once()
|
||||
_, kwargs = mock_to_agent_engine.call_args
|
||||
assert kwargs["trigger_sources"] == "pubsub,eventarc"
|
||||
|
||||
|
||||
def test_cli_deploy_agent_engine_artifact_service_uri(tmp_path: Path):
|
||||
"""Tests that --artifact_service_uri is passed to to_agent_engine."""
|
||||
agent_dir = tmp_path / "my_agent"
|
||||
agent_dir.mkdir()
|
||||
runner = CliRunner()
|
||||
with mock.patch(
|
||||
"src.google.adk.cli.cli_deploy.to_agent_engine"
|
||||
) as mock_to_agent_engine:
|
||||
result = runner.invoke(
|
||||
cli_tools_click.main,
|
||||
[
|
||||
"deploy",
|
||||
"agent_engine",
|
||||
"--artifact_service_uri=gs://my-bucket",
|
||||
str(agent_dir),
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_to_agent_engine.assert_called_once()
|
||||
_, kwargs = mock_to_agent_engine.call_args
|
||||
assert kwargs["artifact_service_uri"] == "gs://my-bucket"
|
||||
|
||||
|
||||
def test_ensure_agent_engine_dependency(tmp_path: Path):
|
||||
"""Tests that _ensure_agent_engine_dependency appends correct extras."""
|
||||
requirements_file = tmp_path / "requirements.txt"
|
||||
|
||||
# Case 1: raises FileNotFoundError when the file doesn't exist
|
||||
with pytest.raises(FileNotFoundError):
|
||||
cli_deploy._ensure_agent_engine_dependency(str(requirements_file))
|
||||
|
||||
# Case 2: appends google-cloud-aiplatform with 'adk' and 'agent_engines'
|
||||
# extras and the versioned google-adk requirement.
|
||||
requirements_file.write_text("")
|
||||
cli_deploy._ensure_agent_engine_dependency(str(requirements_file))
|
||||
content = requirements_file.read_text()
|
||||
assert "google-cloud-aiplatform[adk,agent_engines]\n" in content
|
||||
assert f"google-adk[a2a]=={cli_deploy.__version__}\n" in content
|
||||
|
||||
# Case 3: does not append duplicate if google-cloud-aiplatform already exists
|
||||
requirements_file.write_text("google-cloud-aiplatform[adk,agent_engines]\n")
|
||||
cli_deploy._ensure_agent_engine_dependency(str(requirements_file))
|
||||
content = requirements_file.read_text()
|
||||
assert content == "google-cloud-aiplatform[adk,agent_engines]\n"
|
||||
@@ -0,0 +1,219 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for ignore file support in cli_deploy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
from unittest import mock
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
import src.google.adk.cli.cli_deploy as cli_deploy
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Suppress click.echo to keep test output clean."""
|
||||
monkeypatch.setattr(click, "echo", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(click, "secho", lambda *_a, **_k: None)
|
||||
|
||||
|
||||
def test_to_cloud_run_respects_ignore_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that to_cloud_run respects .gitignore and .gcloudignore."""
|
||||
agent_dir = tmp_path / "agent"
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "agent.py").write_text("# agent")
|
||||
(agent_dir / "__init__.py").write_text("")
|
||||
(agent_dir / "ignored_by_git.txt").write_text("ignored")
|
||||
(agent_dir / "ignored_by_gcloud.txt").write_text("ignored")
|
||||
(agent_dir / "ignored_rooted.txt").write_text("ignored")
|
||||
(agent_dir / "not_ignored.txt").write_text("keep")
|
||||
|
||||
# Use a root-anchored pattern (leading slash) to ensure it is honored.
|
||||
(agent_dir / ".gitignore").write_text(
|
||||
"ignored_by_git.txt\n/ignored_rooted.txt\n"
|
||||
)
|
||||
(agent_dir / ".gcloudignore").write_text("ignored_by_gcloud.txt\n")
|
||||
|
||||
temp_deploy_dir = tmp_path / "temp_deploy"
|
||||
|
||||
# Mock subprocess.run to avoid actual gcloud call
|
||||
monkeypatch.setattr(subprocess, "run", mock.Mock())
|
||||
# Mock shutil.rmtree to keep the temp folder for verification
|
||||
monkeypatch.setattr(
|
||||
shutil,
|
||||
"rmtree",
|
||||
lambda path, **kwargs: None
|
||||
if "temp_deploy" in str(path)
|
||||
else shutil.rmtree(path, **kwargs),
|
||||
)
|
||||
|
||||
cli_deploy.to_cloud_run(
|
||||
agent_folder=str(agent_dir),
|
||||
project="proj",
|
||||
region="us-central1",
|
||||
service_name="svc",
|
||||
app_name="app",
|
||||
temp_folder=str(temp_deploy_dir),
|
||||
port=8080,
|
||||
trace_to_cloud=False,
|
||||
otel_to_cloud=False,
|
||||
with_ui=False,
|
||||
log_level="info",
|
||||
verbosity="info",
|
||||
adk_version="1.0.0",
|
||||
)
|
||||
|
||||
agent_src_path = temp_deploy_dir / "agents" / "app"
|
||||
|
||||
assert (agent_src_path / "agent.py").exists()
|
||||
assert (agent_src_path / "not_ignored.txt").exists()
|
||||
|
||||
# These should be ignored
|
||||
assert not (
|
||||
agent_src_path / "ignored_by_git.txt"
|
||||
).exists(), "Should respect .gitignore"
|
||||
assert not (
|
||||
agent_src_path / "ignored_by_gcloud.txt"
|
||||
).exists(), "Should respect .gcloudignore"
|
||||
assert not (
|
||||
agent_src_path / "ignored_rooted.txt"
|
||||
).exists(), "Should respect root-anchored (leading slash) patterns"
|
||||
|
||||
|
||||
def test_to_agent_engine_respects_multiple_ignore_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that to_agent_engine respects .gitignore, .gcloudignore and .ae_ignore."""
|
||||
# We need to be in the project dir for to_agent_engine
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
agent_dir = project_dir / "my_agent"
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "agent.py").write_text("root_agent = None")
|
||||
(agent_dir / "__init__.py").write_text("from . import agent")
|
||||
(agent_dir / "ignored_by_git.txt").write_text("ignored")
|
||||
(agent_dir / "ignored_by_ae.txt").write_text("ignored")
|
||||
|
||||
(agent_dir / ".gitignore").write_text("ignored_by_git.txt\n")
|
||||
(agent_dir / ".ae_ignore").write_text("ignored_by_ae.txt\n")
|
||||
|
||||
# Mock vertexai.Client and other things to avoid network/complex setup. The
|
||||
# created agent engine must expose a realistic resource name so the downstream
|
||||
# console-URL formatting does not choke on a bare Mock.
|
||||
mock_client = mock.Mock()
|
||||
mock_client.agent_engines.create.return_value.api_resource.name = (
|
||||
"projects/proj/locations/us-central1/reasoningEngines/123"
|
||||
)
|
||||
monkeypatch.setattr("vertexai.Client", mock.Mock(return_value=mock_client))
|
||||
# Mock shutil.rmtree to keep the temp folder for verification
|
||||
original_rmtree = shutil.rmtree
|
||||
|
||||
def mock_rmtree(path, **kwargs):
|
||||
if "_tmp" in str(path):
|
||||
return None
|
||||
return original_rmtree(path, **kwargs)
|
||||
|
||||
monkeypatch.setattr(shutil, "rmtree", mock_rmtree)
|
||||
|
||||
cli_deploy.to_agent_engine(
|
||||
agent_folder=str(agent_dir),
|
||||
staging_bucket="gs://test",
|
||||
adk_app="adk_app",
|
||||
# Pass project/region explicitly so the function does not fall back to
|
||||
# the interactive `gcloud auth application-default login` onboarding flow,
|
||||
# which fails on CI runners without Application Default Credentials.
|
||||
project="proj",
|
||||
region="us-central1",
|
||||
adk_version="1.0.0",
|
||||
)
|
||||
|
||||
# Find the temp folder created by to_agent_engine
|
||||
temp_folders = [
|
||||
d for d in project_dir.iterdir() if d.is_dir() and "_tmp" in d.name
|
||||
]
|
||||
assert len(temp_folders) == 1
|
||||
agent_src_path = temp_folders[0]
|
||||
|
||||
copied_agent_dir = agent_src_path / "agents" / "my_agent"
|
||||
assert (copied_agent_dir / "agent.py").exists()
|
||||
assert not (
|
||||
copied_agent_dir / "ignored_by_git.txt"
|
||||
).exists(), "Should respect .gitignore"
|
||||
assert not (
|
||||
copied_agent_dir / "ignored_by_ae.txt"
|
||||
).exists(), "Should respect .ae_ignore"
|
||||
|
||||
|
||||
def test_to_gke_respects_ignore_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that to_gke respects ignore files."""
|
||||
agent_dir = tmp_path / "agent"
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "agent.py").write_text("# agent")
|
||||
(agent_dir / "__init__.py").write_text("")
|
||||
(agent_dir / "ignored.txt").write_text("ignored")
|
||||
(agent_dir / ".gitignore").write_text("ignored.txt\n")
|
||||
|
||||
temp_deploy_dir = tmp_path / "temp_deploy"
|
||||
|
||||
# Mock subprocess.run to avoid actual gcloud call
|
||||
mock_run = mock.Mock()
|
||||
mock_run.return_value.stdout = "deployment created"
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
# Mock shutil.rmtree to keep the temp folder for verification
|
||||
monkeypatch.setattr(
|
||||
shutil,
|
||||
"rmtree",
|
||||
lambda path, **kwargs: None
|
||||
if "temp_deploy" in str(path)
|
||||
else shutil.rmtree(path, **kwargs),
|
||||
)
|
||||
|
||||
cli_deploy.to_gke(
|
||||
agent_folder=str(agent_dir),
|
||||
project="proj",
|
||||
region="us-central1",
|
||||
cluster_name="cluster",
|
||||
service_name="svc",
|
||||
app_name="app",
|
||||
temp_folder=str(temp_deploy_dir),
|
||||
port=8080,
|
||||
trace_to_cloud=False,
|
||||
otel_to_cloud=False,
|
||||
with_ui=False,
|
||||
log_level="info",
|
||||
adk_version="1.0.0",
|
||||
)
|
||||
|
||||
agent_src_path = temp_deploy_dir / "agents" / "app"
|
||||
|
||||
assert (agent_src_path / "agent.py").exists()
|
||||
assert not (
|
||||
agent_src_path / "ignored.txt"
|
||||
).exists(), "Should respect .gitignore"
|
||||
@@ -0,0 +1,349 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for to_cloud_run functionality in cli_deploy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Protocol
|
||||
from typing import Tuple
|
||||
from unittest import mock
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
import src.google.adk.cli.cli_deploy as cli_deploy
|
||||
|
||||
|
||||
class AgentDirFixture(Protocol):
|
||||
"""Protocol for the agent_dir pytest fixture factory."""
|
||||
|
||||
def __call__(self, *, include_requirements: bool, include_env: bool) -> Path:
|
||||
...
|
||||
|
||||
|
||||
# Helpers
|
||||
class _Recorder:
|
||||
"""A callable object that records every invocation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> None:
|
||||
self.calls.append((args, kwargs))
|
||||
|
||||
def get_last_call_args(self) -> Tuple[Any, ...]:
|
||||
"""Returns the positional arguments of the last call."""
|
||||
if not self.calls:
|
||||
raise IndexError("No calls have been recorded.")
|
||||
return self.calls[-1][0]
|
||||
|
||||
def get_last_call_kwargs(self) -> Dict[str, Any]:
|
||||
"""Returns the keyword arguments of the last call."""
|
||||
if not self.calls:
|
||||
raise IndexError("No calls have been recorded.")
|
||||
return self.calls[-1][1]
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Suppress click.echo to keep test output clean."""
|
||||
monkeypatch.setattr(click, "echo", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(click, "secho", lambda *_a, **_k: None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def agent_dir(tmp_path: Path) -> AgentDirFixture:
|
||||
"""
|
||||
Return a factory that creates a dummy agent directory tree.
|
||||
"""
|
||||
|
||||
def _factory(*, include_requirements: bool, include_env: bool) -> Path:
|
||||
base = tmp_path / "agent"
|
||||
base.mkdir()
|
||||
(base / "agent.py").write_text("# dummy agent")
|
||||
(base / "__init__.py").write_text("from . import agent")
|
||||
if include_requirements:
|
||||
(base / "requirements.txt").write_text("pytest\n")
|
||||
if include_env:
|
||||
(base / ".env").write_text('TEST_VAR="test_value"\n')
|
||||
return base
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_requirements", [True, False])
|
||||
@pytest.mark.parametrize("with_ui", [True, False])
|
||||
def test_to_cloud_run_happy_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: AgentDirFixture,
|
||||
tmp_path: Path,
|
||||
include_requirements: bool,
|
||||
with_ui: bool,
|
||||
) -> None:
|
||||
"""
|
||||
End-to-end execution test for `to_cloud_run`.
|
||||
"""
|
||||
src_dir = agent_dir(
|
||||
include_requirements=include_requirements, include_env=False
|
||||
)
|
||||
run_recorder = _Recorder()
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", run_recorder)
|
||||
rmtree_recorder = _Recorder()
|
||||
monkeypatch.setattr(shutil, "rmtree", rmtree_recorder)
|
||||
|
||||
cli_deploy.to_cloud_run(
|
||||
agent_folder=str(src_dir),
|
||||
project="proj",
|
||||
region="asia-northeast1",
|
||||
service_name="svc",
|
||||
app_name="agent",
|
||||
temp_folder=str(tmp_path),
|
||||
port=8080,
|
||||
trace_to_cloud=True,
|
||||
otel_to_cloud=True,
|
||||
with_ui=with_ui,
|
||||
log_level="info",
|
||||
verbosity="info",
|
||||
allow_origins=["http://localhost:3000", "https://my-app.com"],
|
||||
session_service_uri="sqlite://",
|
||||
artifact_service_uri="gs://bucket",
|
||||
memory_service_uri="rag://",
|
||||
adk_version="1.3.0",
|
||||
)
|
||||
|
||||
agent_dest_path = tmp_path / "agents" / "agent"
|
||||
assert (agent_dest_path / "agent.py").is_file()
|
||||
assert (agent_dest_path / "__init__.py").is_file()
|
||||
assert (
|
||||
agent_dest_path / "requirements.txt"
|
||||
).is_file() == include_requirements
|
||||
|
||||
dockerfile_path = tmp_path / "Dockerfile"
|
||||
assert dockerfile_path.is_file()
|
||||
dockerfile_content = dockerfile_path.read_text()
|
||||
|
||||
expected_command = "api_server --with_ui" if with_ui else "api_server"
|
||||
assert f"CMD adk {expected_command} --port=8080" in dockerfile_content
|
||||
assert "FROM python:3.11-slim" in dockerfile_content
|
||||
assert (
|
||||
'RUN adduser --disabled-password --gecos "" myuser' in dockerfile_content
|
||||
)
|
||||
assert "USER myuser" in dockerfile_content
|
||||
assert "ENV GOOGLE_CLOUD_PROJECT=proj" in dockerfile_content
|
||||
assert "ENV GOOGLE_CLOUD_LOCATION=asia-northeast1" in dockerfile_content
|
||||
assert 'RUN pip install "google-adk[a2a]==1.3.0"' in dockerfile_content
|
||||
assert "--trace_to_cloud" in dockerfile_content
|
||||
assert "--otel_to_cloud" in dockerfile_content
|
||||
|
||||
# Check agent dependencies installation based on include_requirements
|
||||
if include_requirements:
|
||||
assert (
|
||||
'RUN pip install -r "/app/agents/agent/requirements.txt"'
|
||||
in dockerfile_content
|
||||
)
|
||||
else:
|
||||
assert "# No requirements.txt found." in dockerfile_content
|
||||
|
||||
assert (
|
||||
"--allow_origins=http://localhost:3000,https://my-app.com"
|
||||
in dockerfile_content
|
||||
)
|
||||
|
||||
assert len(run_recorder.calls) == 1
|
||||
gcloud_args = run_recorder.get_last_call_args()[0]
|
||||
|
||||
expected_gcloud_command = [
|
||||
cli_deploy._GCLOUD_CMD,
|
||||
"run",
|
||||
"deploy",
|
||||
"svc",
|
||||
"--source",
|
||||
str(tmp_path),
|
||||
"--project",
|
||||
"proj",
|
||||
"--region",
|
||||
"asia-northeast1",
|
||||
"--port",
|
||||
"8080",
|
||||
"--verbosity",
|
||||
"info",
|
||||
"--sandbox-launcher",
|
||||
"--labels",
|
||||
"created-by=adk",
|
||||
]
|
||||
assert gcloud_args == expected_gcloud_command
|
||||
|
||||
assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_path)
|
||||
|
||||
|
||||
def test_to_cloud_run_cleans_temp_dir(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: AgentDirFixture,
|
||||
) -> None:
|
||||
"""`to_cloud_run` should always delete the temporary folder on exit."""
|
||||
tmp_dir = Path(tempfile.mkdtemp())
|
||||
src_dir = agent_dir(include_requirements=False, include_env=False)
|
||||
|
||||
deleted: Dict[str, Path] = {}
|
||||
|
||||
def _fake_rmtree(path: str | Path, *_a: Any, **_k: Any) -> None:
|
||||
deleted["path"] = Path(path)
|
||||
|
||||
monkeypatch.setattr(shutil, "rmtree", _fake_rmtree)
|
||||
monkeypatch.setattr(subprocess, "run", _Recorder())
|
||||
|
||||
cli_deploy.to_cloud_run(
|
||||
agent_folder=str(src_dir),
|
||||
project="proj",
|
||||
region=None,
|
||||
service_name="svc",
|
||||
app_name="app",
|
||||
temp_folder=str(tmp_dir),
|
||||
port=8080,
|
||||
trace_to_cloud=False,
|
||||
otel_to_cloud=False,
|
||||
with_ui=False,
|
||||
log_level="info",
|
||||
verbosity="info",
|
||||
adk_version="1.0.0",
|
||||
session_service_uri=None,
|
||||
artifact_service_uri=None,
|
||||
memory_service_uri=None,
|
||||
)
|
||||
|
||||
assert deleted["path"] == tmp_dir
|
||||
|
||||
|
||||
def test_to_cloud_run_cleans_temp_dir_on_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: AgentDirFixture,
|
||||
) -> None:
|
||||
"""`to_cloud_run` should delete the temp folder on exit, even if gcloud fails."""
|
||||
tmp_dir = Path(tempfile.mkdtemp())
|
||||
src_dir = agent_dir(include_requirements=False, include_env=False)
|
||||
|
||||
rmtree_recorder = _Recorder()
|
||||
monkeypatch.setattr(shutil, "rmtree", rmtree_recorder)
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
mock.Mock(side_effect=subprocess.CalledProcessError(1, "gcloud")),
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
cli_deploy.to_cloud_run(
|
||||
agent_folder=str(src_dir),
|
||||
project="proj",
|
||||
region="us-central1",
|
||||
service_name="svc",
|
||||
app_name="app",
|
||||
temp_folder=str(tmp_dir),
|
||||
port=8080,
|
||||
trace_to_cloud=False,
|
||||
otel_to_cloud=False,
|
||||
with_ui=False,
|
||||
log_level="info",
|
||||
verbosity="info",
|
||||
adk_version="1.0.0",
|
||||
session_service_uri=None,
|
||||
artifact_service_uri=None,
|
||||
memory_service_uri=None,
|
||||
)
|
||||
|
||||
assert rmtree_recorder.calls, "shutil.rmtree should have been called"
|
||||
assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_dir)
|
||||
|
||||
|
||||
# Label merging tests
|
||||
@pytest.mark.parametrize(
|
||||
"extra_gcloud_args, expected_labels",
|
||||
[
|
||||
# No user labels - should only have default ADK label
|
||||
(None, "created-by=adk"),
|
||||
([], "created-by=adk"),
|
||||
# Single user label
|
||||
(["--labels=env=test"], "created-by=adk,env=test"),
|
||||
# Multiple user labels in same argument
|
||||
(
|
||||
["--labels=env=test,team=myteam"],
|
||||
"created-by=adk,env=test,team=myteam",
|
||||
),
|
||||
# User labels mixed with other args
|
||||
(
|
||||
["--memory=1Gi", "--labels=env=test", "--cpu=1"],
|
||||
"created-by=adk,env=test",
|
||||
),
|
||||
# Multiple --labels arguments
|
||||
(
|
||||
["--labels=env=test", "--labels=team=myteam"],
|
||||
"created-by=adk,env=test,team=myteam",
|
||||
),
|
||||
# Labels with other passthrough args
|
||||
(
|
||||
["--timeout=300", "--labels=env=prod", "--max-instances=10"],
|
||||
"created-by=adk,env=prod",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_cloud_run_label_merging(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: AgentDirFixture,
|
||||
tmp_path: Path,
|
||||
extra_gcloud_args: list[str] | None,
|
||||
expected_labels: str,
|
||||
) -> None:
|
||||
"""Test that user labels are properly merged with the default ADK label."""
|
||||
src_dir = agent_dir(include_requirements=False, include_env=False)
|
||||
run_recorder = _Recorder()
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", run_recorder)
|
||||
monkeypatch.setattr(shutil, "rmtree", lambda _x: None)
|
||||
|
||||
# Execute the function under test
|
||||
cli_deploy.to_cloud_run(
|
||||
agent_folder=str(src_dir),
|
||||
project="test-project",
|
||||
region="us-central1",
|
||||
service_name="test-service",
|
||||
app_name="test-app",
|
||||
temp_folder=str(tmp_path),
|
||||
port=8080,
|
||||
trace_to_cloud=False,
|
||||
otel_to_cloud=False,
|
||||
with_ui=False,
|
||||
log_level="info",
|
||||
verbosity="info",
|
||||
adk_version="1.0.0",
|
||||
extra_gcloud_args=tuple(extra_gcloud_args) if extra_gcloud_args else None,
|
||||
)
|
||||
|
||||
# Verify that the gcloud command was called
|
||||
assert len(run_recorder.calls) == 1
|
||||
gcloud_args = run_recorder.get_last_call_args()[0]
|
||||
|
||||
# Find the labels argument
|
||||
labels_idx = gcloud_args.index("--labels")
|
||||
actual_labels = gcloud_args[labels_idx + 1]
|
||||
|
||||
assert actual_labels == expected_labels
|
||||
@@ -0,0 +1,51 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for utilities in cli_eval."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
|
||||
def test_get_eval_sets_manager_local(monkeypatch):
|
||||
mock_local_manager = mock.MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager",
|
||||
lambda *a, **k: mock_local_manager,
|
||||
)
|
||||
from google.adk.cli.cli_eval import get_eval_sets_manager
|
||||
|
||||
manager = get_eval_sets_manager(eval_storage_uri=None, agents_dir="some/dir")
|
||||
assert manager == mock_local_manager
|
||||
|
||||
|
||||
def test_get_eval_sets_manager_gcs(monkeypatch):
|
||||
mock_gcs_manager = mock.MagicMock()
|
||||
mock_create_gcs = mock.MagicMock()
|
||||
mock_create_gcs.return_value = SimpleNamespace(
|
||||
eval_sets_manager=mock_gcs_manager
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"google.adk.cli.utils.evals.create_gcs_eval_managers_from_uri",
|
||||
mock_create_gcs,
|
||||
)
|
||||
from google.adk.cli.cli_eval import get_eval_sets_manager
|
||||
|
||||
manager = get_eval_sets_manager(
|
||||
eval_storage_uri="gs://bucket", agents_dir="some/dir"
|
||||
)
|
||||
assert manager == mock_gcs_manager
|
||||
mock_create_gcs.assert_called_once_with("gs://bucket")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from google.adk.cli.utils.dot_adk_folder import dot_adk_folder_for_agent
|
||||
from google.adk.cli.utils.dot_adk_folder import DotAdkFolder
|
||||
import pytest
|
||||
|
||||
|
||||
def test_paths_are_relative_to_agent_dir(tmp_path: Path):
|
||||
agent_dir = tmp_path / "agent_a"
|
||||
folder = DotAdkFolder(agent_dir)
|
||||
|
||||
assert folder.dot_adk_dir == agent_dir.resolve() / ".adk"
|
||||
assert folder.artifacts_dir == folder.dot_adk_dir / "artifacts"
|
||||
assert folder.session_db_path == folder.dot_adk_dir / "session.db"
|
||||
|
||||
|
||||
def test_for_agent_validates_app_name(tmp_path: Path):
|
||||
agents_root = tmp_path / "agents"
|
||||
agents_root.mkdir()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
dot_adk_folder_for_agent(
|
||||
agents_root=agents_root, app_name="../escape_attempt"
|
||||
)
|
||||
|
||||
folder = dot_adk_folder_for_agent(
|
||||
agents_root=agents_root, app_name="valid_agent"
|
||||
)
|
||||
|
||||
expected_dir = (agents_root / "valid_agent").resolve()
|
||||
assert folder.agent_dir == expected_dir
|
||||
|
||||
|
||||
def test_for_agent_rejects_prefix_sibling_escape(tmp_path: Path):
|
||||
agents_root = tmp_path / "agents"
|
||||
agents_root.mkdir()
|
||||
|
||||
# Sibling whose path shares the agents_root string prefix ("agents" ->
|
||||
# "agents_evil"); a startswith() check would wrongly accept this.
|
||||
with pytest.raises(ValueError):
|
||||
dot_adk_folder_for_agent(agents_root=agents_root, app_name="../agents_evil")
|
||||
@@ -0,0 +1,92 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for dotenv loading utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import google.adk.cli.utils.envs as envs
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_explicit_env_cache() -> None:
|
||||
envs._get_explicit_env_keys.cache_clear()
|
||||
|
||||
|
||||
def test_load_dotenv_for_agent_preserves_explicit_env(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
agents_dir = tmp_path / "agents"
|
||||
agent_dir = agents_dir / "agent1"
|
||||
agent_dir.mkdir(parents=True)
|
||||
|
||||
explicit_key = "ADK_TEST_EXPLICIT_ENV"
|
||||
from_dotenv_key = "ADK_TEST_FROM_DOTENV"
|
||||
|
||||
monkeypatch.setenv(explicit_key, "explicit")
|
||||
monkeypatch.delenv(from_dotenv_key, raising=False)
|
||||
envs._get_explicit_env_keys.cache_clear()
|
||||
|
||||
(agent_dir / ".env").write_text(
|
||||
f"{explicit_key}=from_dotenv\n{from_dotenv_key}=from_dotenv\n"
|
||||
)
|
||||
|
||||
envs.load_dotenv_for_agent("agent1", str(agents_dir))
|
||||
|
||||
assert os.environ[explicit_key] == "explicit"
|
||||
assert os.environ[from_dotenv_key] == "from_dotenv"
|
||||
|
||||
|
||||
def test_load_dotenv_for_agent_overrides_previous_dotenv(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
agents_dir = tmp_path / "agents"
|
||||
agent1_dir = agents_dir / "agent1"
|
||||
agent2_dir = agents_dir / "agent2"
|
||||
agent1_dir.mkdir(parents=True)
|
||||
agent2_dir.mkdir(parents=True)
|
||||
|
||||
key = "ADK_TEST_DOTENV_OVERRIDE"
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
(agent1_dir / ".env").write_text(f"{key}=one\n")
|
||||
envs.load_dotenv_for_agent("agent1", str(agents_dir))
|
||||
assert os.environ[key] == "one"
|
||||
|
||||
(agent2_dir / ".env").write_text(f"{key}=two\n")
|
||||
envs.load_dotenv_for_agent("agent2", str(agents_dir))
|
||||
assert os.environ[key] == "two"
|
||||
|
||||
|
||||
def test_load_dotenv_for_agent_respects_disable_flag(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
agents_dir = tmp_path / "agents"
|
||||
agent_dir = agents_dir / "agent1"
|
||||
agent_dir.mkdir(parents=True)
|
||||
|
||||
key = "ADK_TEST_DISABLE_DOTENV"
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("ADK_DISABLE_LOAD_DOTENV", "1")
|
||||
envs._get_explicit_env_keys.cache_clear()
|
||||
|
||||
(agent_dir / ".env").write_text(f"{key}=from_dotenv\n")
|
||||
|
||||
envs.load_dotenv_for_agent("agent1", str(agents_dir))
|
||||
|
||||
assert key not in os.environ
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for utilities in eval."""
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.cli.utils import evals
|
||||
from google.adk.evaluation.gcs_eval_set_results_manager import GcsEvalSetResultsManager
|
||||
from google.adk.evaluation.gcs_eval_sets_manager import GcsEvalSetsManager
|
||||
import pytest
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, {'GOOGLE_CLOUD_PROJECT': 'test-project'})
|
||||
@mock.patch(
|
||||
'google.adk.evaluation.gcs_eval_set_results_manager.GcsEvalSetResultsManager',
|
||||
autospec=True,
|
||||
)
|
||||
@mock.patch(
|
||||
'google.adk.evaluation.gcs_eval_sets_manager.GcsEvalSetsManager',
|
||||
autospec=True,
|
||||
)
|
||||
def test_create_gcs_eval_managers_from_uri_success(
|
||||
mock_gcs_eval_sets_manager, mock_gcs_eval_set_results_manager
|
||||
):
|
||||
mock_gcs_eval_sets_manager.return_value = mock.MagicMock(
|
||||
spec=GcsEvalSetsManager
|
||||
)
|
||||
mock_gcs_eval_set_results_manager.return_value = mock.MagicMock(
|
||||
spec=GcsEvalSetResultsManager
|
||||
)
|
||||
|
||||
managers = evals.create_gcs_eval_managers_from_uri('gs://test-bucket')
|
||||
|
||||
assert managers is not None
|
||||
mock_gcs_eval_sets_manager.assert_called_once_with(
|
||||
bucket_name='test-bucket', project='test-project'
|
||||
)
|
||||
mock_gcs_eval_set_results_manager.assert_called_once_with(
|
||||
bucket_name='test-bucket', project='test-project'
|
||||
)
|
||||
assert managers.eval_sets_manager == mock_gcs_eval_sets_manager.return_value
|
||||
assert (
|
||||
managers.eval_set_results_manager
|
||||
== mock_gcs_eval_set_results_manager.return_value
|
||||
)
|
||||
|
||||
|
||||
def test_create_gcs_eval_managers_from_uri_failure():
|
||||
with pytest.raises(ValueError):
|
||||
evals.create_gcs_eval_managers_from_uri('unsupported-uri')
|
||||
@@ -0,0 +1,228 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for gcp_utils."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.cli.utils import gcp_utils
|
||||
import google.auth
|
||||
import google.auth.exceptions
|
||||
import requests
|
||||
|
||||
|
||||
class TestGcpUtils(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
patcher = mock.patch(
|
||||
"google.adk.cli.utils.gcp_utils._mtls_utils.use_client_cert_effective",
|
||||
return_value=False,
|
||||
)
|
||||
self.mock_use_client_cert_effective = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
@mock.patch("google.auth.default")
|
||||
def test_check_adc_success(self, mock_auth_default):
|
||||
mock_auth_default.return_value = (mock.Mock(), "test-project")
|
||||
self.assertTrue(gcp_utils.check_adc())
|
||||
|
||||
@mock.patch("google.auth.default")
|
||||
def test_check_adc_failure(self, mock_auth_default):
|
||||
mock_auth_default.side_effect = (
|
||||
google.auth.exceptions.DefaultCredentialsError()
|
||||
)
|
||||
self.assertFalse(gcp_utils.check_adc())
|
||||
|
||||
@mock.patch("google.auth.default")
|
||||
def test_get_access_token(self, mock_auth_default):
|
||||
mock_creds = mock.Mock()
|
||||
mock_creds.token = "test-token"
|
||||
mock_creds.valid = True
|
||||
mock_auth_default.return_value = (mock_creds, "test-project")
|
||||
self.assertEqual(gcp_utils.get_access_token(), "test-token")
|
||||
|
||||
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
|
||||
@mock.patch("google.auth.default")
|
||||
def test_retrieve_express_project_success(
|
||||
self, mock_auth_default, mock_session_cls
|
||||
):
|
||||
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
|
||||
|
||||
mock_session = mock.Mock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = {
|
||||
"expressProject": {
|
||||
"projectId": "test-project",
|
||||
"defaultApiKey": "test-api-key",
|
||||
"region": "us-central1",
|
||||
}
|
||||
}
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
result = gcp_utils.retrieve_express_project()
|
||||
self.assertEqual(result["project_id"], "test-project")
|
||||
self.assertEqual(result["api_key"], "test-api-key")
|
||||
self.assertEqual(result["region"], "us-central1")
|
||||
mock_session.get.assert_called_once()
|
||||
args, kwargs = mock_session.get.call_args
|
||||
self.assertEqual(
|
||||
args[0],
|
||||
"https://us-central1-aiplatform.googleapis.com/v1beta1/vertexExpress:retrieveExpressProject",
|
||||
)
|
||||
self.assertEqual(kwargs["params"], {"get_default_api_key": True})
|
||||
|
||||
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
|
||||
@mock.patch("google.auth.default")
|
||||
def test_retrieve_express_project_not_found(
|
||||
self, mock_auth_default, mock_session_cls
|
||||
):
|
||||
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
|
||||
|
||||
mock_session = mock.Mock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_response = mock.Mock()
|
||||
mock_response.status_code = 404
|
||||
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
||||
response=mock_response
|
||||
)
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
result = gcp_utils.retrieve_express_project()
|
||||
self.assertIsNone(result)
|
||||
|
||||
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
|
||||
@mock.patch("google.auth.default")
|
||||
def test_check_express_eligibility(self, mock_auth_default, mock_session_cls):
|
||||
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
|
||||
|
||||
mock_session = mock.Mock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = {"eligibility": "IN_SCOPE"}
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
self.assertTrue(gcp_utils.check_express_eligibility())
|
||||
|
||||
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
|
||||
@mock.patch("google.auth.default")
|
||||
def test_sign_up_express(self, mock_auth_default, mock_session_cls):
|
||||
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
|
||||
|
||||
mock_session = mock.Mock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = {
|
||||
"projectId": "new-project",
|
||||
"defaultApiKey": "new-api-key",
|
||||
"region": "us-central1",
|
||||
}
|
||||
mock_session.post.return_value = mock_response
|
||||
|
||||
result = gcp_utils.sign_up_express()
|
||||
self.assertEqual(result["project_id"], "new-project")
|
||||
self.assertEqual(result["api_key"], "new-api-key")
|
||||
args, kwargs = mock_session.post.call_args
|
||||
self.assertEqual(
|
||||
args[0],
|
||||
"https://us-central1-aiplatform.googleapis.com/v1beta1/vertexExpress:signUp",
|
||||
)
|
||||
self.assertEqual(
|
||||
kwargs["json"],
|
||||
{
|
||||
"region": "us-central1",
|
||||
"tos_accepted": True,
|
||||
"get_default_api_key": True,
|
||||
},
|
||||
)
|
||||
|
||||
@mock.patch("google.cloud.resourcemanager_v3.ProjectsClient")
|
||||
def test_list_gcp_projects(self, mock_client_cls):
|
||||
mock_client = mock.Mock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
mock_project1 = mock.Mock()
|
||||
mock_project1.project_id = "p1"
|
||||
mock_project1.display_name = "Project 1"
|
||||
|
||||
mock_project2 = mock.Mock()
|
||||
mock_project2.project_id = "p2"
|
||||
mock_project2.display_name = None
|
||||
|
||||
mock_client.search_projects.return_value = [mock_project1, mock_project2]
|
||||
|
||||
projects = gcp_utils.list_gcp_projects()
|
||||
self.assertEqual(len(projects), 2)
|
||||
self.assertEqual(projects[0], ("p1", "Project 1"))
|
||||
self.assertEqual(projects[1], ("p2", "p2"))
|
||||
|
||||
@mock.patch.dict("sys.modules", {"google.cloud": None})
|
||||
def test_list_gcp_projects_import_error(self):
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"Listing GCP projects requires the 'gcp' optional dependency",
|
||||
):
|
||||
gcp_utils.list_gcp_projects()
|
||||
|
||||
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
|
||||
@mock.patch("google.auth.default")
|
||||
def test_retrieve_express_project_mtls_enabled(
|
||||
self, mock_auth_default, mock_session_cls
|
||||
):
|
||||
# Enable mtls
|
||||
self.mock_use_client_cert_effective.return_value = True
|
||||
|
||||
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
|
||||
|
||||
mock_session = mock.Mock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = {
|
||||
"expressProject": {
|
||||
"projectId": "test-project",
|
||||
"defaultApiKey": "test-api-key",
|
||||
"region": "us-central1",
|
||||
}
|
||||
}
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
with mock.patch(
|
||||
"google.adk.cli.utils.gcp_utils._mtls_utils.get_api_endpoint",
|
||||
return_value=(
|
||||
"https://us-central1-aiplatform.mtls.googleapis.com/v1beta1"
|
||||
),
|
||||
) as mock_get_api_endpoint:
|
||||
result = gcp_utils.retrieve_express_project()
|
||||
|
||||
self.assertEqual(result["project_id"], "test-project")
|
||||
mock_session.configure_mtls_channel.assert_called_once()
|
||||
mock_get_api_endpoint.assert_called_once_with(
|
||||
location="us-central1",
|
||||
default_template="https://{location}-aiplatform.googleapis.com/v1beta1",
|
||||
mtls_template=(
|
||||
"https://{location}-aiplatform.mtls.googleapis.com/v1beta1"
|
||||
),
|
||||
)
|
||||
mock_session.get.assert_called_once()
|
||||
args, _ = mock_session.get.call_args
|
||||
self.assertEqual(
|
||||
args[0],
|
||||
"https://us-central1-aiplatform.mtls.googleapis.com/v1beta1/vertexExpress:retrieveExpressProject",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,163 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for graph_serialization edge handling with routing maps."""
|
||||
|
||||
import json
|
||||
|
||||
from google.adk.agents import LlmAgent
|
||||
from google.adk.cli.utils.graph_serialization import serialize_agent
|
||||
from google.adk.models.lite_llm import LiteLlm
|
||||
from google.adk.tools.base_toolset import BaseToolset
|
||||
from google.adk.workflow import START
|
||||
from google.adk.workflow import Workflow
|
||||
|
||||
from tests.unittests.workflow.workflow_testing_utils import TestingNode
|
||||
|
||||
|
||||
def test_serialize_edges_with_routing_map() -> None:
|
||||
"""Tests that routing map dicts in edges are serialized without error."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
|
||||
agent = Workflow(
|
||||
name='test_workflow',
|
||||
edges=[
|
||||
(START, node_a),
|
||||
(node_a, {'route_b': node_b, 'route_c': node_c}),
|
||||
],
|
||||
)
|
||||
|
||||
result = serialize_agent(agent)
|
||||
|
||||
serialized_edges = result['edges']
|
||||
assert len(serialized_edges) == 2
|
||||
|
||||
# First edge: (START, node_a) — serialized as a 2-element list.
|
||||
assert len(serialized_edges[0]) == 2
|
||||
|
||||
# Second edge: (node_a, {route: node}) — serialized as a 2-element list
|
||||
# where the second element is a dict with string keys.
|
||||
routing_map_edge = serialized_edges[1]
|
||||
assert len(routing_map_edge) == 2
|
||||
assert isinstance(routing_map_edge[1], dict)
|
||||
assert 'route_b' in routing_map_edge[1]
|
||||
assert 'route_c' in routing_map_edge[1]
|
||||
|
||||
|
||||
def test_serialize_edges_with_routing_map_int_keys() -> None:
|
||||
"""Tests that integer routing map keys are serialized as strings."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
|
||||
agent = Workflow(
|
||||
name='test_workflow',
|
||||
edges=[
|
||||
(START, node_a),
|
||||
(node_a, {1: node_b}),
|
||||
],
|
||||
)
|
||||
|
||||
result = serialize_agent(agent)
|
||||
|
||||
routing_map_edge = result['edges'][1]
|
||||
# Integer keys become string keys in the serialized output.
|
||||
assert '1' in routing_map_edge[1]
|
||||
|
||||
|
||||
def test_serialize_edges_mixed_formats() -> None:
|
||||
"""Tests serialization of edges mixing tuples, Edge objects, and routing maps."""
|
||||
from google.adk.workflow import Edge
|
||||
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
node_d = TestingNode(name='NodeD')
|
||||
|
||||
agent = Workflow(
|
||||
name='test_workflow',
|
||||
edges=[
|
||||
(START, node_a),
|
||||
(node_a, {'route_b': node_b, 'route_c': node_c}),
|
||||
(node_b, node_d),
|
||||
Edge(from_node=node_c, to_node=node_d),
|
||||
],
|
||||
)
|
||||
|
||||
result = serialize_agent(agent)
|
||||
|
||||
serialized_edges = result['edges']
|
||||
assert len(serialized_edges) == 4
|
||||
|
||||
# Tuple edges are lists, Edge objects are dicts with from_node/to_node.
|
||||
assert isinstance(serialized_edges[0], list) # (START, node_a)
|
||||
assert isinstance(serialized_edges[1], list) # routing map
|
||||
assert isinstance(serialized_edges[2], list) # (node_b, node_d)
|
||||
assert isinstance(serialized_edges[3], dict) # Edge object
|
||||
assert 'from_node' in serialized_edges[3]
|
||||
|
||||
|
||||
def test_serialize_agent_with_toolset() -> None:
|
||||
"""Tests that toolsets are serialized using their class name."""
|
||||
|
||||
class MockToolset(BaseToolset):
|
||||
|
||||
async def get_tools(self, readonly_context=None):
|
||||
return []
|
||||
|
||||
class FakeAgent:
|
||||
model_fields = {'tools': None}
|
||||
|
||||
def __init__(self):
|
||||
self.tools = [MockToolset()]
|
||||
|
||||
agent = FakeAgent()
|
||||
result = serialize_agent(agent) # type: ignore
|
||||
|
||||
assert 'tools' in result
|
||||
assert len(result['tools']) == 1
|
||||
assert result['tools'][0]['name'] == 'MockToolset'
|
||||
assert result['tools'][0]['type'] == 'tool'
|
||||
|
||||
|
||||
def test_serialize_agent_with_litellm_model_is_json_safe() -> None:
|
||||
agent = LlmAgent(
|
||||
name='repro',
|
||||
model=LiteLlm(model='ollama_chat/llama3'),
|
||||
)
|
||||
|
||||
result = serialize_agent(agent)
|
||||
|
||||
assert result['model'] == 'ollama_chat/llama3'
|
||||
json.dumps(result)
|
||||
|
||||
|
||||
def test_serialize_agent_skips_excluded_fields() -> None:
|
||||
"""Fields marked Field(exclude=True) are omitted from serialization."""
|
||||
from typing import Any
|
||||
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
from pydantic import ConfigDict
|
||||
from pydantic import Field
|
||||
|
||||
class _Agent(BaseAgent):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
secret: Any = Field(default=None, exclude=True)
|
||||
|
||||
agent = _Agent(name='a', secret=lambda: None)
|
||||
result = serialize_agent(agent)
|
||||
|
||||
assert 'secret' not in result
|
||||
assert result['name'] == 'a'
|
||||
@@ -0,0 +1,341 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from google.adk.artifacts.file_artifact_service import FileArtifactService
|
||||
from google.adk.cli.utils.local_storage import create_local_artifact_service
|
||||
from google.adk.cli.utils.local_storage import create_local_database_session_service
|
||||
from google.adk.cli.utils.local_storage import create_local_session_service
|
||||
from google.adk.cli.utils.local_storage import PerAgentDatabaseSessionService
|
||||
from google.adk.cli.utils.local_storage import PerAgentFileArtifactService
|
||||
from google.adk.events.event import Event
|
||||
from google.adk.events.event_actions import EventActions
|
||||
from google.adk.sessions.sqlite_session_service import SqliteSessionService
|
||||
from google.genai import types
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_session_service_creates_scoped_dot_adk(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_a = tmp_path / "agent_a"
|
||||
agent_b = tmp_path / "agent_b"
|
||||
agent_a.mkdir()
|
||||
agent_b.mkdir()
|
||||
|
||||
async with PerAgentDatabaseSessionService(agents_root=tmp_path) as service:
|
||||
await service.create_session(app_name="agent_a", user_id="user_a")
|
||||
await service.create_session(app_name="agent_b", user_id="user_b")
|
||||
|
||||
assert (agent_a / ".adk" / "session.db").exists()
|
||||
assert (agent_b / ".adk" / "session.db").exists()
|
||||
|
||||
agent_a_sessions = await service.list_sessions(app_name="agent_a")
|
||||
agent_b_sessions = await service.list_sessions(app_name="agent_b")
|
||||
|
||||
assert len(agent_a_sessions.sessions) == 1
|
||||
assert agent_a_sessions.sessions[0].app_name == "agent_a"
|
||||
assert len(agent_b_sessions.sessions) == 1
|
||||
assert agent_b_sessions.sessions[0].app_name == "agent_b"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_session_service_respects_app_name_alias(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
folder_name = "agent_folder"
|
||||
logical_name = "custom_app"
|
||||
(tmp_path / folder_name).mkdir()
|
||||
|
||||
service = create_local_session_service(
|
||||
base_dir=tmp_path,
|
||||
per_agent=True,
|
||||
app_name_to_dir={logical_name: folder_name},
|
||||
)
|
||||
try:
|
||||
session = await service.create_session(
|
||||
app_name=logical_name,
|
||||
user_id="user",
|
||||
)
|
||||
|
||||
assert session.app_name == logical_name
|
||||
assert (tmp_path / folder_name / ".adk" / "session.db").exists()
|
||||
finally:
|
||||
if isinstance(service, PerAgentDatabaseSessionService):
|
||||
await service.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_session_service_routes_built_in_agents_to_root_dot_adk(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
async with PerAgentDatabaseSessionService(agents_root=tmp_path) as service:
|
||||
await service.create_session(app_name="__helper", user_id="user")
|
||||
|
||||
assert not (tmp_path / "__helper").exists()
|
||||
assert (tmp_path / ".adk" / "session.db").exists()
|
||||
|
||||
|
||||
def test_create_local_database_session_service_returns_sqlite(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = create_local_database_session_service(base_dir=tmp_path)
|
||||
|
||||
assert isinstance(service, SqliteSessionService)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_session_service_get_user_state(tmp_path: Path) -> None:
|
||||
"""Verifies get_user_state routes to correct agent and returns correct state."""
|
||||
agent_a = tmp_path / "agent_a"
|
||||
agent_b = tmp_path / "agent_b"
|
||||
agent_a.mkdir()
|
||||
agent_b.mkdir()
|
||||
|
||||
async with PerAgentDatabaseSessionService(agents_root=tmp_path) as service:
|
||||
session_a = await service.create_session(
|
||||
app_name="agent_a", user_id="user_a"
|
||||
)
|
||||
await service.append_event(
|
||||
session_a,
|
||||
Event(
|
||||
author="system",
|
||||
actions=EventActions(
|
||||
state_delta={"user:profile": {"name": "Alice"}}
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
state_a = await service.get_user_state(app_name="agent_a", user_id="user_a")
|
||||
state_b = await service.get_user_state(app_name="agent_b", user_id="user_b")
|
||||
|
||||
assert state_a == {"profile": {"name": "Alice"}}
|
||||
assert not state_b
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_creates_scoped_dot_adk(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_a = tmp_path / "agent_a"
|
||||
agent_b = tmp_path / "agent_b"
|
||||
agent_a.mkdir()
|
||||
agent_b.mkdir()
|
||||
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
artifact = types.Part.from_bytes(data=b"data", mime_type="text/plain")
|
||||
|
||||
await service.save_artifact(
|
||||
app_name="agent_a",
|
||||
user_id="user_a",
|
||||
session_id="session_a",
|
||||
filename="file.txt",
|
||||
artifact=artifact,
|
||||
)
|
||||
await service.save_artifact(
|
||||
app_name="agent_b",
|
||||
user_id="user_b",
|
||||
session_id="session_b",
|
||||
filename="file.txt",
|
||||
artifact=artifact,
|
||||
)
|
||||
|
||||
assert (agent_a / ".adk" / "artifacts").exists()
|
||||
assert (agent_b / ".adk" / "artifacts").exists()
|
||||
assert not (tmp_path / ".adk").exists()
|
||||
|
||||
keys_a = await service.list_artifact_keys(
|
||||
app_name="agent_a", user_id="user_a", session_id="session_a"
|
||||
)
|
||||
assert keys_a == ["file.txt"]
|
||||
# agent_b's store doesn't see agent_a's artifact, even at the same scope.
|
||||
keys_from_other_agent = await service.list_artifact_keys(
|
||||
app_name="agent_b", user_id="user_a", session_id="session_a"
|
||||
)
|
||||
assert keys_from_other_agent == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_respects_app_name_alias(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
folder_name = "agent_folder"
|
||||
logical_name = "custom_app"
|
||||
(tmp_path / folder_name).mkdir()
|
||||
|
||||
service = create_local_artifact_service(
|
||||
base_dir=tmp_path,
|
||||
per_agent=True,
|
||||
app_name_to_dir={logical_name: folder_name},
|
||||
)
|
||||
|
||||
await service.save_artifact(
|
||||
app_name=logical_name,
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
filename="file.txt",
|
||||
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
|
||||
)
|
||||
|
||||
assert (tmp_path / folder_name / ".adk" / "artifacts").exists()
|
||||
assert not (tmp_path / logical_name).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_routes_built_in_agents_to_root_dot_adk(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
|
||||
await service.save_artifact(
|
||||
app_name="__helper",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
filename="file.txt",
|
||||
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
|
||||
)
|
||||
|
||||
assert not (tmp_path / "__helper").exists()
|
||||
assert (tmp_path / ".adk" / "artifacts").exists()
|
||||
|
||||
|
||||
def test_create_local_artifact_service_returns_file_service(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = create_local_artifact_service(base_dir=tmp_path)
|
||||
|
||||
assert isinstance(service, FileArtifactService)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_delegates_all_operations(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
(tmp_path / "agent_a").mkdir()
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
artifact = types.Part.from_bytes(data=b"data", mime_type="text/plain")
|
||||
scope = {"app_name": "agent_a", "user_id": "user", "session_id": "session"}
|
||||
|
||||
version = await service.save_artifact(
|
||||
filename="file.txt", artifact=artifact, **scope
|
||||
)
|
||||
assert version == 0
|
||||
|
||||
loaded = await service.load_artifact(filename="file.txt", **scope)
|
||||
assert loaded is not None
|
||||
assert loaded.inline_data.data == b"data"
|
||||
assert await service.list_versions(filename="file.txt", **scope) == [0]
|
||||
assert (
|
||||
len(await service.list_artifact_versions(filename="file.txt", **scope))
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
await service.get_artifact_version(filename="file.txt", **scope)
|
||||
) is not None
|
||||
|
||||
await service.delete_artifact(filename="file.txt", **scope)
|
||||
assert await service.list_artifact_keys(**scope) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_reads_legacy_shared_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
scope = {"app_name": "agent_a", "user_id": "user", "session_id": "session"}
|
||||
# Seed an artifact in the pre-per-agent shared <root>/.adk/artifacts store.
|
||||
legacy = FileArtifactService(root_dir=tmp_path / ".adk" / "artifacts")
|
||||
await legacy.save_artifact(
|
||||
filename="legacy.txt",
|
||||
artifact=types.Part.from_bytes(data=b"old", mime_type="text/plain"),
|
||||
**scope,
|
||||
)
|
||||
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
|
||||
loaded = await service.load_artifact(filename="legacy.txt", **scope)
|
||||
assert loaded is not None
|
||||
assert loaded.inline_data.data == b"old"
|
||||
assert await service.list_artifact_keys(**scope) == ["legacy.txt"]
|
||||
assert await service.list_versions(filename="legacy.txt", **scope) == [0]
|
||||
assert (
|
||||
await service.get_artifact_version(filename="legacy.txt", **scope)
|
||||
) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_writes_do_not_touch_legacy_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
scope = {"app_name": "agent_a", "user_id": "user", "session_id": "session"}
|
||||
legacy = FileArtifactService(root_dir=tmp_path / ".adk" / "artifacts")
|
||||
await legacy.save_artifact(
|
||||
filename="legacy.txt",
|
||||
artifact=types.Part.from_bytes(data=b"old", mime_type="text/plain"),
|
||||
**scope,
|
||||
)
|
||||
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
await service.save_artifact(
|
||||
filename="new.txt",
|
||||
artifact=types.Part.from_bytes(data=b"new", mime_type="text/plain"),
|
||||
**scope,
|
||||
)
|
||||
|
||||
# New write lands per-agent, not in the legacy shared root.
|
||||
assert (tmp_path / "agent_a" / ".adk" / "artifacts").exists()
|
||||
assert await legacy.list_artifact_keys(**scope) == ["legacy.txt"]
|
||||
# Reads union the new per-agent key with the legacy one.
|
||||
assert await service.list_artifact_keys(**scope) == ["legacy.txt", "new.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_no_fallback_without_legacy_dir(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
|
||||
result = await service.load_artifact(
|
||||
app_name="agent_a",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
filename="missing.txt",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert not (tmp_path / ".adk").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_delete_removes_legacy_copy(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
scope = {"app_name": "agent_a", "user_id": "user", "session_id": "session"}
|
||||
legacy = FileArtifactService(root_dir=tmp_path / ".adk" / "artifacts")
|
||||
await legacy.save_artifact(
|
||||
filename="legacy.txt",
|
||||
artifact=types.Part.from_bytes(data=b"old", mime_type="text/plain"),
|
||||
**scope,
|
||||
)
|
||||
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
await service.delete_artifact(filename="legacy.txt", **scope)
|
||||
|
||||
# Deleted artifact must not reappear through the legacy read fallback.
|
||||
assert await service.load_artifact(filename="legacy.txt", **scope) is None
|
||||
assert await service.list_artifact_keys(**scope) == []
|
||||
assert await legacy.list_artifact_keys(**scope) == []
|
||||
@@ -0,0 +1,156 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from google.adk.cli.utils._nested_agent_loader import NestedAgentLoader
|
||||
import pytest
|
||||
|
||||
|
||||
class TestNestedAgentLoader:
|
||||
"""Comprehensive tests for NestedAgentLoader focusing on list_agents."""
|
||||
|
||||
def test_list_agents_in_single_agent_mode_returns_only_single_agent(self):
|
||||
"""Single agent mode returns list containing only the single agent's name."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
agent_file = temp_path / "my_agent.py"
|
||||
agent_file.write_text("")
|
||||
|
||||
loader = NestedAgentLoader(str(agent_file))
|
||||
agents = loader.list_agents()
|
||||
|
||||
assert agents == ["my_agent"]
|
||||
|
||||
def test_list_agents_with_nonexistent_directory_returns_empty_list(self):
|
||||
"""Constructing with a nonexistent directory path returns an empty list."""
|
||||
loader = NestedAgentLoader("/nonexistent/directory/path")
|
||||
agents = loader.list_agents()
|
||||
|
||||
assert agents == []
|
||||
|
||||
def test_list_agents_finds_nested_agents_recursively(self):
|
||||
"""Recursive directory walk discovers all agents with nested namespaces."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create agent_one/agent.py
|
||||
dir1 = temp_path / "agent_one"
|
||||
dir1.mkdir()
|
||||
(dir1 / "agent.py").write_text("")
|
||||
|
||||
# Create sub_dir/agent_two/root_agent.yaml
|
||||
dir2 = temp_path / "sub_dir" / "agent_two"
|
||||
dir2.mkdir(parents=True)
|
||||
(dir2 / "root_agent.yaml").write_text("")
|
||||
|
||||
# Create sub_dir/sub_sub_dir/agent_three/__init__.py
|
||||
dir3 = temp_path / "sub_dir" / "sub_sub_dir" / "agent_three"
|
||||
dir3.mkdir(parents=True)
|
||||
(dir3 / "__init__.py").write_text("root_agent = None")
|
||||
|
||||
loader = NestedAgentLoader(str(temp_path))
|
||||
agents = loader.list_agents()
|
||||
|
||||
assert agents == [
|
||||
"agent_one",
|
||||
"sub_dir.agent_two",
|
||||
"sub_dir.sub_sub_dir.agent_three",
|
||||
]
|
||||
|
||||
def test_list_agents_ignores_hidden_and_special_directories(self):
|
||||
"""Discovered agents exclude hidden directories, pycache, and temp folders."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Valid agent
|
||||
dir_valid = temp_path / "valid_agent"
|
||||
dir_valid.mkdir()
|
||||
(dir_valid / "agent.py").write_text("")
|
||||
|
||||
# Hidden agent (starts with dot)
|
||||
dir_hidden = temp_path / ".hidden_agent"
|
||||
dir_hidden.mkdir()
|
||||
(dir_hidden / "agent.py").write_text("")
|
||||
|
||||
# Special directory __pycache__
|
||||
dir_pycache = temp_path / "__pycache__"
|
||||
dir_pycache.mkdir()
|
||||
(dir_pycache / "agent.py").write_text("")
|
||||
|
||||
# Special directory tmp
|
||||
dir_tmp = temp_path / "tmp"
|
||||
dir_tmp.mkdir()
|
||||
(dir_tmp / "agent.py").write_text("")
|
||||
|
||||
loader = NestedAgentLoader(str(temp_path))
|
||||
agents = loader.list_agents()
|
||||
|
||||
assert agents == ["valid_agent"]
|
||||
|
||||
def test_list_agents_excludes_root_directory_itself(self):
|
||||
"""Root app directory itself is not listed as a nested agent sub-app."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create agent.py at the root itself
|
||||
(temp_path / "agent.py").write_text("")
|
||||
|
||||
# Create a nested valid agent
|
||||
dir_nested = temp_path / "sub_agent"
|
||||
dir_nested.mkdir()
|
||||
(dir_nested / "agent.py").write_text("")
|
||||
|
||||
loader = NestedAgentLoader(str(temp_path))
|
||||
agents = loader.list_agents()
|
||||
|
||||
assert agents == ["sub_agent"]
|
||||
|
||||
def test_list_agents_sorts_discovered_agents_alphabetically(self):
|
||||
"""Returned list of discovered agents is sorted alphabetically."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create agents in non-alphabetical order
|
||||
for name in ["z_agent", "a_agent", "m_agent"]:
|
||||
agent_dir = temp_path / name
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "agent.py").write_text("")
|
||||
|
||||
loader = NestedAgentLoader(str(temp_path))
|
||||
agents = loader.list_agents()
|
||||
|
||||
assert agents == ["a_agent", "m_agent", "z_agent"]
|
||||
|
||||
def test_remove_agent_from_cache_clears_module_keys_and_internal_cache(self):
|
||||
"""remove_agent_from_cache removes agent from internal cache and deletes its sys.modules keys."""
|
||||
loader = NestedAgentLoader("/dummy/path")
|
||||
|
||||
# Arrange: populate internal cache (with slash-separated path) and sys.modules (with dot-separated path)
|
||||
loader._agent_cache["foo/bar/my_agent"] = "dummy_agent_instance"
|
||||
sys.modules["foo.bar.my_agent"] = "dummy_module_instance"
|
||||
sys.modules["foo.bar.my_agent.submodule"] = "dummy_submodule_instance"
|
||||
|
||||
# Act: remove the agent from cache
|
||||
loader.remove_agent_from_cache("foo/bar/my_agent")
|
||||
|
||||
# Assert: cleared from loader's internal cache
|
||||
assert "foo/bar/my_agent" not in loader._agent_cache
|
||||
|
||||
# Assert: cleared from sys.modules (both the module and its submodules)
|
||||
assert "foo.bar.my_agent" not in sys.modules
|
||||
assert "foo.bar.my_agent.submodule" not in sys.modules
|
||||
@@ -0,0 +1,568 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for service factory helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.cli.service_registry import ServiceRegistry
|
||||
from google.adk.cli.utils.local_storage import PerAgentDatabaseSessionService
|
||||
from google.adk.cli.utils.local_storage import PerAgentFileArtifactService
|
||||
import google.adk.cli.utils.service_factory as service_factory
|
||||
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
|
||||
from google.adk.sessions.database_session_service import DatabaseSessionService
|
||||
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
||||
from google.genai import types
|
||||
import pytest
|
||||
|
||||
|
||||
def test_create_session_service_uses_registry(tmp_path: Path, monkeypatch):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
expected = object()
|
||||
registry.create_session_service.return_value = expected
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
result = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
session_service_uri="sqlite:///test.db",
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
registry.create_session_service.assert_called_once_with(
|
||||
"sqlite:///test.db",
|
||||
agents_dir=str(tmp_path),
|
||||
)
|
||||
|
||||
|
||||
def test_create_session_service_logs_redacted_uri(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
registry.create_session_service.return_value = object()
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
session_service_uri = (
|
||||
"postgresql://user:supersecret@localhost:5432/dbname?sslmode=require"
|
||||
)
|
||||
caplog.set_level(logging.INFO, logger=service_factory.logger.name)
|
||||
|
||||
service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
session_service_uri=session_service_uri,
|
||||
)
|
||||
|
||||
assert "supersecret" not in caplog.text
|
||||
assert "sslmode=require" not in caplog.text
|
||||
assert "localhost:5432" in caplog.text
|
||||
|
||||
|
||||
def test_redact_uri_for_log_removes_credentials_with_at_in_password() -> None:
|
||||
uri = "postgresql://user:super@secret@localhost:5432/dbname"
|
||||
|
||||
assert (
|
||||
service_factory._redact_uri_for_log(uri)
|
||||
== "postgresql://localhost:5432/dbname"
|
||||
)
|
||||
|
||||
|
||||
def test_redact_uri_for_log_preserves_host_when_no_credentials() -> None:
|
||||
uri = "postgresql://localhost:5432/dbname?sslmode=require&password=secret"
|
||||
|
||||
redacted = service_factory._redact_uri_for_log(uri)
|
||||
|
||||
assert redacted.startswith("postgresql://localhost:5432/dbname?")
|
||||
assert "require" not in redacted
|
||||
assert "secret" not in redacted
|
||||
assert "sslmode=<redacted>" in redacted
|
||||
assert "password=<redacted>" in redacted
|
||||
|
||||
|
||||
def test_redact_uri_for_log_redacts_when_parse_qsl_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def _raise_value_error(*_args, **_kwargs):
|
||||
raise ValueError("bad query")
|
||||
|
||||
monkeypatch.setattr(service_factory, "parse_qsl", _raise_value_error)
|
||||
|
||||
uri = "postgresql://user:pass@localhost:5432/dbname?sslmode=require"
|
||||
redacted = service_factory._redact_uri_for_log(uri)
|
||||
|
||||
assert "pass" not in redacted
|
||||
assert "require" not in redacted
|
||||
assert redacted.endswith("?<redacted>")
|
||||
|
||||
|
||||
def test_redact_uri_for_log_escapes_crlf() -> None:
|
||||
uri = (
|
||||
"postgresql://user:pass@localhost:5432/dbname\rINJECT\nINJECT"
|
||||
"?sslmode=require"
|
||||
)
|
||||
|
||||
redacted = service_factory._redact_uri_for_log(uri)
|
||||
|
||||
assert "\r" not in redacted
|
||||
assert "\n" not in redacted
|
||||
assert "\\rINJECT\\nINJECT" in redacted
|
||||
|
||||
|
||||
def test_redact_uri_for_log_returns_scheme_missing_without_separator() -> None:
|
||||
assert (
|
||||
service_factory._redact_uri_for_log("user:pass@localhost:5432/dbname")
|
||||
== "<scheme-missing>"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_service_defaults_to_per_agent_sqlite(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_dir = tmp_path / "agent_a"
|
||||
agent_dir.mkdir()
|
||||
service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, PerAgentDatabaseSessionService)
|
||||
session = await service.create_session(app_name="agent_a", user_id="user")
|
||||
assert session.app_name == "agent_a"
|
||||
assert (agent_dir / ".adk" / "session.db").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_service_respects_app_name_mapping(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_dir = tmp_path / "agent_folder"
|
||||
logical_name = "custom_app"
|
||||
agent_dir.mkdir()
|
||||
|
||||
service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
app_name_to_dir={logical_name: "agent_folder"},
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, PerAgentDatabaseSessionService)
|
||||
session = await service.create_session(app_name=logical_name, user_id="user")
|
||||
assert session.app_name == logical_name
|
||||
assert (agent_dir / ".adk" / "session.db").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_service_fallbacks_to_database(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
registry.create_session_service.return_value = None
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
session_service_uri="sqlite+aiosqlite:///:memory:",
|
||||
session_db_kwargs={"echo": True},
|
||||
)
|
||||
|
||||
assert isinstance(service, DatabaseSessionService)
|
||||
assert service.db_engine.url.drivername == "sqlite+aiosqlite"
|
||||
assert service.db_engine.echo is True
|
||||
registry.create_session_service.assert_called_once_with(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
agents_dir=str(tmp_path),
|
||||
echo=True,
|
||||
)
|
||||
await service.close()
|
||||
|
||||
|
||||
def test_create_artifact_service_uses_registry(tmp_path: Path, monkeypatch):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
expected = object()
|
||||
registry.create_artifact_service.return_value = expected
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
result = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
artifact_service_uri="gs://bucket/path",
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
registry.create_artifact_service.assert_called_once_with(
|
||||
"gs://bucket/path",
|
||||
agents_dir=str(tmp_path),
|
||||
)
|
||||
|
||||
|
||||
def test_create_artifact_service_raises_on_unknown_scheme_when_strict(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
registry.create_artifact_service.return_value = None
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
artifact_service_uri="unknown://foo",
|
||||
strict_uri=True,
|
||||
)
|
||||
|
||||
|
||||
def test_create_memory_service_uses_registry(tmp_path: Path, monkeypatch):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
expected = object()
|
||||
registry.create_memory_service.return_value = expected
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
result = service_factory.create_memory_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
memory_service_uri="rag://my-corpus",
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
registry.create_memory_service.assert_called_once_with(
|
||||
"rag://my-corpus",
|
||||
agents_dir=str(tmp_path),
|
||||
)
|
||||
|
||||
|
||||
def test_create_memory_service_defaults_to_in_memory(tmp_path: Path):
|
||||
service = service_factory.create_memory_service_from_options(
|
||||
base_dir=tmp_path
|
||||
)
|
||||
|
||||
assert isinstance(service, InMemoryMemoryService)
|
||||
|
||||
|
||||
def test_create_memory_service_supports_memory_uri(tmp_path: Path):
|
||||
service = service_factory.create_memory_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
memory_service_uri="memory://",
|
||||
)
|
||||
|
||||
assert isinstance(service, InMemoryMemoryService)
|
||||
|
||||
|
||||
def test_create_memory_service_raises_on_unknown_scheme(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
registry.create_memory_service.return_value = None
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
service_factory.create_memory_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
memory_service_uri="unknown://foo",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_service_defaults_to_in_memory_when_disabled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=False,
|
||||
)
|
||||
|
||||
assert isinstance(service, InMemorySessionService)
|
||||
session = await service.create_session(app_name="agent_a", user_id="user")
|
||||
assert session.app_name == "agent_a"
|
||||
assert not (tmp_path / "agent_a" / ".adk").exists()
|
||||
|
||||
|
||||
def test_create_artifact_service_defaults_to_in_memory_when_disabled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=False,
|
||||
)
|
||||
|
||||
assert isinstance(service, InMemoryArtifactService)
|
||||
assert not (tmp_path / ".adk").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_artifact_service_defaults_to_per_agent(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_dir = tmp_path / "agent_a"
|
||||
agent_dir.mkdir()
|
||||
service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, PerAgentFileArtifactService)
|
||||
await service.save_artifact(
|
||||
app_name="agent_a",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
filename="file.txt",
|
||||
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
|
||||
)
|
||||
assert (agent_dir / ".adk" / "artifacts").exists()
|
||||
assert not (tmp_path / ".adk").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_artifact_service_respects_app_name_mapping(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_dir = tmp_path / "agent_folder"
|
||||
logical_name = "custom_app"
|
||||
agent_dir.mkdir()
|
||||
|
||||
service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
app_name_to_dir={logical_name: "agent_folder"},
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, PerAgentFileArtifactService)
|
||||
await service.save_artifact(
|
||||
app_name=logical_name,
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
filename="file.txt",
|
||||
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
|
||||
)
|
||||
assert (agent_dir / ".adk" / "artifacts").exists()
|
||||
|
||||
|
||||
def test_create_artifact_service_warns_on_legacy_shared_root(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
(tmp_path / ".adk" / "artifacts").mkdir(parents=True)
|
||||
|
||||
caplog.set_level(logging.WARNING, logger=service_factory.logger.name)
|
||||
service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, PerAgentFileArtifactService)
|
||||
assert "legacy shared artifacts" in caplog.text
|
||||
|
||||
|
||||
def test_create_artifact_service_no_warning_without_legacy_root(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level(logging.WARNING, logger=service_factory.logger.name)
|
||||
service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert "legacy shared artifacts" not in caplog.text
|
||||
|
||||
|
||||
def test_create_session_service_fallbacks_to_in_memory_on_permission_error(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def _raise_permission_error(*_args, **_kwargs):
|
||||
raise PermissionError("nope")
|
||||
|
||||
monkeypatch.setattr(
|
||||
service_factory, "create_local_session_service", _raise_permission_error
|
||||
)
|
||||
|
||||
service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, InMemorySessionService)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="chmod behavior differs on Windows")
|
||||
def test_create_services_default_to_in_memory_when_agents_dir_unwritable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agents_dir = tmp_path / "agents"
|
||||
agents_dir.mkdir()
|
||||
try:
|
||||
agents_dir.chmod(0o555)
|
||||
if os.access(agents_dir, os.W_OK | os.X_OK):
|
||||
pytest.skip("Test cannot make directory unwritable in this environment.")
|
||||
|
||||
session_service = service_factory.create_session_service_from_options(
|
||||
base_dir=agents_dir,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(session_service, InMemorySessionService)
|
||||
|
||||
artifact_service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=agents_dir,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(artifact_service, InMemoryArtifactService)
|
||||
finally:
|
||||
agents_dir.chmod(0o755)
|
||||
|
||||
|
||||
def test_adk_disable_local_storage_env_forces_in_memory(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ADK_DISABLE_LOCAL_STORAGE", "1")
|
||||
|
||||
session_service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(session_service, InMemorySessionService)
|
||||
|
||||
artifact_service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(artifact_service, InMemoryArtifactService)
|
||||
|
||||
|
||||
def test_cloud_run_env_defaults_to_in_memory(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("K_SERVICE", "adk-service")
|
||||
|
||||
session_service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(session_service, InMemorySessionService)
|
||||
|
||||
artifact_service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(artifact_service, InMemoryArtifactService)
|
||||
|
||||
|
||||
def test_kubernetes_env_defaults_to_in_memory(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1")
|
||||
|
||||
session_service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(session_service, InMemorySessionService)
|
||||
|
||||
artifact_service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(artifact_service, InMemoryArtifactService)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adk_force_local_storage_env_overrides_flag(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ADK_FORCE_LOCAL_STORAGE", "1")
|
||||
agent_dir = tmp_path / "agent_a"
|
||||
agent_dir.mkdir()
|
||||
|
||||
session_service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=False,
|
||||
)
|
||||
assert isinstance(session_service, PerAgentDatabaseSessionService)
|
||||
await session_service.create_session(app_name="agent_a", user_id="user")
|
||||
assert (agent_dir / ".adk" / "session.db").exists()
|
||||
|
||||
artifact_service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=False,
|
||||
)
|
||||
assert isinstance(artifact_service, PerAgentFileArtifactService)
|
||||
await artifact_service.save_artifact(
|
||||
app_name="agent_a",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
filename="file.txt",
|
||||
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
|
||||
)
|
||||
assert (agent_dir / ".adk" / "artifacts").exists()
|
||||
|
||||
|
||||
def test_create_artifact_service_fallbacks_to_in_memory_on_permission_error(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def _raise_permission_error(*_args, **_kwargs):
|
||||
raise PermissionError("nope")
|
||||
|
||||
monkeypatch.setattr(
|
||||
service_factory, "create_local_artifact_service", _raise_permission_error
|
||||
)
|
||||
|
||||
service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, InMemoryArtifactService)
|
||||
|
||||
|
||||
def test_create_task_store_uses_registry(monkeypatch):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
expected = object()
|
||||
registry._create_task_store_service.return_value = expected
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
result = service_factory._create_task_store_from_options(
|
||||
task_store_uri="postgresql+asyncpg://user:pass@host/db",
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
registry._create_task_store_service.assert_called_once_with(
|
||||
"postgresql+asyncpg://user:pass@host/db",
|
||||
)
|
||||
|
||||
|
||||
def test_create_task_store_defaults_to_in_memory():
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
|
||||
service = service_factory._create_task_store_from_options()
|
||||
|
||||
assert isinstance(service, InMemoryTaskStore)
|
||||
|
||||
|
||||
def test_create_task_store_raises_on_unknown_scheme(monkeypatch):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
registry._create_task_store_service.side_effect = ValueError("Unsupported")
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
service_factory._create_task_store_from_options(
|
||||
task_store_uri="unknown://foo",
|
||||
)
|
||||
Reference in New Issue
Block a user