feat(v1.7.0): AI 友好度增强 + 测试补全 (155→309)
核心新增(面向 AI Agent 程序化使用): - 结构化错误码体系:E_CONFIG/E_AUTH/E_NETWORK/E_RATE_LIMIT/E_PARSE/E_EMPTY/E_INPUT/E_INTERNAL classify_error() 自动分类异常,JSON 错误输出含 error_code 字段 - JSON Lines 流式输出 (--stream):每条结果独立一行,AI 可增量处理 - 进度事件 (--progress):JSON Lines 事件流到 stderr(start/cache_hit/fetch_ok/done 等) 测试补全(+154 例,覆盖全部高风险盲区): - HTML 回退搜索路径 (19) - --fetch 自动抓取 (21) - --verify 健康检查 (15) - 输出格式化 (15) - 实例解析链 (20) - 并行多实例搜索 (10) - CLI 入口与端到端 (17) - 错误码分类 (27) - 流式输出与进度事件 (10) 源码改进: - search.py: h3 内 a 标签 href 作为 url fallback,提升 SearXNG 主题兼容性 - common.py: 新增 classify_error/emit_progress/set_progress_enabled 文档同步:SKILL.md 新增 AI Agent Integration Guide 章节,README.md 更新参数与错误码表
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
"""Tests for --stream (JSON Lines output) and --progress (progress events).
|
||||
|
||||
Covers: stream output format (result/done/error events), progress event
|
||||
emission (start/cache_hit/cache_store/done/error/fetch_*), progress
|
||||
disabled by default, stream only works with --format json.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
import common
|
||||
from common import emit_progress, set_progress_enabled
|
||||
import search as search_mod
|
||||
from search import _run_single_query, main
|
||||
|
||||
|
||||
# ----- emit_progress: default disabled -----
|
||||
|
||||
def test_progress_disabled_by_default(capsys):
|
||||
"""Without --progress, emit_progress is a no-op."""
|
||||
set_progress_enabled(False)
|
||||
emit_progress("start", query="test", instances=1)
|
||||
out, err = capsys.readouterr()
|
||||
assert out == ""
|
||||
assert err == ""
|
||||
|
||||
|
||||
def test_progress_enabled_emits_json(capsys):
|
||||
"""With --progress, emit_progress outputs JSON Lines to stderr."""
|
||||
set_progress_enabled(True)
|
||||
try:
|
||||
emit_progress("start", query="test", instances=2)
|
||||
_, err = capsys.readouterr()
|
||||
data = json.loads(err.strip())
|
||||
assert data["event"] == "start"
|
||||
assert data["query"] == "test"
|
||||
assert data["instances"] == 2
|
||||
finally:
|
||||
set_progress_enabled(False)
|
||||
|
||||
|
||||
def test_progress_multiple_events(capsys):
|
||||
"""Multiple events produce multiple JSON Lines."""
|
||||
set_progress_enabled(True)
|
||||
try:
|
||||
emit_progress("start", query="q", instances=1)
|
||||
emit_progress("cache_hit", query="q", ttl=30)
|
||||
emit_progress("done", results=5, query="q")
|
||||
_, err = capsys.readouterr()
|
||||
lines = [l for l in err.strip().split("\n") if l]
|
||||
assert len(lines) == 3
|
||||
events = [json.loads(l)["event"] for l in lines]
|
||||
assert events == ["start", "cache_hit", "done"]
|
||||
finally:
|
||||
set_progress_enabled(False)
|
||||
|
||||
|
||||
def test_progress_event_with_error_code(capsys):
|
||||
"""Error events include error_code field."""
|
||||
set_progress_enabled(True)
|
||||
try:
|
||||
emit_progress("error", error="timeout", error_code="E_NETWORK",
|
||||
query="test")
|
||||
_, err = capsys.readouterr()
|
||||
data = json.loads(err.strip())
|
||||
assert data["error_code"] == "E_NETWORK"
|
||||
finally:
|
||||
set_progress_enabled(False)
|
||||
|
||||
|
||||
# ----- _run_single_query: progress events -----
|
||||
|
||||
def _make_args(**overrides):
|
||||
"""Construct a minimal args object for _run_single_query."""
|
||||
defaults = dict(
|
||||
query="test", format="json", method="GET", timeout=15, retry=0,
|
||||
serial=False, no_dedup=False, sort_by="none", max_results=None,
|
||||
include_domain=None, exclude_domain=None, fetch=0, fetch_timeout=10,
|
||||
fetch_retries=3, max_size=None, cache_ttl=0, snippet_len=0,
|
||||
categories=None, language=None, pageno=1, time_range="year",
|
||||
safesearch=0, engines="google,bing",
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def test_run_single_query_emits_start_and_done(capsys):
|
||||
"""_run_single_query emits start and done events when progress is enabled."""
|
||||
set_progress_enabled(True)
|
||||
try:
|
||||
mock_results = {"results": [{"title": "t", "url": "https://x.com"}]}
|
||||
with patch.object(search_mod, "search_multi", return_value=mock_results):
|
||||
_run_single_query("test", _make_args(),
|
||||
["https://x.example.com"], {}, 0)
|
||||
_, err = capsys.readouterr()
|
||||
lines = [json.loads(l) for l in err.strip().split("\n") if l]
|
||||
events = [e["event"] for e in lines]
|
||||
assert "start" in events
|
||||
assert "done" in events
|
||||
done_event = next(e for e in lines if e["event"] == "done")
|
||||
assert done_event["results"] == 1
|
||||
finally:
|
||||
set_progress_enabled(False)
|
||||
|
||||
|
||||
def test_run_single_query_emits_cache_hit(capsys, isolated_cache):
|
||||
"""Cache hit emits cache_hit event."""
|
||||
set_progress_enabled(True)
|
||||
try:
|
||||
mock_results = {"results": [{"title": "t", "url": "https://x.com"}]}
|
||||
with patch.object(search_mod, "search_multi", return_value=mock_results):
|
||||
# First call: cache miss, stores result
|
||||
_run_single_query("test", _make_args(cache_ttl=30),
|
||||
["https://x.example.com"], {}, 1800)
|
||||
capsys.readouterr() # clear
|
||||
# Second call: cache hit
|
||||
_run_single_query("test", _make_args(cache_ttl=30),
|
||||
["https://x.example.com"], {}, 1800)
|
||||
_, err = capsys.readouterr()
|
||||
lines = [json.loads(l) for l in err.strip().split("\n") if l]
|
||||
events = [e["event"] for e in lines]
|
||||
assert "cache_hit" in events
|
||||
finally:
|
||||
set_progress_enabled(False)
|
||||
|
||||
|
||||
def test_run_single_query_emits_error_on_failure(capsys):
|
||||
"""Search failure emits error event with error_code."""
|
||||
set_progress_enabled(True)
|
||||
try:
|
||||
import urllib.error
|
||||
err = urllib.error.HTTPError("url", 403, "Forbidden", {}, None)
|
||||
with patch.object(search_mod, "search_multi", side_effect=err):
|
||||
_run_single_query("test", _make_args(),
|
||||
["https://x.example.com"], {}, 0)
|
||||
_, err_out = capsys.readouterr()
|
||||
lines = [json.loads(l) for l in err_out.strip().split("\n") if l]
|
||||
error_events = [e for e in lines if e["event"] == "error"]
|
||||
assert len(error_events) == 1
|
||||
assert error_events[0]["error_code"] == "E_AUTH"
|
||||
finally:
|
||||
set_progress_enabled(False)
|
||||
|
||||
|
||||
# ----- --stream: JSON Lines output -----
|
||||
|
||||
def test_stream_outputs_json_lines(capsys):
|
||||
"""--stream outputs each result as a JSON Line + a done event."""
|
||||
mock_results = {"results": [
|
||||
{"title": "first", "url": "https://a.com"},
|
||||
{"title": "second", "url": "https://b.com"},
|
||||
]}
|
||||
with patch.object(search_mod, "search_multi", return_value=mock_results):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main.__wrapped__ if hasattr(main, "__wrapped__") else None
|
||||
# Call main with --stream
|
||||
with patch.object(sys, "argv", ["search.py", "-q", "test",
|
||||
"-i", "https://x.example.com",
|
||||
"--stream", "--format", "json"]):
|
||||
# Mock the early argparse for logging
|
||||
with patch.object(search_mod, "setup_logging"):
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
out, _ = capsys.readouterr()
|
||||
lines = [json.loads(l) for l in out.strip().split("\n") if l]
|
||||
# Should have 2 result events + 1 done event
|
||||
result_events = [e for e in lines if e["type"] == "result"]
|
||||
done_events = [e for e in lines if e["type"] == "done"]
|
||||
assert len(result_events) == 2
|
||||
assert len(done_events) == 1
|
||||
assert done_events[0]["count"] == 2
|
||||
assert result_events[0]["result"]["title"] == "first"
|
||||
|
||||
|
||||
def test_stream_empty_results_exit_2(capsys):
|
||||
"""--stream with empty results exits with code 2 and emits done with count=0."""
|
||||
mock_results = {"results": []}
|
||||
with patch.object(search_mod, "search_multi", return_value=mock_results):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with patch.object(sys, "argv", ["search.py", "-q", "test",
|
||||
"-i", "https://x.example.com",
|
||||
"--stream", "--format", "json"]):
|
||||
with patch.object(search_mod, "setup_logging"):
|
||||
main()
|
||||
assert exc_info.value.code == 2
|
||||
out, _ = capsys.readouterr()
|
||||
lines = [json.loads(l) for l in out.strip().split("\n") if l]
|
||||
done_events = [e for e in lines if e["type"] == "done"]
|
||||
assert len(done_events) == 1
|
||||
assert done_events[0]["count"] == 0
|
||||
|
||||
|
||||
def test_stream_result_event_shape():
|
||||
"""Each result event has type=result and result=<result dict>."""
|
||||
# Unit test the stream output logic directly
|
||||
results = [{"title": "t", "url": "https://x.com"}]
|
||||
lines = []
|
||||
for r in results:
|
||||
lines.append(json.dumps({"type": "result", "result": r}))
|
||||
lines.append(json.dumps({"type": "done", "count": len(results)}))
|
||||
parsed = [json.loads(l) for l in lines]
|
||||
assert parsed[0]["type"] == "result"
|
||||
assert parsed[0]["result"]["url"] == "https://x.com"
|
||||
assert parsed[-1]["type"] == "done"
|
||||
assert parsed[-1]["count"] == 1
|
||||
Reference in New Issue
Block a user