From 8963300518d10299dc311a4c8f4e49928d2aea39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=95=82?= <51281148+K-dash@users.noreply.github.com> Date: Thu, 8 May 2025 22:17:58 -0700 Subject: [PATCH] test(cli): Add unit tests for CLI functionality Copybara import of the project: -- f60707a22905f30040808b41b7e3510a47a80fc6 by K <51281148+K-dash@users.noreply.github.com>: test(cli): Add unit tests for CLI functionality This commit introduces unit tests for the following CLI-related components: - cli_deploy.py: Tests for the cloud deployment feature. - cli_create.py: Tests for the agent creation feature. - cli.py: Tests for the main CLI execution logic. - cli_tools_click.py: Tests for the Click-based CLI tools. -- 7be2159a475d0785619fea5e40c70e6461a7f4e1 by K <51281148+K-dash@users.noreply.github.com>: fix test_cli_eval_success_path COPYBARA_INTEGRATE_REVIEW=https://github.com/google/adk-python/pull/577 from K-dash:test/add-unit-tests-for-cli 69f12d3a27d9c50a46ef269075e050f498dee67a PiperOrigin-RevId: 756602765 --- tests/unittests/cli/utils/test_cli.py | 256 +++++++++++++++++ tests/unittests/cli/utils/test_cli_create.py | 230 ++++++++++++++++ tests/unittests/cli/utils/test_cli_deploy.py | 168 ++++++++++++ .../cli/utils/test_cli_tools_click.py | 258 ++++++++++++++++++ 4 files changed, 912 insertions(+) create mode 100644 tests/unittests/cli/utils/test_cli.py create mode 100644 tests/unittests/cli/utils/test_cli_create.py create mode 100644 tests/unittests/cli/utils/test_cli_deploy.py create mode 100644 tests/unittests/cli/utils/test_cli_tools_click.py diff --git a/tests/unittests/cli/utils/test_cli.py b/tests/unittests/cli/utils/test_cli.py new file mode 100644 index 0000000..352e470 --- /dev/null +++ b/tests/unittests/cli/utils/test_cli.py @@ -0,0 +1,256 @@ +# Copyright 2025 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 click +import json +import pytest +import sys +import types + +import google.adk.cli.cli as cli + +from pathlib import Path +from typing import Any, Dict, List, Tuple + +# 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}")]) + yield types.SimpleNamespace(author="assistant", content=response) + + monkeypatch.setattr(cli, "Runner", _FakeRunner) + + +@pytest.fixture() +def fake_agent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """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( + "from types import SimpleNamespace\n" + "root_agent = SimpleNamespace(name='fake_root')\n" + ) + + # Ensure importable via sys.path + sys.path.insert(0, str(parent_dir)) + + import importlib + + module = importlib.import_module("fake_agent") + fake_module = types.SimpleNamespace(agent=module) + + monkeypatch.setattr(importlib, "import_module", lambda n: fake_module) + monkeypatch.setattr(cli.envs, "load_dotenv_for_agent", lambda *a, **k: None) + + yield parent_dir, "fake_agent" + + # Cleanup + sys.path.remove(str(parent_dir)) + del sys.modules["fake_agent"] + + +# _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 = cli.InMemoryArtifactService() + session_service = cli.InMemorySessionService() + dummy_root = types.SimpleNamespace(name="root") + + session = await cli.run_input_file( + app_name="app", + user_id="user", + root_agent=dummy_root, + artifact_service=artifact_service, + session_service=session_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, + ) + + +# _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_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 + svc = cli.InMemorySessionService() + sess = svc.create_session(app_name="dummy", user_id="u") + artifact_service = cli.InMemoryArtifactService() + root_agent = types.SimpleNamespace(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, svc) + + # verify: assistant echoed once with 'echo:hello' + assert any("echo:hello" in m for m in echoed) + + +# run_cli (resume branch) +@pytest.mark.asyncio +async def test_run_cli_resume_saved_session(tmp_path: Path, fake_agent, monkeypatch: pytest.MonkeyPatch) -> None: + """run_cli should load previous session, print its events, then re-enter interactive mode.""" + parent_dir, folder = fake_agent + + # stub Session.model_validate_json to return dummy session with two events + user_content = types.SimpleNamespace(parts=[types.SimpleNamespace(text="hi")]) + assistant_content = types.SimpleNamespace(parts=[types.SimpleNamespace(text="hello!")]) + dummy_session = types.SimpleNamespace( + id="sess", + app_name=folder, + user_id="u", + events=[ + types.SimpleNamespace(author="user", content=user_content, partial=False), + types.SimpleNamespace(author="assistant", content=assistant_content, partial=False), + ], + ) + monkeypatch.setattr(cli.Session, "model_validate_json", staticmethod(lambda _s: dummy_session)) + monkeypatch.setattr(cli.InMemorySessionService, "append_event", lambda *_a, **_k: None) + # interactive inputs: immediately 'exit' + monkeypatch.setattr("builtins.input", lambda *_a, **_k: "exit") + + # collect echo output + captured: list[str] = [] + monkeypatch.setattr(click, "echo", lambda m: captured.append(m)) + + saved_path = tmp_path / "prev.session.json" + saved_path.write_text("{}") # contents not used – patched above + + await cli.run_cli( + agent_parent_dir=str(parent_dir), + agent_folder_name=folder, + input_file=None, + saved_session_file=str(saved_path), + save_session=False, + ) + + # ④ ensure both historical messages were printed + assert any("[user]: hi" in m for m in captured) + assert any("[assistant]: hello!" in m for m in captured) diff --git a/tests/unittests/cli/utils/test_cli_create.py b/tests/unittests/cli/utils/test_cli_create.py new file mode 100644 index 0000000..7ae9c22 --- /dev/null +++ b/tests/unittests/cli/utils/test_cli_create.py @@ -0,0 +1,230 @@ +# Copyright 2025 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 click +import os +import pytest +import subprocess + +import google.adk.cli.cli_create as cli_create + +from pathlib import Path +from typing import Any, Dict, List, Tuple + +# 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.0-flash-001", + ) + + env_content = (agent_folder / ".env").read_text() + assert "GOOGLE_API_KEY=dummy-key" in env_content + assert "GOOGLE_GENAI_USE_VERTEXAI=0" in env_content + 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.0-flash-001", + ) + + 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_VERTEXAI=1" 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.0-flash-001", + ) + + 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.0-flash-001") + + +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.0-flash-001") + + env_content = (agent_folder / ".env").read_text() + for key in ("GOOGLE_API_KEY", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "GOOGLE_GENAI_USE_VERTEXAI"): + assert key not in env_content + + +# 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(os.path, "exists", lambda _p: True) + monkeypatch.setattr(os, "listdir", lambda _p: ["dummy.txt"]) + monkeypatch.setattr(click, "confirm", lambda *a, **k: False) + + with pytest.raises(click.Abort): + cli_create.run_cmd( + agent_name, + model="gemini-2.0-flash-001", + google_api_key=None, + google_cloud_project=None, + google_cloud_region=None, + ) + + +# 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 cli_create._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 cli_create._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 cli_create._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-2.0-flash-001" + + +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() == "" + 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(cli_create, "_prompt_for_google_api_key", lambda _v: "api-key") + + api_key, proj, region = cli_create._prompt_to_choose_backend(None, None, None) + assert api_key == "api-key" + assert proj is None and region is None + + +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(cli_create, "_prompt_for_google_cloud", lambda _v: "proj") + monkeypatch.setattr(cli_create, "_prompt_for_google_cloud_region", lambda _v: "region") + + api_key, proj, region = cli_create._prompt_to_choose_backend(None, None, None) + assert api_key is None + assert proj == "proj" + assert region == "region" + + + +# 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 cli_create._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 cli_create._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 cli_create._get_gcp_region_from_gcloud() == "" diff --git a/tests/unittests/cli/utils/test_cli_deploy.py b/tests/unittests/cli/utils/test_cli_deploy.py new file mode 100644 index 0000000..55c067c --- /dev/null +++ b/tests/unittests/cli/utils/test_cli_deploy.py @@ -0,0 +1,168 @@ +# Copyright 2025 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 click +import shutil +import pytest +import subprocess +import tempfile +import types + +import google.adk.cli.cli_deploy as cli_deploy + +from pathlib import Path +from typing import Any, Callable, Dict, List, Tuple +from unittest import mock + +# 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)) + + +# 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) + + +@pytest.fixture() +def agent_dir(tmp_path: Path) -> Callable[[bool], Path]: + """Return a factory that creates a dummy agent directory tree.""" + + def _factory(include_requirements: bool) -> Path: + base = tmp_path / "agent" + base.mkdir() + (base / "agent.py").write_text("# dummy agent") + (base / "__init__.py").touch() + if include_requirements: + (base / "requirements.txt").write_text("pytest\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() + + +# to_cloud_run +@pytest.mark.parametrize("include_requirements", [True, False]) +def test_to_cloud_run_happy_path( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool], Path], + include_requirements: bool, +) -> None: + """ + End-to-end execution test for `to_cloud_run` covering both presence and + absence of *requirements.txt*. + """ + tmp_dir = Path(tempfile.mkdtemp()) + src_dir = agent_dir(include_requirements) + + copy_recorder = _Recorder() + run_recorder = _Recorder() + + # Cache the ORIGINAL copytree before patching + original_copytree = cli_deploy.shutil.copytree + + def _recording_copytree(*args: Any, **kwargs: Any): + copy_recorder(*args, **kwargs) + return original_copytree(*args, **kwargs) + + monkeypatch.setattr(cli_deploy.shutil, "copytree", _recording_copytree) + # Skip actual cleanup so that we can inspect generated files later. + monkeypatch.setattr(cli_deploy.shutil, "rmtree", lambda *_a, **_k: None) + monkeypatch.setattr(subprocess, "run", run_recorder) + + cli_deploy.to_cloud_run( + agent_folder=str(src_dir), + project="proj", + region="asia-northeast1", + service_name="svc", + app_name="app", + temp_folder=str(tmp_dir), + port=8080, + trace_to_cloud=True, + with_ui=True, + verbosity="info", + session_db_url="sqlite://", + ) + + # Assertions + assert len(copy_recorder.calls) == 1, "Agent sources must be copied exactly once." + assert run_recorder.calls, "gcloud command should be executed at least once." + assert (tmp_dir / "Dockerfile").exists(), "Dockerfile must be generated." + + # Manual cleanup because we disabled rmtree in the monkeypatch. + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def test_to_cloud_run_cleans_temp_dir( + monkeypatch: pytest.MonkeyPatch, + agent_dir: Callable[[bool], Path], +) -> None: + """`to_cloud_run` should always delete the temporary folder on exit.""" + tmp_dir = Path(tempfile.mkdtemp()) + src_dir = agent_dir(False) + + deleted: Dict[str, Path] = {} + + def _fake_rmtree(path: str | Path, *a: Any, **k: Any) -> None: + deleted["path"] = Path(path) + + monkeypatch.setattr(cli_deploy.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, + with_ui=False, + verbosity="info", + session_db_url=None, + ) + + assert deleted["path"] == tmp_dir diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py new file mode 100644 index 0000000..917878d --- /dev/null +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -0,0 +1,258 @@ +# Copyright 2025 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_tool_click.""" + + +from __future__ import annotations + +import builtins +import click +import pytest + +from google.adk.cli import cli_tools_click + +from pathlib import Path +from typing import Any, Dict, List, Tuple +from types import SimpleNamespace +from click.testing import CliRunner + +# 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: # noqa: D401 + self.calls.append((args, kwargs)) + + +# Fixtures +@pytest.fixture(autouse=True) +def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None: + """Suppress click output during tests.""" + monkeypatch.setattr(click, "echo", lambda *a, **k: None) + monkeypatch.setattr(click, "secho", lambda *a, **k: None) + + +# validate_exclusive +def test_validate_exclusive_allows_single() -> None: + """Providing exactly one exclusive option should pass.""" + ctx = click.Context(cli_tools_click.main) + param = SimpleNamespace(name="replay") + assert cli_tools_click.validate_exclusive(ctx, param, "file.json") == "file.json" + + +def test_validate_exclusive_blocks_multiple() -> None: + """Providing two exclusive options should raise UsageError.""" + ctx = click.Context(cli_tools_click.main) + param1 = SimpleNamespace(name="replay") + param2 = SimpleNamespace(name="resume") + + # First option registers fine + cli_tools_click.validate_exclusive(ctx, param1, "replay.json") + + # Second option triggers conflict + with pytest.raises(click.UsageError): + cli_tools_click.validate_exclusive(ctx, param2, "resume.json") + + +# cli create +def test_cli_create_cmd_invokes_run_cmd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """`adk create` should forward arguments to cli_create.run_cmd.""" + rec = _Recorder() + monkeypatch.setattr(cli_tools_click.cli_create, "run_cmd", rec) + + app_dir = tmp_path / "my_app" + runner = CliRunner() + result = runner.invoke( + cli_tools_click.main, + ["create", "--model", "gemini", "--api_key", "key123", str(app_dir)], + ) + assert result.exit_code == 0 + assert rec.calls, "cli_create.run_cmd must be called" + + +# cli run +@pytest.mark.asyncio +async def test_cli_run_invokes_run_cli(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """`adk run` should call run_cli via asyncio.run with correct parameters.""" + rec = _Recorder() + monkeypatch.setattr(cli_tools_click, "run_cli", lambda **kwargs: rec(kwargs)) + monkeypatch.setattr(cli_tools_click.asyncio, "run", lambda coro: coro) # pass-through + + # create dummy agent directory + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + (agent_dir / "__init__.py").touch() + (agent_dir / "agent.py").touch() + + runner = CliRunner() + result = runner.invoke(cli_tools_click.main, ["run", str(agent_dir)]) + assert result.exit_code == 0 + assert rec.calls and rec.calls[0][0][0]["agent_folder_name"] == "agent" + + +# cli deploy cloud_run +def test_cli_deploy_cloud_run_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Successful path should call cli_deploy.to_cloud_run once.""" + rec = _Recorder() + monkeypatch.setattr(cli_tools_click.cli_deploy, "to_cloud_run", rec) + + agent_dir = tmp_path / "agent2" + agent_dir.mkdir() + runner = CliRunner() + result = runner.invoke( + cli_tools_click.main, + [ + "deploy", + "cloud_run", + "--project", + "proj", + "--region", + "asia-northeast1", + str(agent_dir), + ], + ) + assert result.exit_code == 0 + assert rec.calls, "cli_deploy.to_cloud_run must be invoked" + + +def test_cli_deploy_cloud_run_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Exception from to_cloud_run should be caught and surfaced via click.secho.""" + def _boom(*_a: Any, **_k: Any) -> None: # noqa: D401 + raise RuntimeError("boom") + + monkeypatch.setattr(cli_tools_click.cli_deploy, "to_cloud_run", _boom) + + # intercept click.secho(error=True) output + captured: List[str] = [] + monkeypatch.setattr(click, "secho", lambda msg, **__: captured.append(msg)) + + agent_dir = tmp_path / "agent3" + agent_dir.mkdir() + runner = CliRunner() + result = runner.invoke(cli_tools_click.main, ["deploy", "cloud_run", str(agent_dir)]) + + assert result.exit_code == 0 + assert any("Deploy failed: boom" in m for m in captured) + + +# cli eval +def test_cli_eval_missing_deps_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """If cli_eval sub-module is missing, command should raise ClickException.""" + # Ensure .cli_eval is not importable + orig_import = builtins.__import__ + + def _fake_import(name: str, *a: Any, **k: Any): + if name.endswith(".cli_eval") or name == "google.adk.cli.cli_eval": + raise ModuleNotFoundError() + return orig_import(name, *a, **k) + + monkeypatch.setattr(builtins, "__import__", _fake_import) + + +# cli web & api_server (uvicorn patched) +@pytest.fixture() +def _patch_uvicorn(monkeypatch: pytest.MonkeyPatch) -> _Recorder: + """Patch uvicorn.Config/Server to avoid real network operations.""" + rec = _Recorder() + + class _DummyServer: + def __init__(self, *a: Any, **k: Any) -> None: ... + def run(self) -> None: + rec() + + monkeypatch.setattr(cli_tools_click.uvicorn, "Config", lambda *a, **k: object()) + monkeypatch.setattr(cli_tools_click.uvicorn, "Server", lambda *_a, **_k: _DummyServer()) + monkeypatch.setattr(cli_tools_click, "get_fast_api_app", lambda **_k: object()) + return rec + + +def test_cli_web_invokes_uvicorn(tmp_path: Path, _patch_uvicorn: _Recorder) -> None: + """`adk web` should configure and start uvicorn.Server.run.""" + agents_dir = tmp_path / "agents" + agents_dir.mkdir() + runner = CliRunner() + result = runner.invoke(cli_tools_click.main, ["web", str(agents_dir)]) + assert result.exit_code == 0 + assert _patch_uvicorn.calls, "uvicorn.Server.run must be called" + + +def test_cli_api_server_invokes_uvicorn(tmp_path: Path, _patch_uvicorn: _Recorder) -> None: + """`adk api_server` should configure and start uvicorn.Server.run.""" + agents_dir = tmp_path / "agents_api" + agents_dir.mkdir() + runner = CliRunner() + result = runner.invoke(cli_tools_click.main, ["api_server", str(agents_dir)]) + assert result.exit_code == 0 + assert _patch_uvicorn.calls, "uvicorn.Server.run must be called" + + +def test_cli_eval_success_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test the success path of `adk eval` by fully executing it with a stub module, up to summary generation.""" + import sys, types + + # stub cli_eval module + stub = types.ModuleType("google.adk.cli.cli_eval") + + class _EvalMetric: + def __init__(self, metric_name: str, threshold: float) -> None: ... + + class _EvalResult: + def __init__(self, eval_set_file: str, final_eval_status: str) -> None: + self.eval_set_file = eval_set_file + self.final_eval_status = final_eval_status + + # minimal enum-like namespace + _EvalStatus = types.SimpleNamespace(PASSED="PASSED", FAILED="FAILED") + + # helper funcs + stub.EvalMetric = _EvalMetric + stub.EvalResult = _EvalResult + stub.EvalStatus = _EvalStatus + stub.MISSING_EVAL_DEPENDENCIES_MESSAGE = "stub msg" + + stub.get_evaluation_criteria_or_default = lambda _p: {"foo": 1.0} + stub.get_root_agent = lambda _p: object() + stub.try_get_reset_func = lambda _p: None + stub.parse_and_get_evals_to_run = lambda _paths: {"set1.json": ["e1", "e2"]} + stub.run_evals = lambda *_a, **_k: iter( + [_EvalResult("set1.json", "PASSED"), _EvalResult("set1.json", "FAILED")] + ) + + monkeypatch.setattr(cli_tools_click.asyncio, "run", lambda coro: list(coro)) + + # inject stub + sys.modules["google.adk.cli.cli_eval"] = stub + + # create dummy agent directory + agent_dir = tmp_path / "agent5" + agent_dir.mkdir() + (agent_dir / "__init__.py").touch() + + # inject monkeypatch + monkeypatch.setattr(cli_tools_click.envs, "load_dotenv_for_agent", lambda *a, **k: None) + + runner = CliRunner() + result = runner.invoke( + cli_tools_click.main, + ["eval", str(agent_dir), str(tmp_path / "dummy_eval.json")], + ) + + assert result.exit_code == 0 + assert "Eval Run Summary" in result.output + assert "Tests passed: 1" in result.output + assert "Tests failed: 1" in result.output