Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

383 lines
13 KiB
Python

"""Tests for the Gemini CLI adapter (detect / build / parse / env forwarding)."""
from __future__ import annotations
import json
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from integrations.llm_cli.binary_resolver import npm_prefix_bin_dirs
from integrations.llm_cli.gemini_cli import (
_PROBE_TIMEOUT_SEC,
GeminiCLIAdapter,
_fallback_gemini_cli_paths,
_resolve_exec_timeout_seconds,
)
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
def _posix_path_set(paths: list[str]) -> set[str]:
return {Path(p).as_posix() for p in paths}
def _version_proc() -> MagicMock:
m = MagicMock()
m.returncode = 0
m.stdout = "gemini-cli 0.1.2\n"
m.stderr = ""
return m
def _auth_ok_proc() -> MagicMock:
m = MagicMock()
m.returncode = 0
m.stdout = '{"response":"ok"}\n'
m.stderr = ""
return m
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
@patch("integrations.llm_cli.binary_resolver.shutil.which")
def test_detect_logged_in(mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_which.return_value = "/usr/bin/gemini"
mock_run.side_effect = [_version_proc(), _auth_ok_proc()]
probe = GeminiCLIAdapter().detect()
assert probe.installed is True
assert probe.logged_in is True
assert probe.bin_path == "/usr/bin/gemini"
assert probe.version == "0.1.2"
# The probe should always carry the 2026-06-18 sunset notice when installed,
# pointing users to antigravity-cli (additive — paid Code Assist exempt).
assert "2026-06-18" in probe.detail
assert "antigravity-cli" in probe.detail
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
@patch("integrations.llm_cli.binary_resolver.shutil.which")
def test_detect_not_authenticated(mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_which.return_value = "/usr/bin/gemini"
auth = MagicMock()
auth.returncode = 1
auth.stdout = ""
auth.stderr = "Authentication required"
mock_run.side_effect = [_version_proc(), auth]
with patch.dict(os.environ, {"GEMINI_API_KEY": ""}, clear=False):
probe = GeminiCLIAdapter().detect()
assert probe.installed is True
assert probe.logged_in is False
# The sunset notice is suppressed on confirmed auth failures so users
# debugging "Not authenticated" don't read it next to the actual error.
assert "2026-06-18" not in probe.detail
assert "antigravity-cli" not in probe.detail
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
@patch("integrations.llm_cli.binary_resolver.shutil.which")
def test_detect_not_authenticated_when_json_error_requests_auth(
mock_which: MagicMock, mock_run: MagicMock
) -> None:
mock_which.return_value = "/usr/bin/gemini"
auth = MagicMock()
auth.returncode = 41
auth.stdout = json.dumps(
{
"error": {
"type": "Error",
"message": "Please set an Auth method in your settings.json or set GEMINI_API_KEY",
"code": 41,
}
}
)
auth.stderr = ""
mock_run.side_effect = [_version_proc(), auth]
with patch.dict(os.environ, {"GEMINI_API_KEY": ""}, clear=False):
probe = GeminiCLIAdapter().detect()
assert probe.installed is True
assert probe.logged_in is False
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
@patch("integrations.llm_cli.binary_resolver.shutil.which")
def test_detect_not_authenticated_uses_api_key_fallback(
mock_which: MagicMock, mock_run: MagicMock
) -> None:
mock_which.return_value = "/usr/bin/gemini"
auth = MagicMock()
auth.returncode = 1
auth.stdout = ""
auth.stderr = "Authentication required"
mock_run.side_effect = [_version_proc(), auth]
with patch.dict(os.environ, {"GEMINI_API_KEY": "gk-test"}, clear=False):
probe = GeminiCLIAdapter().detect()
assert probe.installed is True
assert probe.logged_in is True
assert "GEMINI_API_KEY fallback" in probe.detail
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
@patch("integrations.llm_cli.binary_resolver.shutil.which")
def test_detect_unclear_auth(mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_which.return_value = "/usr/bin/gemini"
auth = MagicMock()
auth.returncode = 2
auth.stdout = ""
auth.stderr = "network unreachable"
mock_run.side_effect = [_version_proc(), auth]
with patch.dict(os.environ, {"GEMINI_API_KEY": ""}, clear=False):
probe = GeminiCLIAdapter().detect()
assert probe.installed is True
assert probe.logged_in is None
assert "Network error" in probe.detail
# Ambiguous auth (network blip, timeout) still surfaces the sunset note —
# the user's CLI is presumed operational; they need the migration warning.
assert "2026-06-18" in probe.detail
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
@patch("integrations.llm_cli.binary_resolver.shutil.which")
def test_detect_version_command_fails(_mock_which: MagicMock, mock_run: MagicMock) -> None:
_mock_which.return_value = "/usr/bin/gemini"
m = MagicMock()
m.returncode = 1
m.stdout = ""
m.stderr = "some error\n"
mock_run.return_value = m
probe = GeminiCLIAdapter().detect()
assert probe.installed is False
assert probe.logged_in is None
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
@patch("integrations.llm_cli.binary_resolver.shutil.which")
def test_detect_version_timeout_expired(_mock_which: MagicMock, mock_run: MagicMock) -> None:
import subprocess
_mock_which.return_value = "/usr/bin/gemini"
mock_run.side_effect = subprocess.TimeoutExpired(
cmd=["/usr/bin/gemini", "--version"], timeout=_PROBE_TIMEOUT_SEC
)
probe = GeminiCLIAdapter().detect()
assert probe.installed is False
assert probe.logged_in is None
assert probe.bin_path is None
assert "could not run" in probe.detail.lower()
@patch("integrations.llm_cli.gemini_cli._fallback_gemini_cli_paths", return_value=[])
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value=None)
def test_detect_not_installed(_mock_which: MagicMock, _mock_fallback: MagicMock) -> None:
probe = GeminiCLIAdapter().detect()
assert probe.installed is False
assert probe.logged_in is None
assert probe.bin_path is None
assert "not found" in probe.detail.lower()
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/gemini")
def test_build_basic_invocation(_mock_which: MagicMock) -> None:
inv = GeminiCLIAdapter().build(prompt="explain this alert", model=None, workspace="")
assert inv.argv[0] == "/usr/bin/gemini"
assert "-p" in inv.argv
assert "--output-format" in inv.argv
assert "json" in inv.argv
assert inv.stdin is None
assert inv.timeout_sec == 300.0
def test_resolve_exec_timeout_default() -> None:
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("GEMINI_CLI_TIMEOUT_SECONDS", None)
assert _resolve_exec_timeout_seconds() == 300.0
def test_resolve_exec_timeout_clamps_low_and_high() -> None:
with patch.dict(os.environ, {"GEMINI_CLI_TIMEOUT_SECONDS": "5"}, clear=False):
assert _resolve_exec_timeout_seconds() == 30.0
with patch.dict(os.environ, {"GEMINI_CLI_TIMEOUT_SECONDS": "9999"}, clear=False):
assert _resolve_exec_timeout_seconds() == 600.0
def test_resolve_exec_timeout_uses_valid_value() -> None:
with patch.dict(os.environ, {"GEMINI_CLI_TIMEOUT_SECONDS": "240"}, clear=False):
assert _resolve_exec_timeout_seconds() == 240.0
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/gemini")
def test_build_uses_timeout_override(_mock_which: MagicMock) -> None:
with patch.dict(os.environ, {"GEMINI_CLI_TIMEOUT_SECONDS": "180"}, clear=False):
inv = GeminiCLIAdapter().build(prompt="p", model=None, workspace="")
assert inv.timeout_sec == 180.0
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/gemini")
def test_build_adds_model_flag(_mock_which: MagicMock) -> None:
inv = GeminiCLIAdapter().build(prompt="p", model="gemini-2.5-pro", workspace="")
assert "--model" in inv.argv
idx = inv.argv.index("--model")
assert inv.argv[idx + 1] == "gemini-2.5-pro"
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/gemini")
def test_build_forwards_gemini_google_env(_mock_which: MagicMock) -> None:
with patch.dict(
os.environ,
{
"GEMINI_API_KEY": "gk-test",
"GOOGLE_CLOUD_PROJECT": "proj-x",
"GOOGLE_CLOUD_LOCATION": "us-central1",
},
clear=False,
):
inv = GeminiCLIAdapter().build(prompt="p", model=None, workspace="")
assert inv.env is not None
assert inv.env["GEMINI_API_KEY"] == "gk-test"
assert inv.env["GOOGLE_CLOUD_PROJECT"] == "proj-x"
def test_parse_json_response() -> None:
adapter = GeminiCLIAdapter()
result = adapter.parse(
stdout=json.dumps({"response": " hello world "}),
stderr="",
returncode=0,
)
assert result == "hello world"
def test_parse_non_json_falls_back_to_stdout() -> None:
adapter = GeminiCLIAdapter()
result = adapter.parse(stdout="plain text answer", stderr="", returncode=0)
assert result == "plain text answer"
def test_parse_raises_runtime_error_on_error_payload() -> None:
adapter = GeminiCLIAdapter()
with pytest.raises(RuntimeError, match="token expired"):
adapter.parse(
stdout=json.dumps({"error": {"message": "token expired"}}),
stderr="",
returncode=0,
)
def test_explain_failure_includes_returncode_and_stderr() -> None:
adapter = GeminiCLIAdapter()
msg = adapter.explain_failure(stdout="", stderr="auth error", returncode=1)
assert "1" in msg
assert "auth error" in msg
def test_fallback_paths_macos() -> None:
npm_prefix_bin_dirs.cache_clear()
with (
patch("integrations.llm_cli.binary_resolver.sys.platform", "darwin"),
patch.dict(os.environ, {}, clear=False),
):
paths = _fallback_gemini_cli_paths()
normalized = _posix_path_set(paths)
assert "/opt/homebrew/bin/gemini" in normalized
assert "/usr/local/bin/gemini" in normalized
def test_fallback_paths_windows() -> None:
npm_prefix_bin_dirs.cache_clear()
with (
patch("integrations.llm_cli.binary_resolver.sys.platform", "win32"),
patch.dict(
os.environ,
{
"APPDATA": r"C:\Users\me\AppData\Roaming",
"LOCALAPPDATA": r"C:\Users\me\AppData\Local",
},
clear=False,
),
):
paths = _fallback_gemini_cli_paths()
normalized = {p.replace("\\", "/") for p in paths}
assert "C:/Users/me/AppData/Roaming/npm/gemini.cmd" in normalized
def test_gemini_cli_registry_entry() -> None:
from integrations.llm_cli.registry import get_cli_provider_registration
reg = get_cli_provider_registration("gemini-cli")
assert reg is not None
assert reg.model_env_key == "GEMINI_CLI_MODEL"
assert reg.adapter_factory().name == "gemini-cli"
def test_gemini_google_prefix_forwarded_to_subprocess() -> None:
with patch.dict(
os.environ,
{
"GEMINI_CLI_MODEL": "gemini-2.5-pro",
"GEMINI_CLI_BIN": "/usr/bin/gemini",
"GOOGLE_CLOUD_PROJECT": "proj-x",
},
clear=False,
):
env = build_cli_subprocess_env(None)
assert env["GEMINI_CLI_MODEL"] == "gemini-2.5-pro"
assert env["GEMINI_CLI_BIN"] == "/usr/bin/gemini"
assert env["GOOGLE_CLOUD_PROJECT"] == "proj-x"
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
@patch("integrations.llm_cli.binary_resolver.shutil.which")
def test_detect_auth_probe_uses_filtered_subprocess_env(
mock_which: MagicMock, mock_run: MagicMock
) -> None:
mock_which.return_value = "/usr/bin/gemini"
mock_run.side_effect = [_version_proc(), _auth_ok_proc()]
with patch.dict(
os.environ,
{
"PATH": "/usr/bin",
"RANDOM_SECRET": "must-not-leak",
"GOOGLE_CLOUD_PROJECT": "proj-x",
"GEMINI_API_KEY": "gk-test",
},
clear=False,
):
GeminiCLIAdapter().detect()
env = mock_run.call_args_list[1].kwargs["env"]
assert env["PATH"] == "/usr/bin"
assert env["GOOGLE_CLOUD_PROJECT"] == "proj-x"
assert env["GEMINI_API_KEY"] == "gk-test"
assert "RANDOM_SECRET" not in env
def test_parse_raises_on_empty_stdout() -> None:
adapter = GeminiCLIAdapter()
with pytest.raises(RuntimeError, match="empty output"):
adapter.parse(stdout=" ", stderr="", returncode=0)
def test_parse_raises_on_empty_stdout_surfaces_stderr() -> None:
adapter = GeminiCLIAdapter()
with pytest.raises(RuntimeError, match="some stderr detail"):
adapter.parse(stdout="", stderr="some stderr detail", returncode=0)