核心新增(面向 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 更新参数与错误码表
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""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
|