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

548 lines
19 KiB
Python

"""Tests for the xAI Grok Build CLI adapter (detect / build / failure / env forwarding)."""
from __future__ import annotations
import os
import subprocess
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.grok_cli import (
GrokCLIAdapter,
_classify_grok_auth_from_probe,
_fallback_grok_paths,
_has_explicit_grok_auth_env,
parse_grok_models_output,
)
from tests.integrations.llm_cli.testing_helpers import write_fake_runnable_cli_bin
_SUBPROCESS_RUN = "integrations.llm_cli.grok_cli.subprocess.run"
_WHICH = "integrations.llm_cli.binary_resolver.shutil.which"
def _posix_path_set(paths: list[str]) -> set[str]:
return {Path(p).as_posix() for p in paths}
def _version_proc(version: str = "0.1.0") -> MagicMock:
m = MagicMock()
m.returncode = 0
m.stdout = f"grok {version}\n"
m.stderr = ""
return m
def _auth_proc(returncode: int = 0, stdout: str = "ok", stderr: str = "") -> MagicMock:
m = MagicMock()
m.returncode = returncode
m.stdout = stdout
m.stderr = stderr
return m
# ---------------------------------------------------------------------------
# Auth classification from probe output
# ---------------------------------------------------------------------------
def test_classify_auth_logged_in_string() -> None:
logged_in, detail = _classify_grok_auth_from_probe(
0, "You are logged in with grok.com.\n\nDefault model: grok-build\n", ""
)
assert logged_in is True
assert "authenticated" in detail.lower()
def test_classify_auth_exit0_is_authenticated() -> None:
logged_in, detail = _classify_grok_auth_from_probe(0, "some other output", "")
assert logged_in is True
assert "authenticated" in detail.lower()
def test_classify_auth_unauthorized_string() -> None:
logged_in, detail = _classify_grok_auth_from_probe(1, "", "Error: Unauthorized")
assert logged_in is False
assert "grok login" in detail.lower() or "xai_api_key" in detail.lower()
def test_classify_auth_401_in_output() -> None:
logged_in, detail = _classify_grok_auth_from_probe(1, "", "HTTP 401 Unauthorized")
assert logged_in is False
def test_classify_auth_not_logged_in_string() -> None:
logged_in, detail = _classify_grok_auth_from_probe(1, "not logged in", "")
assert logged_in is False
def test_classify_auth_network_error_is_unclear() -> None:
logged_in, detail = _classify_grok_auth_from_probe(1, "", "network unreachable")
assert logged_in is None
assert "network" in detail.lower()
def test_classify_auth_unknown_nonzero_is_unclear() -> None:
logged_in, detail = _classify_grok_auth_from_probe(2, "", "something weird")
assert logged_in is None
assert "unclear" in detail.lower()
def test_classify_auth_api_key_invalid() -> None:
logged_in, detail = _classify_grok_auth_from_probe(1, "", "api key is invalid")
assert logged_in is False
# ---------------------------------------------------------------------------
# _has_explicit_grok_auth_env
# ---------------------------------------------------------------------------
def test_has_explicit_auth_env_with_key() -> None:
with patch.dict(os.environ, {"XAI_API_KEY": "xai-test"}, clear=False):
assert _has_explicit_grok_auth_env() == "XAI_API_KEY"
def test_has_explicit_auth_env_empty_key() -> None:
with patch.dict(os.environ, {"XAI_API_KEY": ""}, clear=False):
assert _has_explicit_grok_auth_env() is None
def test_has_explicit_auth_env_missing_key() -> None:
env = {k: v for k, v in os.environ.items() if k != "XAI_API_KEY"}
with patch.dict(os.environ, env, clear=True):
assert _has_explicit_grok_auth_env() is None
# ---------------------------------------------------------------------------
# detect()
# ---------------------------------------------------------------------------
@patch(_SUBPROCESS_RUN)
@patch(_WHICH)
def test_detect_logged_in_via_probe(mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_which.return_value = "/usr/bin/grok"
mock_run.side_effect = [_version_proc(), _auth_proc(returncode=0, stdout="ok")]
with patch.dict(os.environ, {"XAI_API_KEY": "", "GROK_CLI_BIN": ""}, clear=False):
probe = GrokCLIAdapter().detect()
assert probe.installed is True
assert probe.logged_in is True
assert probe.bin_path == "/usr/bin/grok"
assert probe.version == "0.1.0"
@patch(_SUBPROCESS_RUN)
@patch(_WHICH)
def test_detect_logged_in_via_api_key_fallback(mock_which: MagicMock, mock_run: MagicMock) -> None:
"""XAI_API_KEY promotes to authenticated even when the probe result is unclear."""
mock_which.return_value = "/usr/bin/grok"
mock_run.side_effect = [_version_proc(), _auth_proc(returncode=1, stderr="network unreachable")]
with patch.dict(os.environ, {"XAI_API_KEY": "xai-test", "GROK_CLI_BIN": ""}, clear=False):
probe = GrokCLIAdapter().detect()
assert probe.installed is True
assert probe.logged_in is True
assert "XAI_API_KEY" in probe.detail
@patch(_SUBPROCESS_RUN)
@patch(_WHICH)
def test_detect_not_authenticated(mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_which.return_value = "/usr/bin/grok"
mock_run.side_effect = [_version_proc(), _auth_proc(returncode=1, stderr="Error: Unauthorized")]
with patch.dict(os.environ, {"XAI_API_KEY": "", "GROK_CLI_BIN": ""}, clear=False):
probe = GrokCLIAdapter().detect()
assert probe.installed is True
assert probe.logged_in is False
@patch(_SUBPROCESS_RUN)
@patch(_WHICH)
def test_detect_api_key_does_not_override_explicit_false(
mock_which: MagicMock, mock_run: MagicMock
) -> None:
"""XAI_API_KEY must not promote logged_in when the probe explicitly returned False.
The key itself may be what was rejected; masking that defers the failure
to invocation time with a confusing 300s timeout instead of a clear error.
"""
mock_which.return_value = "/usr/bin/grok"
mock_run.side_effect = [_version_proc(), _auth_proc(returncode=1, stderr="Error: Unauthorized")]
with patch.dict(os.environ, {"XAI_API_KEY": "xai-invalid", "GROK_CLI_BIN": ""}, clear=False):
probe = GrokCLIAdapter().detect()
assert probe.logged_in is False
@patch(_SUBPROCESS_RUN)
@patch(_WHICH)
def test_detect_auth_unclear_on_network_error(mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_which.return_value = "/usr/bin/grok"
mock_run.side_effect = [_version_proc(), _auth_proc(returncode=1, stderr="connection refused")]
with patch.dict(os.environ, {"XAI_API_KEY": "", "GROK_CLI_BIN": ""}, clear=False):
probe = GrokCLIAdapter().detect()
assert probe.installed is True
assert probe.logged_in is None
@patch(_SUBPROCESS_RUN)
@patch(_WHICH)
def test_detect_auth_probe_timeout_is_unclear(mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_which.return_value = "/usr/bin/grok"
mock_run.side_effect = [
_version_proc(),
subprocess.TimeoutExpired(cmd=["/usr/bin/grok", "models"], timeout=10.0),
]
with patch.dict(os.environ, {"XAI_API_KEY": "", "GROK_CLI_BIN": ""}, clear=False):
probe = GrokCLIAdapter().detect()
assert probe.installed is True
assert probe.logged_in is None
assert "timed out" in probe.detail.lower()
@patch(_SUBPROCESS_RUN)
@patch(_WHICH)
def test_detect_api_key_fallback_overrides_probe_timeout(
mock_which: MagicMock, mock_run: MagicMock
) -> None:
mock_which.return_value = "/usr/bin/grok"
mock_run.side_effect = [
_version_proc(),
subprocess.TimeoutExpired(cmd=["/usr/bin/grok", "models"], timeout=10.0),
]
with patch.dict(os.environ, {"XAI_API_KEY": "xai-key", "GROK_CLI_BIN": ""}, clear=False):
probe = GrokCLIAdapter().detect()
assert probe.logged_in is True
assert "XAI_API_KEY" in probe.detail
@patch("integrations.llm_cli.grok_cli._fallback_grok_paths", return_value=[])
@patch(_WHICH, return_value=None)
def test_detect_not_installed(_mock_which: MagicMock, _mock_fallback: MagicMock) -> None:
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
probe = GrokCLIAdapter().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(_SUBPROCESS_RUN)
@patch(_WHICH)
def test_detect_version_command_fails(mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_which.return_value = "/usr/bin/grok"
m = MagicMock()
m.returncode = 1
m.stdout = ""
m.stderr = "some error\n"
mock_run.return_value = m
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
probe = GrokCLIAdapter().detect()
assert probe.installed is False
assert probe.logged_in is None
@patch(_SUBPROCESS_RUN)
@patch(_WHICH)
def test_detect_version_timeout(mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_which.return_value = "/usr/bin/grok"
mock_run.side_effect = subprocess.TimeoutExpired(
cmd=["/usr/bin/grok", "--version"], timeout=5.0
)
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
probe = GrokCLIAdapter().detect()
assert probe.installed is False
assert probe.logged_in is None
assert "--version" in probe.detail
# ---------------------------------------------------------------------------
# build()
# ---------------------------------------------------------------------------
@patch(_WHICH, return_value="/usr/bin/grok")
def test_build_basic_invocation(_mock_which: MagicMock) -> None:
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
inv = GrokCLIAdapter().build(prompt="explain this alert", model=None, workspace="")
assert inv.argv[0] == "/usr/bin/grok"
assert "-p" in inv.argv
assert "explain this alert" in inv.argv
assert "--output-format" in inv.argv
assert "plain" in inv.argv
assert "--no-auto-update" not in inv.argv
# Prompt is delivered as the -p argument, not stdin.
assert inv.stdin is None
assert inv.timeout_sec == 300.0
@patch(_WHICH, return_value="/usr/bin/grok")
def test_build_never_auto_approves(_mock_which: MagicMock) -> None:
"""OpenSRE uses Grok as a text responder; it must not auto-run Grok's own tools."""
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
inv = GrokCLIAdapter().build(prompt="p", model=None, workspace="")
assert "--always-approve" not in inv.argv
@patch(_WHICH, return_value="/usr/bin/grok")
def test_build_adds_model_flag(_mock_which: MagicMock) -> None:
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
inv = GrokCLIAdapter().build(prompt="p", model="grok-build-0.1", workspace="")
assert "-m" in inv.argv
idx = inv.argv.index("-m")
assert inv.argv[idx + 1] == "grok-build-0.1"
@patch(_WHICH, return_value="/usr/bin/grok")
def test_build_omits_model_flag_when_empty(_mock_which: MagicMock) -> None:
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
inv = GrokCLIAdapter().build(prompt="p", model="", workspace="")
assert "-m" not in inv.argv
@patch(_WHICH, return_value="/usr/bin/grok")
def test_build_uses_provided_workspace(_mock_which: MagicMock) -> None:
workspace = "/my/project"
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
inv = GrokCLIAdapter().build(prompt="p", model=None, workspace=workspace)
assert Path(inv.cwd) == Path(workspace)
@patch(_WHICH, return_value="/usr/bin/grok")
def test_build_sets_no_color_env(_mock_which: MagicMock) -> None:
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
inv = GrokCLIAdapter().build(prompt="p", model=None, workspace="")
assert inv.env is not None
assert inv.env.get("NO_COLOR") == "1"
@patch("integrations.llm_cli.grok_cli._fallback_grok_paths", return_value=[])
@patch(_WHICH, return_value=None)
def test_build_raises_when_binary_not_found(
_mock_which: MagicMock, _mock_fallback: MagicMock
) -> None:
with (
patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False),
pytest.raises(RuntimeError, match="Grok Build CLI not found"),
):
GrokCLIAdapter().build(prompt="p", model=None, workspace="")
# ---------------------------------------------------------------------------
# parse / explain_failure
# ---------------------------------------------------------------------------
def test_parse_returns_stripped_stdout() -> None:
result = GrokCLIAdapter().parse(stdout=" hello world \n", stderr="", returncode=0)
assert result == "hello world"
def test_parse_raises_on_empty_stdout() -> None:
"""Empty stdout on exit 0 must raise rather than return a silent blank response."""
with pytest.raises(RuntimeError, match="empty output"):
GrokCLIAdapter().parse(stdout="", stderr="", returncode=0)
def test_parse_raises_on_whitespace_only_stdout() -> None:
with pytest.raises(RuntimeError, match="empty output"):
GrokCLIAdapter().parse(stdout=" \n ", stderr="", returncode=0)
def test_parse_surfaces_stderr_via_explain_failure() -> None:
"""When stdout is empty, stderr is surfaced through explain_failure in the error message."""
with pytest.raises(RuntimeError) as exc_info:
GrokCLIAdapter().parse(stdout="", stderr="401 Unauthorized", returncode=1)
assert "401" in str(exc_info.value) or "auth" in str(exc_info.value).lower()
def test_explain_failure_includes_returncode_and_stderr() -> None:
msg = GrokCLIAdapter().explain_failure(stdout="", stderr="boom", returncode=1)
assert "1" in msg
assert "boom" in msg
def test_explain_failure_maps_auth_errors() -> None:
msg = GrokCLIAdapter().explain_failure(stdout="", stderr="401 Unauthorized", returncode=1)
assert "grok login" in msg.lower() or "xai_api_key" in msg.lower()
def test_explain_failure_falls_back_to_stdout() -> None:
msg = GrokCLIAdapter().explain_failure(stdout="some output", stderr="", returncode=2)
assert "some output" in msg
def test_explain_failure_maps_quota_via_shared_helper() -> None:
"""Quota/rate-limit errors get an actionable hint from the shared classifier."""
msg = GrokCLIAdapter().explain_failure(
stdout="", stderr="Error 429: rate limit exceeded", returncode=1
)
assert "quota or rate limit" in msg.lower()
def test_auth_hint_mentions_login_and_api_key() -> None:
adapter = GrokCLIAdapter()
assert "grok login" in adapter.auth_hint
assert "XAI_API_KEY" in adapter.auth_hint
# ---------------------------------------------------------------------------
# GROK_CLI_BIN env override
# ---------------------------------------------------------------------------
@patch(_SUBPROCESS_RUN)
def test_detect_uses_grok_cli_bin_env(mock_run: MagicMock, tmp_path: Path) -> None:
fake_bin = write_fake_runnable_cli_bin(tmp_path, "my-grok")
mock_run.side_effect = [_version_proc(), _auth_proc(returncode=0, stdout="ok")]
with patch.dict(
os.environ,
{"GROK_CLI_BIN": str(fake_bin), "XAI_API_KEY": ""},
clear=False,
):
probe = GrokCLIAdapter().detect()
assert probe.bin_path == str(fake_bin)
assert probe.installed is True
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
def test_grok_cli_registry_entry() -> None:
from integrations.llm_cli.registry import get_cli_provider_registration
reg = get_cli_provider_registration("grok-cli")
assert reg is not None
assert reg.model_env_key == "GROK_CLI_MODEL"
assert reg.adapter_factory().name == "grok-cli"
# ---------------------------------------------------------------------------
# Subprocess env forwarding — XAI_API_KEY must be scoped to the Grok subprocess
# ---------------------------------------------------------------------------
def test_xai_key_forwarded_via_build() -> None:
"""XAI_API_KEY is forwarded explicitly by build(), not via the blanket prefix allowlist."""
with (
patch.dict(
os.environ,
{
"XAI_API_KEY": "xai-forward-me",
"XAI_BASE_URL": "https://proxy.example.com",
"GROK_CLI_BIN": "",
},
clear=False,
),
patch(_WHICH, return_value="/usr/bin/grok"),
):
inv = GrokCLIAdapter().build(prompt="p", model=None, workspace="")
assert inv.env is not None
assert inv.env["XAI_API_KEY"] == "xai-forward-me"
assert inv.env["XAI_BASE_URL"] == "https://proxy.example.com"
def test_xai_key_not_in_blanket_subprocess_env() -> None:
"""XAI_API_KEY must NOT be forwarded via the global prefix allowlist (would leak to others)."""
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
with patch.dict(os.environ, {"XAI_API_KEY": "xai-secret"}, clear=False):
env = build_cli_subprocess_env(None)
assert "XAI_API_KEY" not in env
# ---------------------------------------------------------------------------
# Fallback paths
# ---------------------------------------------------------------------------
def test_fallback_paths_linux() -> None:
npm_prefix_bin_dirs.cache_clear()
with (
patch("integrations.llm_cli.binary_resolver.sys.platform", "linux"),
patch.dict(os.environ, {"npm_config_prefix": "/custom/npm"}, clear=False),
):
paths = _fallback_grok_paths()
normalized = _posix_path_set(paths)
assert "/custom/npm/bin/grok" in normalized
# ---------------------------------------------------------------------------
# parse_grok_models_output
# ---------------------------------------------------------------------------
def test_parse_models_output_typical() -> None:
text = (
"You are logged in with grok.com.\n\n"
"Default model: grok-build\n\n"
"Available models:\n"
" - grok-composer-2.5-fast\n"
" * grok-build (default)\n"
)
assert parse_grok_models_output(text) == ["grok-composer-2.5-fast", "grok-build"]
def test_parse_models_output_empty_string() -> None:
assert parse_grok_models_output("") == []
def test_parse_models_output_no_available_models_section() -> None:
assert parse_grok_models_output("You are logged in.\n\nDefault model: grok-build\n") == []
def test_parse_models_output_stops_at_non_list_line() -> None:
text = (
"Available models:\n"
" - grok-fast\n"
" - grok-slow\n"
"\n"
"Some trailing info that is not a model.\n"
" - grok-phantom\n"
)
# Blank line terminates the section; grok-phantom must not be included.
assert parse_grok_models_output(text) == ["grok-fast", "grok-slow"]
def test_parse_models_output_strips_parenthetical_annotation() -> None:
text = "Available models:\n * grok-build (default)\n"
assert parse_grok_models_output(text) == ["grok-build"]
def test_parse_models_output_model_id_with_parenthesis_in_name() -> None:
# A model whose ID itself contains '(' should be truncated at the first '('
# per the current split("(")[0] behaviour — this test documents that contract.
text = "Available models:\n - grok-weird(beta)\n"
assert parse_grok_models_output(text) == ["grok-weird"]
def test_parse_models_output_section_header_case_insensitive() -> None:
text = "AVAILABLE MODELS:\n - grok-x\n"
assert parse_grok_models_output(text) == ["grok-x"]