Files
searxng-use-cli/tests/test_instance_resolution.py
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

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"]