feat(v1.8.0): 稳定性修复 + AI Agent 体验增强
稳定性修复: - 修复 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 同步更新
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""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"
|
||||
+116
-2
@@ -23,7 +23,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from search import _run_single_query, _emit_error, _build_params
|
||||
from search import _run_single_query, _emit_error, _build_params, _format_results
|
||||
import cache as cache_module
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -256,7 +256,7 @@ 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 "1.8.0" in r.stdout
|
||||
assert "searxng-cli" in r.stdout
|
||||
|
||||
|
||||
@@ -294,3 +294,117 @@ def test_cli_cache_stats_outputs_json(tmp_path):
|
||||
data = json.loads(r.stdout)
|
||||
assert "entries" in data
|
||||
assert "path" in data
|
||||
|
||||
|
||||
# ===== --dump-schema (v1.8.0) =====
|
||||
|
||||
def test_cli_dump_schema_outputs_valid_json():
|
||||
"""`--dump-schema` prints valid JSON with schema_version/title/properties.
|
||||
|
||||
The schema dump is a non-search operation: it exits 0 and does NOT
|
||||
require --query or -i (it short-circuits before instance resolution).
|
||||
"""
|
||||
r = _run_cli("--dump-schema")
|
||||
assert r.returncode == 0
|
||||
data = json.loads(r.stdout)
|
||||
assert data["schema_version"] == "1.0"
|
||||
assert data["title"] == "SearXNG CLI Search Result"
|
||||
assert "properties" in data
|
||||
assert isinstance(data["properties"], dict)
|
||||
|
||||
|
||||
def test_cli_dump_schema_does_not_require_query():
|
||||
"""`--dump-schema` exits 0 without --query (non-search operation)."""
|
||||
r = _run_cli("--dump-schema")
|
||||
assert r.returncode == 0
|
||||
# The --query-required check must not fire for --dump-schema.
|
||||
assert "required" not in r.stderr.lower()
|
||||
|
||||
|
||||
# ===== --stream mutual exclusion (v1.8.0) =====
|
||||
|
||||
def test_cli_stream_with_queries_file_rejected(tmp_path):
|
||||
"""--stream + --queries-file is rejected with E_INPUT on stdout.
|
||||
|
||||
Batch mode emits a JSON array, not JSON Lines; the combination is
|
||||
explicitly rejected so AI agents don't silently get the wrong format.
|
||||
"""
|
||||
qf = tmp_path / "queries.txt"
|
||||
qf.write_text("query\n", encoding="utf-8")
|
||||
r = _run_cli("--stream", "--queries-file", str(qf),
|
||||
"--format", "json", "-i", "https://x.example.com")
|
||||
assert r.returncode == 1
|
||||
data = json.loads(r.stdout)
|
||||
assert data["error_code"] == "E_INPUT"
|
||||
|
||||
|
||||
def test_cli_stream_with_csv_format_rejected():
|
||||
"""--stream + --format csv is rejected with E_INPUT.
|
||||
|
||||
csv is a non-json format, so _emit_error routes the error to stderr
|
||||
(as a ``[E_INPUT]`` prefixed log line) rather than stdout JSON.
|
||||
"""
|
||||
r = _run_cli("--stream", "--format", "csv", "-q", "test",
|
||||
"-i", "https://x.example.com")
|
||||
assert r.returncode == 1
|
||||
assert "E_INPUT" in r.stderr
|
||||
|
||||
|
||||
# ===== schema_version in single-query JSON (v1.8.0) =====
|
||||
|
||||
def test_format_results_json_includes_schema_version():
|
||||
"""Single-query JSON output includes schema_version: '1.0'."""
|
||||
results = {"results": [{"title": "T", "url": "https://example.com"}]}
|
||||
args = SimpleNamespace(format="json")
|
||||
out = _format_results(results, args)
|
||||
parsed = json.loads(out)
|
||||
assert parsed["schema_version"] == "1.0"
|
||||
|
||||
|
||||
# ===== _fallback field must not leak to JSON output (v1.8.0) =====
|
||||
|
||||
def test_run_single_query_html_fallback_pops_underscore_fallback(monkeypatch):
|
||||
"""HTML-fallback's internal _fallback field is removed before output.
|
||||
|
||||
_run_single_query pops _fallback at the end so it never appears in
|
||||
the JSON payload; fetched_source (the public field) is set separately
|
||||
on the --fetch path.
|
||||
"""
|
||||
args = _make_args(format="json")
|
||||
fake = {"results": [{"url": "https://a.com/1", "title": "A"}],
|
||||
"_fallback": "html"}
|
||||
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
|
||||
# _fallback must be popped so it doesn't leak into JSON output
|
||||
assert "_fallback" not in results
|
||||
# And the serialized JSON also omits it
|
||||
out = _format_results(results, args)
|
||||
parsed = json.loads(out)
|
||||
assert "_fallback" not in parsed
|
||||
|
||||
|
||||
def test_run_single_query_fetch_sets_fetched_source_from_html_fallback(monkeypatch):
|
||||
"""--fetch path sets fetched_source from _fallback (or 'json').
|
||||
|
||||
When the search came via HTML fallback (_fallback='html'), the
|
||||
fetched_source field reflects that origin, while _fallback itself is
|
||||
cleaned up and never reaches JSON output.
|
||||
"""
|
||||
args = _make_args(fetch=1)
|
||||
fake = {"results": [{"url": "https://a.com/1"}], "_fallback": "html"}
|
||||
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
|
||||
monkeypatch.setattr("search.fetch_top_results", lambda *a, **kw: [
|
||||
{"url": "https://a.com/1", "status": "ok", "text": "page A",
|
||||
"text_length": 6, "truncated": False},
|
||||
])
|
||||
|
||||
results, err, _ = _run_single_query("test", args,
|
||||
["https://x.example.com"], {}, 0)
|
||||
assert err is None
|
||||
# fetched_source reflects the HTML fallback origin
|
||||
assert results["fetched_source"] == "html"
|
||||
# _fallback still cleaned up
|
||||
assert "_fallback" not in results
|
||||
|
||||
@@ -143,8 +143,10 @@ def test_non_retryable_404():
|
||||
assert is_retryable_error(_http_error(404)) is False
|
||||
|
||||
|
||||
def test_non_retryable_403():
|
||||
assert is_retryable_error(_http_error(403)) is False
|
||||
def test_retryable_403():
|
||||
# 403 是可重试的:让 fetch_url 的 UA-fallback 循环有机会切换到浏览器 UA。
|
||||
# 真正的认证错误会在重试耗尽后由 classify_error 归为 E_AUTH。
|
||||
assert is_retryable_error(_http_error(403)) is True
|
||||
|
||||
|
||||
def test_non_retryable_200():
|
||||
@@ -160,7 +162,8 @@ def test_retryable_os_error():
|
||||
|
||||
|
||||
def test_retryable_status_set_contents():
|
||||
assert RETRYABLE_STATUS == frozenset({429, 502, 503, 504})
|
||||
# 403 加入可重试集合,让 UA-fallback 在 UA 被屏蔽时有机会切换浏览器 UA
|
||||
assert RETRYABLE_STATUS == frozenset({403, 429, 502, 503, 504})
|
||||
|
||||
|
||||
# ----- apply_proxy -----
|
||||
|
||||
@@ -231,3 +231,70 @@ def test_run_single_query_propagates_rate_limit():
|
||||
_, _, err_code = _run_single_query(
|
||||
"test", args, ["https://x.example.com"], {}, 0)
|
||||
assert err_code == E_RATE_LIMIT
|
||||
|
||||
|
||||
# ----- classify_error: HTTP status extraction from RuntimeError messages -----
|
||||
#
|
||||
# search_multi wraps the last error into its RuntimeError message, e.g.
|
||||
# "All 3 instances failed. Last error: HTTP Error 403: Forbidden"
|
||||
# classify_error must extract the status code from that message rather than
|
||||
# always falling back to E_NETWORK.
|
||||
|
||||
def test_classify_runtime_all_instances_failed_with_http_403_is_auth():
|
||||
"""'...Last error: HTTP Error 403' -> E_AUTH (not E_NETWORK)."""
|
||||
err = RuntimeError("All 3 instances failed. Last error: HTTP Error 403: Forbidden")
|
||||
assert classify_error(err) == E_AUTH
|
||||
|
||||
|
||||
def test_classify_runtime_all_instances_failed_with_http_500_is_network():
|
||||
"""'...Last error: HTTP Error 500' -> E_NETWORK (5xx server error)."""
|
||||
err = RuntimeError("All 3 instances failed. Last error: HTTP Error 500: Internal Server Error")
|
||||
assert classify_error(err) == E_NETWORK
|
||||
|
||||
|
||||
def test_classify_runtime_parallel_failed_with_http_429_is_rate_limit():
|
||||
"""'...(parallel). Last error: HTTP Error 429' -> E_RATE_LIMIT."""
|
||||
err = RuntimeError("All 3 instances failed (parallel). Last error: HTTP Error 429: Too Many Requests")
|
||||
assert classify_error(err) == E_RATE_LIMIT
|
||||
|
||||
|
||||
# ----- _emit_error: recovery_hint in JSON output -----
|
||||
#
|
||||
# recovery_hint gives AI agents an actionable suggestion per error_code.
|
||||
# Only present when error_code is known; omitted otherwise (backwards compat).
|
||||
|
||||
def test_emit_error_json_includes_recovery_hint_for_config(capsys):
|
||||
"""JSON error with E_CONFIG includes the config recovery_hint."""
|
||||
args = SimpleNamespace(format="json")
|
||||
with pytest.raises(SystemExit):
|
||||
_emit_error("no instance resolved", args, error_code=E_CONFIG)
|
||||
out, _ = capsys.readouterr()
|
||||
data = json.loads(out)
|
||||
assert data["error_code"] == E_CONFIG
|
||||
assert "recovery_hint" in data
|
||||
hint = data["recovery_hint"].lower()
|
||||
assert "instance" in hint or "config" in hint
|
||||
|
||||
|
||||
def test_emit_error_json_includes_recovery_hint_for_auth(capsys):
|
||||
"""JSON error with E_AUTH includes the auth recovery_hint."""
|
||||
args = SimpleNamespace(format="json")
|
||||
with pytest.raises(SystemExit):
|
||||
_emit_error("forbidden", args, error_code=E_AUTH)
|
||||
out, _ = capsys.readouterr()
|
||||
data = json.loads(out)
|
||||
assert data["error_code"] == E_AUTH
|
||||
assert "recovery_hint" in data
|
||||
hint = data["recovery_hint"].lower()
|
||||
assert "credential" in hint or "token" in hint
|
||||
|
||||
|
||||
def test_emit_error_json_no_recovery_hint_without_error_code(capsys):
|
||||
"""No error_code -> no recovery_hint field (backwards compat)."""
|
||||
args = SimpleNamespace(format="json")
|
||||
with pytest.raises(SystemExit):
|
||||
_emit_error("something broke", args)
|
||||
out, _ = capsys.readouterr()
|
||||
data = json.loads(out)
|
||||
assert "error_code" not in data
|
||||
assert "recovery_hint" not in data
|
||||
|
||||
@@ -6,8 +6,9 @@ disabled by default, stream only works with --format json.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
import common
|
||||
@@ -205,3 +206,115 @@ def test_stream_result_event_shape():
|
||||
assert parsed[0]["result"]["url"] == "https://x.com"
|
||||
assert parsed[-1]["type"] == "done"
|
||||
assert parsed[-1]["count"] == 1
|
||||
|
||||
|
||||
# ----- stream done/error: schema_version & recovery_hint (v1.8.0) -----
|
||||
|
||||
def test_stream_done_includes_schema_version(capsys):
|
||||
"""stream done event includes the schema_version field."""
|
||||
mock_results = {"results": [{"title": "t", "url": "https://x.com"}]}
|
||||
with patch.object(search_mod, "search_multi", return_value=mock_results):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with patch.object(sys, "argv", ["search.py", "-q", "test",
|
||||
"-i", "https://x.example.com",
|
||||
"--stream", "--format", "json",
|
||||
"--retry", "0"]):
|
||||
with patch.object(search_mod, "setup_logging"):
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
out, _ = capsys.readouterr()
|
||||
lines = [json.loads(l) for l in out.strip().split("\n") if l]
|
||||
done_events = [e for e in lines if e["type"] == "done"]
|
||||
assert len(done_events) == 1
|
||||
assert done_events[0]["schema_version"] == "1.0"
|
||||
|
||||
|
||||
def test_stream_error_includes_recovery_hint(capsys):
|
||||
"""stream error event includes recovery_hint for classified errors.
|
||||
|
||||
HTTP 403 -> E_AUTH, which has a recovery_hint in RECOVERY_HINTS.
|
||||
--retry 0 avoids backoff sleeps on the (mocked) 403.
|
||||
"""
|
||||
err = urllib.error.HTTPError("url", 403, "Forbidden", {}, None)
|
||||
with patch.object(search_mod, "search_multi", side_effect=err):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with patch.object(sys, "argv", ["search.py", "-q", "test",
|
||||
"-i", "https://x.example.com",
|
||||
"--stream", "--format", "json",
|
||||
"--retry", "0"]):
|
||||
with patch.object(search_mod, "setup_logging"):
|
||||
main()
|
||||
assert exc_info.value.code == 1
|
||||
out, _ = capsys.readouterr()
|
||||
lines = [json.loads(l) for l in out.strip().split("\n") if l]
|
||||
error_events = [e for e in lines if e["type"] == "error"]
|
||||
assert len(error_events) == 1
|
||||
assert error_events[0]["error_code"] == "E_AUTH"
|
||||
assert "recovery_hint" in error_events[0]
|
||||
|
||||
|
||||
# ----- instance_try / instance_ok / instance_fail progress events (v1.8.0) -----
|
||||
#
|
||||
# These events are emitted inside search_multi (not _run_single_query), so
|
||||
# we must let the real search_multi run and only mock urllib.request.urlopen.
|
||||
|
||||
def _mock_urlopen_resp(data: bytes, content_type="application/json"):
|
||||
"""Build a MagicMock that quacks like an urlopen context manager."""
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = data
|
||||
resp.headers = {"Content-Type": content_type}
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = None
|
||||
return resp
|
||||
|
||||
|
||||
def _url_router(routing):
|
||||
"""Build a side_effect that dispatches urlopen by request URL substring."""
|
||||
def _side_effect(req, *args, **kwargs):
|
||||
url = getattr(req, "full_url", str(req))
|
||||
for key, resp in routing.items():
|
||||
if key in url:
|
||||
if isinstance(resp, BaseException):
|
||||
raise resp
|
||||
return resp
|
||||
raise AssertionError(f"unexpected urlopen for {url!r}")
|
||||
return _side_effect
|
||||
|
||||
|
||||
def test_progress_emits_instance_try_ok_fail(capsys):
|
||||
"""instance_try/instance_ok/instance_fail events are emitted to stderr.
|
||||
|
||||
Uses serial mode (parallel=False) so the events come out in a
|
||||
deterministic order: try a -> fail a -> try b -> ok b.
|
||||
"""
|
||||
set_progress_enabled(True)
|
||||
try:
|
||||
payload = json.dumps(
|
||||
{"results": [{"title": "b", "url": "https://b.com"}]}
|
||||
).encode()
|
||||
router = _url_router({
|
||||
"a.example.com": urllib.error.URLError("down"),
|
||||
"b.example.com": _mock_urlopen_resp(payload),
|
||||
})
|
||||
with patch("urllib.request.urlopen", side_effect=router):
|
||||
search_mod.search_multi(
|
||||
["https://a.example.com", "https://b.example.com"],
|
||||
{"q": "test", "format": "json"},
|
||||
parallel=False, retry_per=0,
|
||||
)
|
||||
_, err = capsys.readouterr()
|
||||
lines = [json.loads(l) for l in err.strip().split("\n") if l]
|
||||
events = [e["event"] for e in lines]
|
||||
assert "instance_try" in events
|
||||
assert "instance_fail" in events
|
||||
assert "instance_ok" in events
|
||||
# instance_fail carries error_code
|
||||
fail_events = [e for e in lines if e["event"] == "instance_fail"]
|
||||
assert len(fail_events) >= 1
|
||||
assert "error_code" in fail_events[0]
|
||||
# instance_ok carries results count
|
||||
ok_events = [e for e in lines if e["event"] == "instance_ok"]
|
||||
assert len(ok_events) >= 1
|
||||
assert "results" in ok_events[0]
|
||||
finally:
|
||||
set_progress_enabled(False)
|
||||
|
||||
Reference in New Issue
Block a user