feat(v1.8.0): 稳定性修复 + AI Agent 体验增强
稳定性修复: - 修复 cache.py SQLite 连接泄漏(contextlib.closing 包装) - 修复 fetch.py requests stream=True 连接泄漏(try/finally resp.close()) - RETRYABLE_STATUS 新增 403,激活 UA fallback 切换逻辑 - --cache-stats 移至实例解析前,无需实例即可查询 - classify_error 从错误消息提取 HTTP 状态码,正确分类 E_AUTH/E_RATE_LIMIT - --stream 与 --queries-file 互斥检查,违规报 E_INPUT - batch 退出码语义统一(0=有结果 / 1=全部错误 / 2=全部空结果) AI Agent 体验增强: - 错误码体系完善:E_CONFIG/E_AUTH/E_NETWORK/E_RATE_LIMIT/E_PARSE/E_EMPTY/E_INPUT/E_INTERNAL - recovery_hint 恢复提示字段,AI Agent 可程序化决策恢复策略 - stream 模式新增 error 事件类型(含 error_code + recovery_hint) - 进度事件扩展:instance_try/instance_ok/instance_fail - batch 模式统一 schema(status 字段区分 success/failed) - JSON 输出含 schema_version 字段确保版本兼容 测试与文档: - 测试覆盖:330 -> 352 - SKILL.md / README.md 同步更新
This commit is contained in:
+116
-2
@@ -23,7 +23,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from search import _run_single_query, _emit_error, _build_params
|
||||
from search import _run_single_query, _emit_error, _build_params, _format_results
|
||||
import cache as cache_module
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -256,7 +256,7 @@ def test_cli_version_prints_version():
|
||||
"""`--version` exits 0 and prints the version string."""
|
||||
r = _run_cli("--version")
|
||||
assert r.returncode == 0
|
||||
assert "1.7.0" in r.stdout
|
||||
assert "1.8.0" in r.stdout
|
||||
assert "searxng-cli" in r.stdout
|
||||
|
||||
|
||||
@@ -294,3 +294,117 @@ def test_cli_cache_stats_outputs_json(tmp_path):
|
||||
data = json.loads(r.stdout)
|
||||
assert "entries" in data
|
||||
assert "path" in data
|
||||
|
||||
|
||||
# ===== --dump-schema (v1.8.0) =====
|
||||
|
||||
def test_cli_dump_schema_outputs_valid_json():
|
||||
"""`--dump-schema` prints valid JSON with schema_version/title/properties.
|
||||
|
||||
The schema dump is a non-search operation: it exits 0 and does NOT
|
||||
require --query or -i (it short-circuits before instance resolution).
|
||||
"""
|
||||
r = _run_cli("--dump-schema")
|
||||
assert r.returncode == 0
|
||||
data = json.loads(r.stdout)
|
||||
assert data["schema_version"] == "1.0"
|
||||
assert data["title"] == "SearXNG CLI Search Result"
|
||||
assert "properties" in data
|
||||
assert isinstance(data["properties"], dict)
|
||||
|
||||
|
||||
def test_cli_dump_schema_does_not_require_query():
|
||||
"""`--dump-schema` exits 0 without --query (non-search operation)."""
|
||||
r = _run_cli("--dump-schema")
|
||||
assert r.returncode == 0
|
||||
# The --query-required check must not fire for --dump-schema.
|
||||
assert "required" not in r.stderr.lower()
|
||||
|
||||
|
||||
# ===== --stream mutual exclusion (v1.8.0) =====
|
||||
|
||||
def test_cli_stream_with_queries_file_rejected(tmp_path):
|
||||
"""--stream + --queries-file is rejected with E_INPUT on stdout.
|
||||
|
||||
Batch mode emits a JSON array, not JSON Lines; the combination is
|
||||
explicitly rejected so AI agents don't silently get the wrong format.
|
||||
"""
|
||||
qf = tmp_path / "queries.txt"
|
||||
qf.write_text("query\n", encoding="utf-8")
|
||||
r = _run_cli("--stream", "--queries-file", str(qf),
|
||||
"--format", "json", "-i", "https://x.example.com")
|
||||
assert r.returncode == 1
|
||||
data = json.loads(r.stdout)
|
||||
assert data["error_code"] == "E_INPUT"
|
||||
|
||||
|
||||
def test_cli_stream_with_csv_format_rejected():
|
||||
"""--stream + --format csv is rejected with E_INPUT.
|
||||
|
||||
csv is a non-json format, so _emit_error routes the error to stderr
|
||||
(as a ``[E_INPUT]`` prefixed log line) rather than stdout JSON.
|
||||
"""
|
||||
r = _run_cli("--stream", "--format", "csv", "-q", "test",
|
||||
"-i", "https://x.example.com")
|
||||
assert r.returncode == 1
|
||||
assert "E_INPUT" in r.stderr
|
||||
|
||||
|
||||
# ===== schema_version in single-query JSON (v1.8.0) =====
|
||||
|
||||
def test_format_results_json_includes_schema_version():
|
||||
"""Single-query JSON output includes schema_version: '1.0'."""
|
||||
results = {"results": [{"title": "T", "url": "https://example.com"}]}
|
||||
args = SimpleNamespace(format="json")
|
||||
out = _format_results(results, args)
|
||||
parsed = json.loads(out)
|
||||
assert parsed["schema_version"] == "1.0"
|
||||
|
||||
|
||||
# ===== _fallback field must not leak to JSON output (v1.8.0) =====
|
||||
|
||||
def test_run_single_query_html_fallback_pops_underscore_fallback(monkeypatch):
|
||||
"""HTML-fallback's internal _fallback field is removed before output.
|
||||
|
||||
_run_single_query pops _fallback at the end so it never appears in
|
||||
the JSON payload; fetched_source (the public field) is set separately
|
||||
on the --fetch path.
|
||||
"""
|
||||
args = _make_args(format="json")
|
||||
fake = {"results": [{"url": "https://a.com/1", "title": "A"}],
|
||||
"_fallback": "html"}
|
||||
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
|
||||
|
||||
results, err, _ = _run_single_query("test", args,
|
||||
["https://x.example.com"], {}, 0)
|
||||
assert err is None
|
||||
# _fallback must be popped so it doesn't leak into JSON output
|
||||
assert "_fallback" not in results
|
||||
# And the serialized JSON also omits it
|
||||
out = _format_results(results, args)
|
||||
parsed = json.loads(out)
|
||||
assert "_fallback" not in parsed
|
||||
|
||||
|
||||
def test_run_single_query_fetch_sets_fetched_source_from_html_fallback(monkeypatch):
|
||||
"""--fetch path sets fetched_source from _fallback (or 'json').
|
||||
|
||||
When the search came via HTML fallback (_fallback='html'), the
|
||||
fetched_source field reflects that origin, while _fallback itself is
|
||||
cleaned up and never reaches JSON output.
|
||||
"""
|
||||
args = _make_args(fetch=1)
|
||||
fake = {"results": [{"url": "https://a.com/1"}], "_fallback": "html"}
|
||||
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
|
||||
monkeypatch.setattr("search.fetch_top_results", lambda *a, **kw: [
|
||||
{"url": "https://a.com/1", "status": "ok", "text": "page A",
|
||||
"text_length": 6, "truncated": False},
|
||||
])
|
||||
|
||||
results, err, _ = _run_single_query("test", args,
|
||||
["https://x.example.com"], {}, 0)
|
||||
assert err is None
|
||||
# fetched_source reflects the HTML fallback origin
|
||||
assert results["fetched_source"] == "html"
|
||||
# _fallback still cleaned up
|
||||
assert "_fallback" not in results
|
||||
|
||||
Reference in New Issue
Block a user