稳定性修复: - 修复 cache.py SQLite 连接泄漏(contextlib.closing 包装) - 修复 fetch.py requests stream=True 连接泄漏(try/finally resp.close()) - RETRYABLE_STATUS 新增 403,激活 UA fallback 切换逻辑 - --cache-stats 移至实例解析前,无需实例即可查询 - classify_error 从错误消息提取 HTTP 状态码,正确分类 E_AUTH/E_RATE_LIMIT - --stream 与 --queries-file 互斥检查,违规报 E_INPUT - batch 退出码语义统一(0=有结果 / 1=全部错误 / 2=全部空结果) AI Agent 体验增强: - 错误码体系完善:E_CONFIG/E_AUTH/E_NETWORK/E_RATE_LIMIT/E_PARSE/E_EMPTY/E_INPUT/E_INTERNAL - recovery_hint 恢复提示字段,AI Agent 可程序化决策恢复策略 - stream 模式新增 error 事件类型(含 error_code + recovery_hint) - 进度事件扩展:instance_try/instance_ok/instance_fail - batch 模式统一 schema(status 字段区分 success/failed) - JSON 输出含 schema_version 字段确保版本兼容 测试与文档: - 测试覆盖:330 -> 352 - SKILL.md / README.md 同步更新
135 lines
5.3 KiB
Python
135 lines
5.3 KiB
Python
"""Tests for --queries-file batch mode: exit code semantics and JSON schema.
|
|
|
|
Covers:
|
|
* Exit codes: all-empty (no error) -> 2, all-error -> 1, partial -> 0
|
|
* Batch JSON shape: {"schema_version": "1.0", "queries": [...]}
|
|
* Per-entry "status" field ("ok"/"error") and result/error payload shape
|
|
|
|
Uses in-process main() with search_multi mocked so no network is touched.
|
|
``--retry 0`` is passed to avoid backoff sleeps when search_multi raises.
|
|
"""
|
|
import json
|
|
import sys
|
|
import urllib.error
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
import search as search_mod
|
|
from search import main
|
|
|
|
|
|
def _batch_argv(queries_file_path, fmt="json"):
|
|
"""Build the sys.argv for a batch main() invocation.
|
|
|
|
``--retry 0`` keeps failing tests fast (no backoff sleeps).
|
|
"""
|
|
return ["search.py", "-i", "https://x.example.com",
|
|
"--queries-file", str(queries_file_path),
|
|
"--format", fmt, "--retry", "0"]
|
|
|
|
|
|
# ===== batch exit code semantics =====
|
|
|
|
def test_batch_all_empty_results_exits_2(tmp_path, capsys):
|
|
"""All queries return empty results (no error) -> exit 2."""
|
|
qf = tmp_path / "queries.txt"
|
|
qf.write_text("q1\nq2\n", encoding="utf-8")
|
|
empty = {"results": []}
|
|
with patch.object(search_mod, "search_multi", return_value=empty):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
with patch.object(sys, "argv", _batch_argv(qf)):
|
|
with patch.object(search_mod, "setup_logging"):
|
|
main()
|
|
assert exc_info.value.code == 2
|
|
|
|
|
|
def test_batch_all_error_exits_1(tmp_path, capsys):
|
|
"""All queries error -> exit 1."""
|
|
qf = tmp_path / "queries.txt"
|
|
qf.write_text("q1\nq2\n", encoding="utf-8")
|
|
err = urllib.error.URLError("connection refused")
|
|
with patch.object(search_mod, "search_multi", side_effect=err):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
with patch.object(sys, "argv", _batch_argv(qf)):
|
|
with patch.object(search_mod, "setup_logging"):
|
|
main()
|
|
assert exc_info.value.code == 1
|
|
|
|
|
|
def test_batch_partial_results_exits_0(tmp_path, capsys):
|
|
"""At least one query returns results -> exit 0."""
|
|
qf = tmp_path / "queries.txt"
|
|
qf.write_text("q1\nq2\n", encoding="utf-8")
|
|
with_data = {"results": [{"url": "https://a.com/1", "title": "A"}]}
|
|
empty = {"results": []}
|
|
with patch.object(search_mod, "search_multi",
|
|
side_effect=[with_data, empty]):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
with patch.object(sys, "argv", _batch_argv(qf)):
|
|
with patch.object(search_mod, "setup_logging"):
|
|
main()
|
|
assert exc_info.value.code == 0
|
|
|
|
|
|
# ===== batch unified JSON schema =====
|
|
|
|
def test_batch_json_has_schema_version_and_queries(tmp_path, capsys):
|
|
"""Batch JSON output wraps entries in {schema_version, queries}."""
|
|
qf = tmp_path / "queries.txt"
|
|
qf.write_text("q1\n", encoding="utf-8")
|
|
mock_results = {"results": [{"url": "https://a.com/1", "title": "A"}]}
|
|
with patch.object(search_mod, "search_multi", return_value=mock_results):
|
|
with pytest.raises(SystemExit):
|
|
with patch.object(sys, "argv", _batch_argv(qf)):
|
|
with patch.object(search_mod, "setup_logging"):
|
|
main()
|
|
out, _ = capsys.readouterr()
|
|
data = json.loads(out)
|
|
assert data["schema_version"] == "1.0"
|
|
assert "queries" in data
|
|
assert isinstance(data["queries"], list)
|
|
assert len(data["queries"]) == 1
|
|
|
|
|
|
def test_batch_entry_has_status_field(tmp_path, capsys):
|
|
"""Each batch entry has a 'status' field of 'ok' or 'error'."""
|
|
qf = tmp_path / "queries.txt"
|
|
qf.write_text("q1\nq2\n", encoding="utf-8")
|
|
ok_results = {"results": [{"url": "https://a.com/1", "title": "A"}]}
|
|
err = urllib.error.URLError("down")
|
|
with patch.object(search_mod, "search_multi",
|
|
side_effect=[ok_results, err]):
|
|
with pytest.raises(SystemExit):
|
|
with patch.object(sys, "argv", _batch_argv(qf)):
|
|
with patch.object(search_mod, "setup_logging"):
|
|
main()
|
|
out, _ = capsys.readouterr()
|
|
data = json.loads(out)
|
|
statuses = [e["status"] for e in data["queries"]]
|
|
assert "ok" in statuses
|
|
assert "error" in statuses
|
|
|
|
|
|
def test_batch_success_entry_has_results_error_entry_has_error_and_code(tmp_path, capsys):
|
|
"""ok entry has 'results'; error entry has 'error' and 'error_code'."""
|
|
qf = tmp_path / "queries.txt"
|
|
qf.write_text("q1\nq2\n", encoding="utf-8")
|
|
ok_results = {"results": [{"url": "https://a.com/1", "title": "A"}]}
|
|
err = urllib.error.URLError("connection refused")
|
|
with patch.object(search_mod, "search_multi",
|
|
side_effect=[ok_results, err]):
|
|
with pytest.raises(SystemExit):
|
|
with patch.object(sys, "argv", _batch_argv(qf)):
|
|
with patch.object(search_mod, "setup_logging"):
|
|
main()
|
|
out, _ = capsys.readouterr()
|
|
data = json.loads(out)
|
|
ok_entry = next(e for e in data["queries"] if e["status"] == "ok")
|
|
assert "results" in ok_entry
|
|
err_entry = next(e for e in data["queries"] if e["status"] == "error")
|
|
assert "error" in err_entry
|
|
assert "error_code" in err_entry
|
|
# URLError -> E_NETWORK per classify_error
|
|
assert err_entry["error_code"] == "E_NETWORK"
|