Files
searxng-use-cli/tests/test_cli_e2e.py
T
thzxx f983a9377e 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 更新参数与错误码表
2026-08-01 17:40:14 +08:00

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