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
420 lines
15 KiB
Python
420 lines
15 KiB
Python
"""Tests for SentrySearchIssuesTool (function-based, @tool decorated)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from integrations.sentry import (
|
|
_MAX_SENTRY_QUERY_LEN,
|
|
SentryConfig,
|
|
_sanitize_sentry_query,
|
|
_sentry_query_candidates,
|
|
describe_sentry_api_error,
|
|
list_sentry_issues,
|
|
)
|
|
from integrations.sentry.tools.sentry_search_issues_tool import search_sentry_issues
|
|
from tests.tools.conftest import BaseToolContract, mock_agent_state
|
|
|
|
|
|
class TestSentrySearchIssuesToolContract(BaseToolContract):
|
|
def get_tool_under_test(self):
|
|
return search_sentry_issues.__opensre_registered_tool__
|
|
|
|
|
|
def test_is_available_requires_connection_verified() -> None:
|
|
rt = search_sentry_issues.__opensre_registered_tool__
|
|
assert rt.is_available({"sentry": {"connection_verified": True}}) is True
|
|
assert rt.is_available({"sentry": {}}) is False
|
|
assert rt.is_available({}) is False
|
|
|
|
|
|
def test_extract_params_maps_fields() -> None:
|
|
rt = search_sentry_issues.__opensre_registered_tool__
|
|
sources = mock_agent_state()
|
|
params = rt.extract_params(sources)
|
|
assert params["organization_slug"] == "my-org"
|
|
assert params["sentry_token"] == "sntryu_test"
|
|
|
|
|
|
def test_extract_params_maps_resolved_config_dump_shape() -> None:
|
|
"""Regression: the resolved ``sentry`` source dict is a ``SentryConfig`` dump,
|
|
so its keys are ``auth_token`` / ``base_url`` — NOT ``sentry_token`` /
|
|
``sentry_url``. Hard-indexing ``sentry['sentry_token']`` raised a KeyError
|
|
that aborted every Sentry query in the gather/investigation loop."""
|
|
from integrations.sentry import SentryConfig
|
|
from integrations.sentry.tools.sentry_search_issues_tool import _search_issues_extract_params
|
|
|
|
sources = {
|
|
"sentry": SentryConfig(
|
|
base_url="https://sentry.example.com",
|
|
organization_slug="acme",
|
|
auth_token="sntryu_resolved",
|
|
).model_dump()
|
|
| {"connection_verified": True}
|
|
}
|
|
|
|
params = _search_issues_extract_params(sources)
|
|
|
|
assert params["organization_slug"] == "acme"
|
|
assert params["sentry_token"] == "sntryu_resolved"
|
|
assert params["sentry_url"] == "https://sentry.example.com"
|
|
|
|
|
|
def test_run_returns_unavailable_when_no_config() -> None:
|
|
# Stub env resolution so the test is hermetic: without this, a local .env
|
|
# carrying real SENTRY_* creds makes _resolve_config fall back to them and
|
|
# the tool reports available=True.
|
|
with patch(
|
|
"integrations.sentry.tools.sentry_search_issues_tool.sentry_config_from_env",
|
|
return_value=None,
|
|
):
|
|
result = search_sentry_issues(organization_slug="", sentry_token="")
|
|
assert result["available"] is False
|
|
assert result["issues"] == []
|
|
|
|
|
|
def test_run_happy_path() -> None:
|
|
fake_issues = [{"id": "1", "title": "TypeError", "status": "unresolved"}]
|
|
with (
|
|
patch(
|
|
"integrations.sentry.tools.sentry_search_issues_tool.list_sentry_issues",
|
|
return_value=fake_issues,
|
|
),
|
|
patch(
|
|
"integrations.sentry.tools.sentry_search_issues_tool.sentry_config_from_env",
|
|
return_value=None,
|
|
),
|
|
):
|
|
result = search_sentry_issues(
|
|
organization_slug="my-org",
|
|
sentry_token="tok_test",
|
|
query="TypeError",
|
|
)
|
|
assert result["available"] is True
|
|
assert len(result["issues"]) == 1
|
|
assert result["query"] == "TypeError"
|
|
|
|
|
|
def test_run_empty_issues() -> None:
|
|
with (
|
|
patch(
|
|
"integrations.sentry.tools.sentry_search_issues_tool.list_sentry_issues",
|
|
return_value=[],
|
|
),
|
|
patch(
|
|
"integrations.sentry.tools.sentry_search_issues_tool.sentry_config_from_env",
|
|
return_value=None,
|
|
),
|
|
):
|
|
result = search_sentry_issues(organization_slug="my-org", sentry_token="tok_test")
|
|
assert result["available"] is True
|
|
assert result["issues"] == []
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_live_env_config_searches_sentry_windows_issues() -> None:
|
|
if not os.getenv("SENTRY_ORG_SLUG") or not os.getenv("SENTRY_AUTH_TOKEN"):
|
|
pytest.skip("SENTRY_ORG_SLUG and SENTRY_AUTH_TOKEN are required for live Sentry search")
|
|
|
|
result = search_sentry_issues(
|
|
organization_slug="",
|
|
sentry_token="",
|
|
query="windows",
|
|
limit=5,
|
|
)
|
|
|
|
assert result["available"] is True
|
|
assert result["source"] == "sentry"
|
|
assert result["query"] == "windows"
|
|
assert isinstance(result["issues"], list)
|
|
|
|
|
|
# --- _sanitize_sentry_query tests ---
|
|
|
|
|
|
def test_sanitize_sentry_query_plain_term() -> None:
|
|
assert _sanitize_sentry_query("TypeError") == "TypeError"
|
|
|
|
|
|
def test_sanitize_sentry_query_multiline_takes_first_line() -> None:
|
|
raw = "TypeError: Cannot read\n at foo (bar.ts:1)\n at baz (qux.ts:2)"
|
|
result = _sanitize_sentry_query(raw)
|
|
assert "\n" not in result
|
|
assert result == "TypeError: Cannot read"
|
|
|
|
|
|
def test_sanitize_sentry_query_truncates_long_query() -> None:
|
|
long = "a" * (_MAX_SENTRY_QUERY_LEN + 50)
|
|
result = _sanitize_sentry_query(long)
|
|
assert len(result) == _MAX_SENTRY_QUERY_LEN
|
|
|
|
|
|
def test_sanitize_sentry_query_strips_whitespace() -> None:
|
|
assert _sanitize_sentry_query(" hello world ") == "hello world"
|
|
|
|
|
|
def test_sanitize_sentry_query_empty_string() -> None:
|
|
assert _sanitize_sentry_query("") == ""
|
|
|
|
|
|
def test_sanitize_sentry_query_or_takes_first_alternative() -> None:
|
|
raw = '"database connection" OR "too many connections" OR "connection refused"'
|
|
assert _sanitize_sentry_query(raw) == '"database connection"'
|
|
|
|
|
|
def test_sentry_query_candidates_splits_or() -> None:
|
|
raw = '"database connection" OR "too many connections" OR "connection refused"'
|
|
assert _sentry_query_candidates(raw) == [
|
|
'"database connection"',
|
|
'"too many connections"',
|
|
'"connection refused"',
|
|
]
|
|
|
|
|
|
def test_run_returns_structured_error_on_http_400() -> None:
|
|
request = httpx.Request("GET", "https://sentry.io/api/0/organizations/my-org/issues/")
|
|
response = httpx.Response(
|
|
400,
|
|
request=request,
|
|
json={"detail": "Invalid query."},
|
|
)
|
|
err = httpx.HTTPStatusError("bad request", request=request, response=response)
|
|
|
|
with (
|
|
patch(
|
|
"integrations.sentry.tools.sentry_search_issues_tool.list_sentry_issues",
|
|
side_effect=err,
|
|
),
|
|
patch(
|
|
"integrations.sentry.tools.sentry_search_issues_tool.sentry_config_from_env",
|
|
return_value=None,
|
|
),
|
|
):
|
|
result = search_sentry_issues(
|
|
organization_slug="my-org",
|
|
sentry_token="tok_test",
|
|
query='"database connection" OR "too many connections"',
|
|
project_slug="python",
|
|
)
|
|
|
|
assert result["available"] is False
|
|
assert result["issues"] == []
|
|
assert "HTTP 400" in result["error"]
|
|
assert "Invalid query." in result["error"]
|
|
assert "does not support OR" in result["error"]
|
|
assert "project slug 'python'" in result["error"]
|
|
|
|
|
|
def test_list_sentry_issues_retries_or_segments_on_400() -> None:
|
|
config = SentryConfig(organization_slug="my-org", auth_token="tok", project_slug="python")
|
|
query = '"database connection" OR "too many connections"'
|
|
calls: list[str] = []
|
|
|
|
def _fake_request_json(
|
|
_config: SentryConfig,
|
|
_method: str,
|
|
_path: str,
|
|
*,
|
|
params: list[tuple[str, str | int | float | bool | None]] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
assert params is not None
|
|
calls.append(str(dict(params)["query"]))
|
|
if len(calls) == 1:
|
|
request = httpx.Request("GET", "https://sentry.io/api/0/organizations/my-org/issues/")
|
|
response = httpx.Response(400, request=request, json={"detail": "Invalid query."})
|
|
raise httpx.HTTPStatusError("bad request", request=request, response=response)
|
|
return [{"id": "1", "title": "too many connections"}]
|
|
|
|
with patch("integrations.sentry._request_json", side_effect=_fake_request_json):
|
|
issues = list_sentry_issues(config=config, query=query)
|
|
|
|
assert calls == ['"database connection"', '"too many connections"']
|
|
assert len(issues) == 1
|
|
|
|
|
|
def test_describe_sentry_api_error_includes_json_detail() -> None:
|
|
request = httpx.Request("GET", "https://sentry.io/api/0/organizations/my-org/issues/")
|
|
response = httpx.Response(400, request=request, json={"detail": "Invalid search query."})
|
|
err = httpx.HTTPStatusError("bad request", request=request, response=response)
|
|
|
|
message = describe_sentry_api_error(err, query="windows", project_slug="backend")
|
|
|
|
assert "HTTP 400" in message
|
|
assert "Invalid search query." in message
|
|
assert "project slug 'backend'" in message
|
|
|
|
|
|
def test_build_issue_list_params_sanitizes_multiline_query() -> None:
|
|
"""_build_issue_list_params must collapse multi-line stack traces so the
|
|
Sentry API does not return a 400 Bad Request."""
|
|
from integrations.sentry import SentryConfig, _build_issue_list_params
|
|
|
|
config = SentryConfig(organization_slug="my-org", auth_token="tok")
|
|
multiline_query = "TypeError: Cannot read props\n at src/foo.ts:10"
|
|
params = dict(_build_issue_list_params(config, limit=10, query=multiline_query))
|
|
assert "\n" not in str(params["query"])
|
|
assert params["query"] == "TypeError: Cannot read props"
|
|
|
|
|
|
# --- limit / statsPeriod resolution tests (under-retrieval fix) ---
|
|
|
|
|
|
def test_clamp_issue_limit_caps_at_sentry_page_size() -> None:
|
|
from integrations.sentry import _MAX_SENTRY_PAGE_SIZE, _clamp_issue_limit
|
|
|
|
assert _clamp_issue_limit(1000) == _MAX_SENTRY_PAGE_SIZE
|
|
assert _clamp_issue_limit(_MAX_SENTRY_PAGE_SIZE) == _MAX_SENTRY_PAGE_SIZE
|
|
|
|
|
|
def test_clamp_issue_limit_floors_at_one() -> None:
|
|
from integrations.sentry import _clamp_issue_limit
|
|
|
|
assert _clamp_issue_limit(0) == 1
|
|
assert _clamp_issue_limit(-5) == 1
|
|
|
|
|
|
def test_clamp_issue_limit_defaults_on_bad_input() -> None:
|
|
from integrations.sentry import DEFAULT_SENTRY_ISSUE_LIMIT, _clamp_issue_limit
|
|
|
|
assert _clamp_issue_limit(None) == DEFAULT_SENTRY_ISSUE_LIMIT
|
|
assert _clamp_issue_limit("not-an-int") == DEFAULT_SENTRY_ISSUE_LIMIT # type: ignore[arg-type]
|
|
|
|
|
|
def test_default_issue_limit_is_full_sentry_page() -> None:
|
|
"""Regression: a tiny default limit was why searches "only found one issue"."""
|
|
from integrations.sentry import _MAX_SENTRY_PAGE_SIZE, DEFAULT_SENTRY_ISSUE_LIMIT
|
|
|
|
assert DEFAULT_SENTRY_ISSUE_LIMIT == _MAX_SENTRY_PAGE_SIZE
|
|
|
|
|
|
def test_build_issue_list_params_clamps_limit_into_page_range() -> None:
|
|
from integrations.sentry import (
|
|
_MAX_SENTRY_PAGE_SIZE,
|
|
SentryConfig,
|
|
_build_issue_list_params,
|
|
)
|
|
|
|
config = SentryConfig(organization_slug="my-org", auth_token="tok")
|
|
params = dict(_build_issue_list_params(config, limit=5000, query=""))
|
|
assert params["limit"] == str(_MAX_SENTRY_PAGE_SIZE)
|
|
|
|
|
|
def test_build_issue_list_params_uses_default_stats_period() -> None:
|
|
from integrations.sentry import (
|
|
DEFAULT_SENTRY_STATS_PERIOD,
|
|
SentryConfig,
|
|
_build_issue_list_params,
|
|
)
|
|
|
|
config = SentryConfig(organization_slug="my-org", auth_token="tok")
|
|
params = dict(_build_issue_list_params(config, limit=10, query=""))
|
|
assert params["statsPeriod"] == DEFAULT_SENTRY_STATS_PERIOD
|
|
|
|
|
|
def test_build_issue_list_params_explicit_stats_period_wins(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from integrations.sentry import SentryConfig, _build_issue_list_params
|
|
|
|
monkeypatch.setenv("SENTRY_STATS_PERIOD", "7d")
|
|
config = SentryConfig(organization_slug="my-org", auth_token="tok")
|
|
params = dict(_build_issue_list_params(config, limit=10, query="", stats_period="14d"))
|
|
assert params["statsPeriod"] == "14d"
|
|
|
|
|
|
def test_build_issue_list_params_stats_period_from_env(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from integrations.sentry import SentryConfig, _build_issue_list_params
|
|
|
|
monkeypatch.setenv("SENTRY_STATS_PERIOD", "30d")
|
|
config = SentryConfig(organization_slug="my-org", auth_token="tok")
|
|
params = dict(_build_issue_list_params(config, limit=10, query=""))
|
|
assert params["statsPeriod"] == "30d"
|
|
|
|
|
|
def test_validate_sentry_config_reports_recent_issue_count() -> None:
|
|
"""Verify reports a meaningful recent issue count over the 7-day window."""
|
|
from integrations.sentry import SentryConfig, validate_sentry_config
|
|
|
|
config = SentryConfig(organization_slug="my-org", auth_token="tok")
|
|
captured: dict[str, object] = {}
|
|
|
|
def _fake_list(**kwargs: object) -> list[dict]:
|
|
captured.update(kwargs)
|
|
return [{"id": str(i)} for i in range(30)]
|
|
|
|
with patch("integrations.sentry.list_sentry_issues", side_effect=_fake_list):
|
|
result = validate_sentry_config(config)
|
|
|
|
assert result.ok is True
|
|
assert result.issue_count == 30
|
|
assert "30 issue(s) in the last 7 days" in result.detail
|
|
assert captured["stats_period"] == "7d"
|
|
|
|
|
|
def test_validate_sentry_config_saturated_count_uses_plus() -> None:
|
|
"""When the page saturates, the count is shown as ``N+`` (honest about
|
|
the page-size cap rather than implying an exact total)."""
|
|
from integrations.sentry import (
|
|
_MAX_SENTRY_PAGE_SIZE,
|
|
SentryConfig,
|
|
validate_sentry_config,
|
|
)
|
|
|
|
config = SentryConfig(organization_slug="my-org", auth_token="tok")
|
|
with patch(
|
|
"integrations.sentry.list_sentry_issues",
|
|
return_value=[{"id": str(i)} for i in range(_MAX_SENTRY_PAGE_SIZE)],
|
|
):
|
|
result = validate_sentry_config(config)
|
|
|
|
assert f"{_MAX_SENTRY_PAGE_SIZE}+ issue(s)" in result.detail
|
|
|
|
|
|
def test_search_tool_default_limit_is_full_page() -> None:
|
|
from integrations.sentry import DEFAULT_SENTRY_ISSUE_LIMIT
|
|
from integrations.sentry.tools.sentry_search_issues_tool import _search_issues_extract_params
|
|
|
|
sources = {
|
|
"sentry": {
|
|
"organization_slug": "my-org",
|
|
"sentry_token": "tok",
|
|
}
|
|
}
|
|
params = _search_issues_extract_params(sources)
|
|
assert params["limit"] == DEFAULT_SENTRY_ISSUE_LIMIT
|
|
|
|
|
|
def test_search_tool_forwards_limit_and_period_to_client() -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
def _fake_list(**kwargs: object) -> list[dict]:
|
|
captured.update(kwargs)
|
|
return []
|
|
|
|
with (
|
|
patch(
|
|
"integrations.sentry.tools.sentry_search_issues_tool.list_sentry_issues",
|
|
side_effect=_fake_list,
|
|
),
|
|
patch(
|
|
"integrations.sentry.tools.sentry_search_issues_tool.sentry_config_from_env",
|
|
return_value=None,
|
|
),
|
|
):
|
|
search_sentry_issues(
|
|
organization_slug="my-org",
|
|
sentry_token="tok",
|
|
limit=50,
|
|
stats_period="14d",
|
|
)
|
|
|
|
assert captured["limit"] == 50
|
|
assert captured["stats_period"] == "14d"
|