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,296 @@
|
||||
"""End-to-end tests for scripts/search.py.
|
||||
|
||||
Covers three layers of the CLI:
|
||||
* ``_run_single_query`` — full orchestration (search → dedup → sort →
|
||||
limit → domain-filter → fetch), stubbed at the ``search_multi`` /
|
||||
``fetch_top_results`` boundary so no network is touched.
|
||||
* ``_emit_error`` — error output + exit (json→stdout, others→stderr).
|
||||
* ``main()`` — real CLI via subprocess (--version / --help /
|
||||
no-args / --cache-stats).
|
||||
|
||||
``_run_single_query`` tests build a ``SimpleNamespace`` args object that
|
||||
matches the argparse.Namespace shape produced by ``main()``. Cache-related
|
||||
tests use the ``isolated_cache`` fixture from conftest.py so each test gets
|
||||
a fresh SQLite cache under a temp dir. ``main()`` tests spawn a real
|
||||
subprocess with cwd pinned to the project root.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from search import _run_single_query, _emit_error, _build_params
|
||||
import cache as cache_module
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _make_args(**overrides):
|
||||
"""Build a minimal args object matching the argparse.Namespace shape
|
||||
that ``_run_single_query`` reads. All fields _run_single_query touches
|
||||
are present with sensible defaults; tests override only what they need.
|
||||
"""
|
||||
base = 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",
|
||||
)
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
# ===== _run_single_query =====
|
||||
|
||||
def test_run_single_query_success(monkeypatch):
|
||||
"""Successful search returns (results, None)."""
|
||||
args = _make_args()
|
||||
fake = {"results": [{"url": "https://a.com/1", "title": "A"}]}
|
||||
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
|
||||
assert results is not None
|
||||
assert results["results"][0]["url"] == "https://a.com/1"
|
||||
|
||||
|
||||
def test_run_single_query_failure_returns_error(monkeypatch):
|
||||
"""When search_multi raises, returns (None, error_str)."""
|
||||
args = _make_args()
|
||||
|
||||
def boom(*a, **kw):
|
||||
raise RuntimeError("network down")
|
||||
monkeypatch.setattr("search.search_multi", boom)
|
||||
|
||||
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 0)
|
||||
assert results is None
|
||||
assert "network down" in err
|
||||
|
||||
|
||||
def test_run_single_query_cache_hit_skips_network(isolated_cache, monkeypatch):
|
||||
"""Cache hit returns the cached result without calling search_multi.
|
||||
|
||||
Pre-populates the cache with the exact params _run_single_query builds,
|
||||
then asserts search_multi is never reached (a call would raise).
|
||||
"""
|
||||
args = _make_args(cache_ttl=5)
|
||||
cached = {"results": [{"url": "https://a.com/1", "title": "cached"}]}
|
||||
|
||||
params = _build_params("test", args)
|
||||
cache_module.put(params, cached, 300)
|
||||
|
||||
def should_not_call(*a, **kw):
|
||||
raise AssertionError("search_multi must not be called on cache hit")
|
||||
monkeypatch.setattr("search.search_multi", should_not_call)
|
||||
|
||||
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 300)
|
||||
assert err is None
|
||||
assert results is not None
|
||||
assert results["results"][0]["title"] == "cached"
|
||||
|
||||
|
||||
def test_run_single_query_cache_miss_stores_result(isolated_cache, monkeypatch):
|
||||
"""Cache miss calls search_multi and stores the result for next time."""
|
||||
args = _make_args(cache_ttl=5)
|
||||
fake = {"results": [{"url": "https://a.com/1", "title": "A"}]}
|
||||
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
|
||||
|
||||
assert cache_module.stats()["entries"] == 0
|
||||
|
||||
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 300)
|
||||
assert err is None
|
||||
|
||||
assert cache_module.stats()["entries"] >= 1
|
||||
|
||||
|
||||
def test_run_single_query_no_dedup_keeps_duplicates(monkeypatch):
|
||||
"""no_dedup=True skips cross-engine URL deduplication.
|
||||
|
||||
Two results with the same URL but different engines are both kept.
|
||||
"""
|
||||
args = _make_args(no_dedup=True)
|
||||
fake = {"results": [
|
||||
{"url": "https://a.com/1", "engine": "google"},
|
||||
{"url": "https://a.com/1", "engine": "bing"},
|
||||
]}
|
||||
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
|
||||
assert len(results["results"]) == 2
|
||||
|
||||
|
||||
def test_run_single_query_max_results_truncation(monkeypatch):
|
||||
"""--max-results truncates the list (applied AFTER dedup+sort)."""
|
||||
args = _make_args(max_results=2)
|
||||
fake = {"results": [{"url": f"https://a.com/{i}"} for i in range(5)]}
|
||||
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
|
||||
assert len(results["results"]) == 2
|
||||
|
||||
|
||||
def test_run_single_query_include_domain_filter(monkeypatch):
|
||||
"""--include-domain keeps only results whose domain matches."""
|
||||
args = _make_args(include_domain="a.com")
|
||||
fake = {"results": [
|
||||
{"url": "https://a.com/1"},
|
||||
{"url": "https://b.com/2"},
|
||||
{"url": "https://a.com/3"},
|
||||
]}
|
||||
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
|
||||
urls = [r["url"] for r in results["results"]]
|
||||
assert urls == ["https://a.com/1", "https://a.com/3"]
|
||||
|
||||
|
||||
def test_run_single_query_exclude_domain_filter(monkeypatch):
|
||||
"""--exclude-domain drops results whose domain matches."""
|
||||
args = _make_args(exclude_domain="b.com")
|
||||
fake = {"results": [
|
||||
{"url": "https://a.com/1"},
|
||||
{"url": "https://b.com/2"},
|
||||
{"url": "https://c.com/3"},
|
||||
]}
|
||||
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
|
||||
urls = [r["url"] for r in results["results"]]
|
||||
assert urls == ["https://a.com/1", "https://c.com/3"]
|
||||
|
||||
|
||||
def test_run_single_query_fetch_attaches_fetched(monkeypatch):
|
||||
"""--fetch N attaches a 'fetched' list (and fetched_source) to results."""
|
||||
args = _make_args(fetch=2)
|
||||
fake = {"results": [
|
||||
{"url": "https://a.com/1"},
|
||||
{"url": "https://b.com/2"},
|
||||
]}
|
||||
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
|
||||
fake_fetched = [
|
||||
{"url": "https://a.com/1", "status": "ok", "text": "page A"},
|
||||
{"url": "https://b.com/2", "status": "ok", "text": "page B"},
|
||||
]
|
||||
monkeypatch.setattr("search.fetch_top_results", lambda *a, **kw: fake_fetched)
|
||||
|
||||
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 0)
|
||||
assert err is None
|
||||
assert results["fetched"] == fake_fetched
|
||||
assert results["fetched_source"] == "json"
|
||||
|
||||
|
||||
# ===== _emit_error =====
|
||||
|
||||
def test_emit_error_json_to_stdout(capsys):
|
||||
"""In json mode the error is printed as JSON to stdout and exits 1."""
|
||||
args = _make_args(format="json")
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_emit_error("boom", args)
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
data = json.loads(captured.out)
|
||||
assert data["error"] == "boom"
|
||||
assert data["exit_code"] == 1
|
||||
|
||||
|
||||
def test_emit_error_brief_keeps_stdout_clean(capsys):
|
||||
"""In non-json mode stdout stays clean (error routed via logger to stderr)."""
|
||||
args = _make_args(format="brief")
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_emit_error("boom", args)
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_emit_error_includes_query_when_provided(capsys):
|
||||
"""JSON error includes the 'query' field when a query is supplied."""
|
||||
args = _make_args(format="json")
|
||||
with pytest.raises(SystemExit):
|
||||
_emit_error("not found", args, query="hello world")
|
||||
captured = capsys.readouterr()
|
||||
data = json.loads(captured.out)
|
||||
assert data["query"] == "hello world"
|
||||
assert data["error"] == "not found"
|
||||
|
||||
|
||||
def test_emit_error_omits_query_when_absent(capsys):
|
||||
"""JSON error omits the 'query' field entirely when query is None."""
|
||||
args = _make_args(format="json")
|
||||
with pytest.raises(SystemExit):
|
||||
_emit_error("boom", args, query=None)
|
||||
captured = capsys.readouterr()
|
||||
data = json.loads(captured.out)
|
||||
assert "query" not in data
|
||||
|
||||
|
||||
# ===== main() via real subprocess =====
|
||||
|
||||
def _run_cli(*args, env=None):
|
||||
"""Run scripts/search.py as a real subprocess at the project root.
|
||||
|
||||
Uses ``sys.executable`` so the same interpreter that runs pytest runs
|
||||
the CLI. cwd is pinned to PROJECT_ROOT so the script's config-file
|
||||
auto-discovery (./searxng.toml) behaves deterministically.
|
||||
"""
|
||||
full_env = {**os.environ, **(env or {})}
|
||||
cmd = [sys.executable, str(PROJECT_ROOT / "scripts" / "search.py")] + list(args)
|
||||
return subprocess.run(
|
||||
cmd, cwd=str(PROJECT_ROOT),
|
||||
capture_output=True, text=True, timeout=30,
|
||||
env=full_env,
|
||||
)
|
||||
|
||||
|
||||
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 "searxng-cli" in r.stdout
|
||||
|
||||
|
||||
def test_cli_help_exits_zero():
|
||||
"""`--help` exits 0 and prints usage text on stdout."""
|
||||
r = _run_cli("--help")
|
||||
assert r.returncode == 0
|
||||
assert "Search via a user-supplied SearXNG instance" in r.stdout
|
||||
assert "--query" in r.stdout
|
||||
|
||||
|
||||
def test_cli_no_args_exits_nonzero():
|
||||
"""No arguments → parser.error → non-zero exit.
|
||||
|
||||
argparse uses exit code 2 for parser.error (not 1); we assert non-zero
|
||||
plus the message mentions --query.
|
||||
"""
|
||||
r = _run_cli()
|
||||
assert r.returncode != 0
|
||||
assert "--query" in r.stderr or "required" in r.stderr.lower()
|
||||
|
||||
|
||||
def test_cli_cache_stats_outputs_json(tmp_path):
|
||||
"""`--cache-stats` prints cache statistics as JSON and exits 0.
|
||||
|
||||
An -i instance is supplied because instance resolution runs before the
|
||||
cache-stats branch in main(); the instance is never contacted.
|
||||
SEARXNG_CACHE_DIR is redirected to a temp dir for isolation.
|
||||
"""
|
||||
r = _run_cli(
|
||||
"--cache-stats", "-i", "https://example.com",
|
||||
env={"SEARXNG_CACHE_DIR": str(tmp_path)},
|
||||
)
|
||||
assert r.returncode == 0
|
||||
data = json.loads(r.stdout)
|
||||
assert "entries" in data
|
||||
assert "path" in data
|
||||
Reference in New Issue
Block a user