核心新增(面向 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 更新参数与错误码表
194 lines
7.6 KiB
Python
194 lines
7.6 KiB
Python
"""Tests for scripts/search.py — instance resolution chain.
|
|
|
|
Covers:
|
|
* ``_load_toml`` — TOML file loading (Python 3.11+ tomllib / 3.8-3.10 tomli)
|
|
* ``_read_instance_file`` — config file parsing (.toml single/list/table,
|
|
.txt per-line / comments / comma-separated / blanks, missing file,
|
|
corrupted toml)
|
|
* ``resolve_instances`` — priority chain (CLI > env > config file > empty)
|
|
and config-file auto-discovery (cwd/home, .toml before .txt)
|
|
|
|
Tests are hermetic: cwd and home are redirected to ``tmp_path`` via
|
|
monkeypatch so the real user environment never interferes.
|
|
"""
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
from search import _load_toml, _read_instance_file, resolve_instances
|
|
|
|
|
|
# ----- _load_toml -----
|
|
|
|
def test_load_toml_valid_file(tmp_path):
|
|
f = tmp_path / "test.toml"
|
|
f.write_text('key = "value"\nnumber = 42\n', encoding="utf-8")
|
|
data = _load_toml(f)
|
|
assert data["key"] == "value"
|
|
assert data["number"] == 42
|
|
|
|
|
|
def test_load_toml_returns_dict(tmp_path):
|
|
f = tmp_path / "test.toml"
|
|
f.write_text('instance = "https://x.example.com"\n', encoding="utf-8")
|
|
data = _load_toml(f)
|
|
assert isinstance(data, dict)
|
|
|
|
|
|
# ----- _read_instance_file: .toml -----
|
|
|
|
def test_read_toml_single_instance(tmp_path):
|
|
"""Top-level instance = "url" → parse_instances single URL."""
|
|
f = tmp_path / "searxng.toml"
|
|
f.write_text('instance = "https://x.example.com"\n', encoding="utf-8")
|
|
assert _read_instance_file(f) == ["https://x.example.com"]
|
|
|
|
|
|
def test_read_toml_instances_list(tmp_path):
|
|
"""Top-level instances = ["a", "b"] → normalized list."""
|
|
f = tmp_path / "searxng.toml"
|
|
f.write_text('instances = ["a.com", "b.com"]\n', encoding="utf-8")
|
|
assert _read_instance_file(f) == ["https://a.com", "https://b.com"]
|
|
|
|
|
|
def test_read_toml_searxng_table_instance(tmp_path):
|
|
"""Instance under [searxng] table is read correctly."""
|
|
f = tmp_path / "searxng.toml"
|
|
f.write_text('[searxng]\ninstance = "https://t.example.com"\n',
|
|
encoding="utf-8")
|
|
assert _read_instance_file(f) == ["https://t.example.com"]
|
|
|
|
|
|
def test_read_corrupted_toml_returns_empty(tmp_path):
|
|
"""Corrupted (unparseable) toml → empty list, no exception raised."""
|
|
f = tmp_path / "bad.toml"
|
|
f.write_text("[searxng\ninstance = broken\n", encoding="utf-8")
|
|
assert _read_instance_file(f) == []
|
|
|
|
|
|
# ----- _read_instance_file: .txt -----
|
|
|
|
def test_read_txt_one_url_per_line(tmp_path):
|
|
f = tmp_path / "instances.txt"
|
|
f.write_text("https://a.com\nhttps://b.com\n", encoding="utf-8")
|
|
assert _read_instance_file(f) == ["https://a.com", "https://b.com"]
|
|
|
|
|
|
def test_read_txt_skips_comments(tmp_path):
|
|
f = tmp_path / "instances.txt"
|
|
f.write_text("# comment\nhttps://a.com\n# another\nhttps://b.com\n",
|
|
encoding="utf-8")
|
|
assert _read_instance_file(f) == ["https://a.com", "https://b.com"]
|
|
|
|
|
|
def test_read_txt_comma_separated(tmp_path):
|
|
"""Comma-separated URLs on one line are split."""
|
|
f = tmp_path / "instances.txt"
|
|
f.write_text("a.com,b.com\n", encoding="utf-8")
|
|
assert _read_instance_file(f) == ["https://a.com", "https://b.com"]
|
|
|
|
|
|
def test_read_txt_skips_blank_lines(tmp_path):
|
|
f = tmp_path / "instances.txt"
|
|
f.write_text("https://a.com\n\n\nhttps://b.com\n", encoding="utf-8")
|
|
assert _read_instance_file(f) == ["https://a.com", "https://b.com"]
|
|
|
|
|
|
# ----- _read_instance_file: edge cases -----
|
|
|
|
def test_read_nonexistent_file_returns_empty(tmp_path):
|
|
"""Missing file → empty list (FileNotFoundError caught internally)."""
|
|
f = tmp_path / "nonexistent.toml"
|
|
assert _read_instance_file(f) == []
|
|
|
|
|
|
# ----- resolve_instances: priority chain -----
|
|
|
|
def test_resolve_cli_arg_takes_priority_over_env(monkeypatch, tmp_path):
|
|
"""CLI arg wins even when SEARXNG_INSTANCE env var is set."""
|
|
monkeypatch.setenv("SEARXNG_INSTANCE", "https://env.example.com")
|
|
assert resolve_instances("https://cli.example.com") == ["https://cli.example.com"]
|
|
|
|
|
|
def test_resolve_env_takes_priority_over_config_file(monkeypatch, tmp_path):
|
|
"""Env var wins over a present config file."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
(tmp_path / "searxng.toml").write_text(
|
|
'instance = "https://file.example.com"\n', encoding="utf-8")
|
|
monkeypatch.setenv("SEARXNG_INSTANCE", "https://env.example.com")
|
|
assert resolve_instances(None) == ["https://env.example.com"]
|
|
|
|
|
|
def test_resolve_no_source_returns_empty(monkeypatch, tmp_path):
|
|
"""No CLI, no env, no config file → empty list."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.delenv("SEARXNG_INSTANCE", raising=False)
|
|
assert resolve_instances(None) == []
|
|
|
|
|
|
def test_resolve_cli_none_falls_back_to_env(monkeypatch, tmp_path):
|
|
"""cli_arg=None → use SEARXNG_INSTANCE env var."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("SEARXNG_INSTANCE", "https://env.example.com")
|
|
assert resolve_instances(None) == ["https://env.example.com"]
|
|
|
|
|
|
def test_resolve_no_env_falls_back_to_config_file(monkeypatch, tmp_path):
|
|
"""No CLI, no env → fall back to config file."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.delenv("SEARXNG_INSTANCE", raising=False)
|
|
(tmp_path / "searxng.toml").write_text(
|
|
'instance = "https://file.example.com"\n', encoding="utf-8")
|
|
assert resolve_instances(None) == ["https://file.example.com"]
|
|
|
|
|
|
# ----- resolve_instances: config file discovery -----
|
|
|
|
def test_resolve_discovers_cwd_searxng_toml(monkeypatch, tmp_path):
|
|
"""./searxng.toml is auto-discovered."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.delenv("SEARXNG_INSTANCE", raising=False)
|
|
(tmp_path / "searxng.toml").write_text(
|
|
'instance = "https://cwd.example.com"\n', encoding="utf-8")
|
|
assert resolve_instances(None) == ["https://cwd.example.com"]
|
|
|
|
|
|
def test_resolve_discovers_home_searxng_toml(monkeypatch, tmp_path):
|
|
"""~/.config/searxng-cli/searxng.toml is auto-discovered."""
|
|
monkeypatch.chdir(tmp_path) # cwd has no config file
|
|
home = tmp_path / "fake_home"
|
|
cfg_dir = home / ".config" / "searxng-cli"
|
|
cfg_dir.mkdir(parents=True)
|
|
(cfg_dir / "searxng.toml").write_text(
|
|
'instance = "https://home.example.com"\n', encoding="utf-8")
|
|
monkeypatch.setattr(Path, "home", lambda: home)
|
|
monkeypatch.delenv("SEARXNG_INSTANCE", raising=False)
|
|
assert resolve_instances(None) == ["https://home.example.com"]
|
|
|
|
|
|
def test_resolve_discovers_cwd_instances_txt(monkeypatch, tmp_path):
|
|
"""./instances.txt is auto-discovered when no .toml present."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.delenv("SEARXNG_INSTANCE", raising=False)
|
|
(tmp_path / "instances.txt").write_text("https://txt.example.com\n",
|
|
encoding="utf-8")
|
|
assert resolve_instances(None) == ["https://txt.example.com"]
|
|
|
|
|
|
def test_resolve_prefers_toml_over_txt(monkeypatch, tmp_path):
|
|
"""When both ./searxng.toml and ./instances.txt exist, .toml wins."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.delenv("SEARXNG_INSTANCE", raising=False)
|
|
(tmp_path / "searxng.toml").write_text(
|
|
'instance = "https://toml.example.com"\n', encoding="utf-8")
|
|
(tmp_path / "instances.txt").write_text("https://txt.example.com\n",
|
|
encoding="utf-8")
|
|
result = resolve_instances(None)
|
|
assert result == ["https://toml.example.com"]
|