feat(routing): ordered backend candidates + real-probing doctor
- backends is now an ordered candidate list (first = preferred); channels report the backend actually serving via active_backend, surfaced in the doctor text report and --json - new agent_reach/probe.py really executes upstream commands and tells apart missing / broken (stale venv shebang after a system Python upgrade) / timeout, with a reinstall prescription for broken installs - all 13 channels migrated off which()-only checks: fixes bilibili false-positive "bili-cli 可用" on broken shims, misleading xiaohongshu "连接失败", rdt OSError crashing doctor, mcporter breakage masquerading as "未配置" - twitter: 15s probe + 1 retry (flaky 10s timeout), broken twitter-cli now falls back to bird instead of aborting the check - doctor survives per-channel exceptions; config supports per-channel backend override (<channel>_backend / <CHANNEL>_BACKEND env) - fix skill install/uninstall crash on symlinked skill dirs (the "[Errno None] None" warning from shutil.rmtree on a symlink) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Contract tests for channel adapters."""
|
||||
|
||||
import subprocess
|
||||
|
||||
from agent_reach.channels import get_all_channels
|
||||
from agent_reach.config import Config
|
||||
|
||||
|
||||
def _fake_run_ok(cmd, **kwargs):
|
||||
"""Pretend any probed CLI executes fine and prints a version."""
|
||||
return subprocess.CompletedProcess(cmd, 0, "2026.06.09", "")
|
||||
|
||||
|
||||
def test_channel_registry_contract():
|
||||
channels = get_all_channels()
|
||||
assert channels, "channel registry must not be empty"
|
||||
@@ -29,6 +36,67 @@ def test_channel_check_contract_with_minimal_runtime(monkeypatch, tmp_path):
|
||||
assert isinstance(message, str) and message.strip()
|
||||
|
||||
|
||||
def test_channel_active_backend_attribute_contract():
|
||||
"""Every channel exposes active_backend (default None, or str once set)."""
|
||||
for ch in get_all_channels():
|
||||
assert hasattr(ch, "active_backend")
|
||||
# Fresh instances must default to None / str (class attribute on base)
|
||||
fresh = type(ch)()
|
||||
assert fresh.active_backend is None or isinstance(fresh.active_backend, str)
|
||||
|
||||
|
||||
def test_channel_active_backend_set_by_check(monkeypatch, tmp_path):
|
||||
"""After check(), active_backend is None or a str — never anything else."""
|
||||
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
||||
|
||||
# Keep the network-based channels (V2EX/Xueqiu/Bilibili API) deterministic.
|
||||
import urllib.request
|
||||
from urllib.error import URLError
|
||||
|
||||
def _no_net(*_a, **_k):
|
||||
raise URLError("offline")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _no_net)
|
||||
import agent_reach.channels.xueqiu as xueqiu_mod
|
||||
monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True)
|
||||
monkeypatch.setattr(xueqiu_mod._opener, "open", _no_net)
|
||||
|
||||
config = Config(config_path=tmp_path / "config.yaml")
|
||||
for ch in get_all_channels():
|
||||
ch.check(config)
|
||||
assert ch.active_backend is None or isinstance(ch.active_backend, str), (
|
||||
f"{ch.name}: active_backend must be None or str after check()"
|
||||
)
|
||||
|
||||
|
||||
def test_ordered_backends_contract(tmp_path):
|
||||
"""ordered_backends(config) is a reordering (same multiset) of backends."""
|
||||
config = Config(config_path=tmp_path / "config.yaml")
|
||||
for ch in get_all_channels():
|
||||
ordered = ch.ordered_backends(config)
|
||||
assert isinstance(ordered, list)
|
||||
assert sorted(ordered) == sorted(ch.backends), (
|
||||
f"{ch.name}: ordered_backends must be a permutation of backends"
|
||||
)
|
||||
# And without any config at all
|
||||
ordered_none = ch.ordered_backends(None)
|
||||
assert sorted(ordered_none) == sorted(ch.backends)
|
||||
|
||||
|
||||
def test_ordered_backends_override_moves_backend_to_front():
|
||||
"""Config key <channel>_backend promotes the named backend to front."""
|
||||
from agent_reach.channels.twitter import TwitterChannel
|
||||
|
||||
ch = TwitterChannel()
|
||||
ordered = ch.ordered_backends({"twitter_backend": "bird"})
|
||||
assert ordered[0] == "bird CLI (legacy)"
|
||||
assert sorted(ordered) == sorted(ch.backends)
|
||||
|
||||
# Unknown override is ignored — never hides working backends
|
||||
ordered_unknown = ch.ordered_backends({"twitter_backend": "no-such-tool"})
|
||||
assert ordered_unknown == list(ch.backends)
|
||||
|
||||
|
||||
def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path):
|
||||
"""YouTube should warn when only Node.js is installed but no yt-dlp config exists."""
|
||||
from agent_reach.channels.youtube import YouTubeChannel
|
||||
@@ -41,6 +109,7 @@ def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path):
|
||||
return None # deno not installed
|
||||
|
||||
monkeypatch.setattr("shutil.which", fake_which)
|
||||
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
|
||||
# Point to a non-existent config file
|
||||
monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / ".config/yt-dlp/config"))
|
||||
|
||||
@@ -48,6 +117,7 @@ def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path):
|
||||
status, message = ch.check()
|
||||
assert status == "warn"
|
||||
assert "--js-runtimes" in message
|
||||
assert ch.active_backend == "yt-dlp" # 本体活着,warn 只关乎 JS runtime
|
||||
|
||||
|
||||
def test_youtube_warns_with_windows_specific_fix_command(monkeypatch, tmp_path):
|
||||
@@ -62,6 +132,7 @@ def test_youtube_warns_with_windows_specific_fix_command(monkeypatch, tmp_path):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("shutil.which", fake_which)
|
||||
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
|
||||
monkeypatch.setattr("agent_reach.utils.paths.sys.platform", "win32")
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
|
||||
@@ -84,10 +155,12 @@ def test_youtube_ok_when_deno_installed(monkeypatch):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("shutil.which", fake_which)
|
||||
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
|
||||
|
||||
ch = YouTubeChannel()
|
||||
status, _msg = ch.check()
|
||||
assert status == "ok"
|
||||
assert ch.active_backend == "yt-dlp"
|
||||
|
||||
|
||||
def test_channel_can_handle_contract():
|
||||
|
||||
+300
-8
@@ -670,9 +670,11 @@ class TestRedditChannel:
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.reddit import RedditChannel
|
||||
status, msg = RedditChannel().check()
|
||||
ch = RedditChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "ok"
|
||||
assert "testuser" in msg
|
||||
assert ch.active_backend == "rdt-cli"
|
||||
|
||||
def test_reports_warn_when_not_authenticated(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt")
|
||||
@@ -687,14 +689,18 @@ class TestRedditChannel:
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.reddit import RedditChannel
|
||||
status, msg = RedditChannel().check()
|
||||
ch = RedditChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "warn"
|
||||
assert "403" in msg
|
||||
assert "rdt login" in msg
|
||||
assert "Cookie-Editor" in msg
|
||||
assert "chromewebstore.google.com" in msg
|
||||
# 未登录是业务态:进程活着,后端仍然算可用
|
||||
assert ch.active_backend == "rdt-cli"
|
||||
|
||||
def test_reports_warn_when_status_check_fails(self, monkeypatch):
|
||||
def test_reports_error_when_status_check_fails(self, monkeypatch):
|
||||
"""rdt 非零退出且输出不可解析 → 工具异常(error),不再算 warn。"""
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
@@ -702,8 +708,43 @@ class TestRedditChannel:
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.reddit import RedditChannel
|
||||
status, msg = RedditChannel().check()
|
||||
assert status == "warn"
|
||||
ch = RedditChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "error"
|
||||
assert "rdt 异常退出" in msg
|
||||
assert ch.active_backend is None
|
||||
|
||||
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
|
||||
"""which 命中但 exec 抛 FileNotFoundError(venv 断链)→ error + 重装处方。"""
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
raise FileNotFoundError("/usr/local/bin/rdt")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.reddit import RedditChannel
|
||||
ch = RedditChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "error"
|
||||
assert "无法执行" in msg
|
||||
assert "pipx install --force" in msg # rdt 专用 git 源重装处方
|
||||
assert "git+https://github.com/public-clis/rdt-cli.git" in msg
|
||||
assert ch.active_backend is None
|
||||
|
||||
def test_reports_error_with_reinstall_hint_on_exit_127(self, monkeypatch):
|
||||
"""退出码 127(找到但跑不动)同样按断链处理。"""
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 127, "", "")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.reddit import RedditChannel
|
||||
ch = RedditChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "error"
|
||||
assert "pipx install --force" in msg
|
||||
assert ch.active_backend is None
|
||||
|
||||
def test_can_handle_reddit_urls(self):
|
||||
from agent_reach.channels.reddit import RedditChannel
|
||||
@@ -723,9 +764,11 @@ class TestXiaoHongShuChannel:
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
status, msg = XiaoHongShuChannel().check()
|
||||
ch = XiaoHongShuChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "ok"
|
||||
assert "完整可用" in msg
|
||||
assert ch.active_backend == "xhs-cli (xiaohongshu-cli)"
|
||||
|
||||
def test_reports_warn_when_not_authenticated(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
|
||||
@@ -735,12 +778,261 @@ class TestXiaoHongShuChannel:
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
status, msg = XiaoHongShuChannel().check()
|
||||
ch = XiaoHongShuChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "warn"
|
||||
assert "xhs login" in msg
|
||||
# 未登录是业务态:工具进程活着,后端仍可用
|
||||
assert ch.active_backend == "xhs-cli (xiaohongshu-cli)"
|
||||
|
||||
def test_reports_off_when_not_installed(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda _: None)
|
||||
status, msg = XiaoHongShuChannel().check()
|
||||
ch = XiaoHongShuChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "off"
|
||||
assert "xiaohongshu-cli" in msg
|
||||
assert ch.active_backend is None
|
||||
|
||||
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
|
||||
"""which 命中但 exec 抛 FileNotFoundError(venv 断链)→ error + 重装处方。"""
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
raise FileNotFoundError("/usr/local/bin/xhs")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
ch = XiaoHongShuChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "error"
|
||||
assert "无法执行" in msg
|
||||
assert "uv tool install --force xiaohongshu-cli" in msg
|
||||
assert "pipx reinstall xiaohongshu-cli" in msg
|
||||
assert ch.active_backend is None
|
||||
|
||||
|
||||
class TestBilibiliChannel:
|
||||
def test_reports_error_with_reinstall_hint_when_ytdlp_broken(self, monkeypatch):
|
||||
"""yt-dlp which 命中但 exec 失败(venv 断链)→ error + 重装处方。"""
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/yt-dlp")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
raise FileNotFoundError(cmd[0])
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.bilibili import BilibiliChannel
|
||||
ch = BilibiliChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "error"
|
||||
assert "无法执行" in msg
|
||||
assert "uv tool install --force yt-dlp" in msg
|
||||
assert "pipx reinstall yt-dlp" in msg
|
||||
assert ch.active_backend is None
|
||||
|
||||
def test_active_backend_set_when_ytdlp_and_bili_ok(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
shutil, "which",
|
||||
lambda cmd: f"/usr/local/bin/{cmd}" if cmd in ("yt-dlp", "bili") else None,
|
||||
)
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 0, "2026.06.09", "")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.bilibili import BilibiliChannel
|
||||
ch = BilibiliChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "ok"
|
||||
assert "bili-cli 可用" in msg
|
||||
assert ch.active_backend == "yt-dlp"
|
||||
|
||||
def test_bili_broken_does_not_count_as_available(self, monkeypatch):
|
||||
"""bili-cli 断链时不计为可用,降级走搜索 API;yt-dlp 仍是 active_backend。"""
|
||||
monkeypatch.setattr(
|
||||
shutil, "which",
|
||||
lambda cmd: f"/usr/local/bin/{cmd}" if cmd in ("yt-dlp", "bili") else None,
|
||||
)
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if "yt-dlp" in cmd[0]:
|
||||
return subprocess.CompletedProcess(cmd, 0, "2026.06.09", "")
|
||||
raise FileNotFoundError(cmd[0])
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
import agent_reach.channels.bilibili as bilibili_mod
|
||||
monkeypatch.setattr(bilibili_mod, "_search_api_ok", lambda: True)
|
||||
ch = bilibili_mod.BilibiliChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "ok" # 搜索 API 兜底
|
||||
assert "不计为可用" in msg
|
||||
assert ch.active_backend == "yt-dlp"
|
||||
|
||||
|
||||
class TestYouTubeChannel:
|
||||
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
|
||||
"""yt-dlp which 命中但 exec 抛 FileNotFoundError → error + 重装处方。"""
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/yt-dlp")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
raise FileNotFoundError(cmd[0])
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.youtube import YouTubeChannel
|
||||
ch = YouTubeChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "error"
|
||||
assert "无法执行" in msg
|
||||
assert "uv tool install --force yt-dlp" in msg
|
||||
assert ch.active_backend is None
|
||||
|
||||
|
||||
class TestGitHubChannel:
|
||||
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
|
||||
"""gh which 命中但 exec 失败 → error + brew 重装处方(gh 不是 pip 包)。"""
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/gh")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
raise FileNotFoundError(cmd[0])
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.github import GitHubChannel
|
||||
ch = GitHubChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "error"
|
||||
assert "无法执行" in msg
|
||||
assert "brew reinstall gh" in msg
|
||||
assert ch.active_backend is None
|
||||
|
||||
def test_active_backend_set_when_authenticated(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/gh")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 0, "Logged in to github.com", "")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.github import GitHubChannel
|
||||
ch = GitHubChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "ok"
|
||||
assert ch.active_backend == "gh CLI"
|
||||
|
||||
def test_active_backend_set_when_unauthenticated(self, monkeypatch):
|
||||
"""gh auth status 非零退出是正常业务态(未登录):warn 但后端可用。"""
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/gh")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 1, "", "You are not logged in")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.github import GitHubChannel
|
||||
ch = GitHubChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "warn"
|
||||
assert "gh auth login" in msg
|
||||
assert ch.active_backend == "gh CLI"
|
||||
|
||||
|
||||
class TestLinkedInChannel:
|
||||
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
|
||||
"""mcporter which 命中但 exec 失败 → error + npm 重装处方。"""
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
raise FileNotFoundError(cmd[0])
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.linkedin import LinkedInChannel
|
||||
ch = LinkedInChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "error"
|
||||
assert "npm install -g mcporter" in msg
|
||||
assert ch.active_backend is None
|
||||
|
||||
def test_active_backend_set_when_linkedin_configured(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 0, "linkedin http://localhost:3000/mcp", "")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.linkedin import LinkedInChannel
|
||||
ch = LinkedInChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "ok"
|
||||
assert ch.active_backend == "linkedin-scraper-mcp"
|
||||
|
||||
def test_off_without_backend_when_linkedin_not_configured(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 0, "exa https://mcp.exa.ai/mcp", "")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.linkedin import LinkedInChannel
|
||||
ch = LinkedInChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "off"
|
||||
assert ch.active_backend is None
|
||||
|
||||
|
||||
class TestExaSearchChannel:
|
||||
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
|
||||
"""mcporter which 命中但 exec 失败 → error + npm 重装处方。"""
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
raise FileNotFoundError(cmd[0])
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.exa_search import ExaSearchChannel
|
||||
ch = ExaSearchChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "error"
|
||||
assert "npm install -g mcporter" in msg
|
||||
assert ch.active_backend is None
|
||||
|
||||
def test_active_backend_set_when_exa_configured(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 0, "exa https://mcp.exa.ai/mcp", "")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.exa_search import ExaSearchChannel
|
||||
ch = ExaSearchChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "ok"
|
||||
assert ch.active_backend == "Exa via mcporter"
|
||||
|
||||
|
||||
class TestXiaoyuzhouChannel:
|
||||
def test_reports_error_with_reinstall_hint_when_ffmpeg_broken(self, monkeypatch):
|
||||
"""ffmpeg which 命中但 exec 失败(pip 假 ffmpeg 断链)→ error + 重装处方。"""
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/ffmpeg")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
raise FileNotFoundError(cmd[0])
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from agent_reach.channels.xiaoyuzhou import XiaoyuzhouChannel
|
||||
ch = XiaoyuzhouChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "error"
|
||||
assert "无法执行" in msg
|
||||
assert "brew install ffmpeg" in msg
|
||||
assert ch.active_backend is None
|
||||
|
||||
def test_active_backend_set_when_fully_configured(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/ffmpeg")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 0, "ffmpeg version 7.0", "")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True) # transcribe.sh 已安装
|
||||
monkeypatch.setenv("GROQ_API_KEY", "gsk_test")
|
||||
from agent_reach.channels.xiaoyuzhou import XiaoyuzhouChannel
|
||||
ch = XiaoyuzhouChannel()
|
||||
status, msg = ch.check()
|
||||
assert status == "ok"
|
||||
assert ch.active_backend == "groq-whisper"
|
||||
|
||||
@@ -8,13 +8,15 @@ from agent_reach.config import Config
|
||||
|
||||
|
||||
class _StubChannel:
|
||||
def __init__(self, name, description, tier, status, message, backends=None):
|
||||
def __init__(self, name, description, tier, status, message, backends=None,
|
||||
active_backend=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.tier = tier
|
||||
self._status = status
|
||||
self._message = message
|
||||
self.backends = backends or []
|
||||
self.active_backend = active_backend
|
||||
|
||||
def check(self, config=None):
|
||||
return self._status, self._message
|
||||
@@ -31,7 +33,8 @@ class TestDoctor:
|
||||
doctor,
|
||||
"get_all_channels",
|
||||
lambda: [
|
||||
_StubChannel("web", "网页", 0, "ok", "可抓取网页", ["requests"]),
|
||||
_StubChannel("web", "网页", 0, "ok", "可抓取网页", ["requests"],
|
||||
active_backend="requests"),
|
||||
_StubChannel("github", "GitHub", 0, "warn", "gh 未安装", ["gh"]),
|
||||
_StubChannel("exa_search", "全网语义搜索", 1, "off", "mcporter 未配置", ["Exa"]),
|
||||
],
|
||||
@@ -46,6 +49,7 @@ class TestDoctor:
|
||||
"message": "可抓取网页",
|
||||
"tier": 0,
|
||||
"backends": ["requests"],
|
||||
"active_backend": "requests",
|
||||
},
|
||||
"github": {
|
||||
"status": "warn",
|
||||
@@ -53,6 +57,7 @@ class TestDoctor:
|
||||
"message": "gh 未安装",
|
||||
"tier": 0,
|
||||
"backends": ["gh"],
|
||||
"active_backend": None,
|
||||
},
|
||||
"exa_search": {
|
||||
"status": "off",
|
||||
@@ -60,6 +65,7 @@ class TestDoctor:
|
||||
"message": "mcporter 未配置",
|
||||
"tier": 1,
|
||||
"backends": ["Exa"],
|
||||
"active_backend": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for agent_reach.probe — real-execution probing and failure classification."""
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_reach.probe import ProbeResult, probe_command, reinstall_hint
|
||||
|
||||
|
||||
def _make_executable(path, content):
|
||||
path.write_text(content)
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_missing_command():
|
||||
r = probe_command("definitely-not-a-real-command-xyz")
|
||||
assert r.status == "missing"
|
||||
assert not r.ok
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="shebang semantics are POSIX-only")
|
||||
def test_broken_shebang_detected_as_broken(tmp_path, monkeypatch):
|
||||
"""A stale venv shim: which() finds it, exec raises FileNotFoundError."""
|
||||
script = _make_executable(
|
||||
tmp_path / "stale-tool", "#!/nonexistent/venv/bin/python\nprint('hi')\n"
|
||||
)
|
||||
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
|
||||
|
||||
r = probe_command("stale-tool", package="stale-tool-pkg")
|
||||
assert r.status == "broken"
|
||||
assert "uv tool install --force stale-tool-pkg" in r.hint
|
||||
assert "pipx reinstall stale-tool-pkg" in r.hint
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only")
|
||||
def test_healthy_command_returns_ok_with_output(tmp_path, monkeypatch):
|
||||
script = _make_executable(
|
||||
tmp_path / "healthy-tool", "#!/bin/sh\necho 'healthy-tool 1.2.3'\n"
|
||||
)
|
||||
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
|
||||
|
||||
r = probe_command("healthy-tool")
|
||||
assert r.ok
|
||||
assert "1.2.3" in r.output
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only")
|
||||
def test_nonzero_exit_classified_as_error(tmp_path, monkeypatch):
|
||||
script = _make_executable(
|
||||
tmp_path / "failing-tool", "#!/bin/sh\necho 'boom' >&2\nexit 3\n"
|
||||
)
|
||||
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
|
||||
|
||||
r = probe_command("failing-tool")
|
||||
assert r.status == "error"
|
||||
assert "boom" in r.output
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only")
|
||||
def test_exit_127_classified_as_broken(tmp_path, monkeypatch):
|
||||
script = _make_executable(tmp_path / "wrapper-tool", "#!/bin/sh\nexit 127\n")
|
||||
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
|
||||
|
||||
r = probe_command("wrapper-tool", package="wrapper-pkg")
|
||||
assert r.status == "broken"
|
||||
assert "wrapper-pkg" in r.hint
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only")
|
||||
def test_retries_help_transient_failures(tmp_path, monkeypatch):
|
||||
"""First call fails (exit 1), second succeeds — retries=1 should return ok."""
|
||||
marker = tmp_path / "ran-once"
|
||||
script = _make_executable(
|
||||
tmp_path / "flaky-tool",
|
||||
f"#!/bin/sh\nif [ -f {marker} ]; then echo ok; exit 0; fi\ntouch {marker}\nexit 1\n",
|
||||
)
|
||||
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
|
||||
|
||||
r = probe_command("flaky-tool", retries=1)
|
||||
assert r.ok
|
||||
|
||||
|
||||
def test_reinstall_hint_mentions_both_installers():
|
||||
hint = reinstall_hint("some-pkg")
|
||||
assert "uv tool install --force some-pkg" in hint
|
||||
assert "pipx reinstall some-pkg" in hint
|
||||
@@ -26,6 +26,7 @@ def test_check_twitter_cli_found_and_auth_ok():
|
||||
assert status == "ok"
|
||||
assert "twitter-cli" in message
|
||||
assert "完整可用" in message
|
||||
assert channel.active_backend == "twitter-cli"
|
||||
|
||||
|
||||
def test_check_twitter_cli_found_auth_missing():
|
||||
@@ -41,6 +42,8 @@ def test_check_twitter_cli_found_auth_missing():
|
||||
status, message = channel.check()
|
||||
assert status == "warn"
|
||||
assert "未认证" in message
|
||||
# 未认证是业务态:工具进程活着,后端仍可用
|
||||
assert channel.active_backend == "twitter-cli"
|
||||
|
||||
|
||||
# --- bird CLI fallback tests ---
|
||||
@@ -59,6 +62,7 @@ def test_check_bird_fallback_auth_ok():
|
||||
status, message = channel.check()
|
||||
assert status == "ok"
|
||||
assert "bird" in message
|
||||
assert channel.active_backend == "bird CLI (legacy)"
|
||||
|
||||
|
||||
def test_check_bird_fallback_auth_missing():
|
||||
@@ -86,6 +90,7 @@ def test_check_nothing_installed():
|
||||
status, message = channel.check()
|
||||
assert status == "warn"
|
||||
assert "twitter-cli" in message
|
||||
assert channel.active_backend is None
|
||||
|
||||
|
||||
# --- twitter-cli preferred over bird ---
|
||||
@@ -106,3 +111,44 @@ def test_twitter_cli_preferred_over_bird():
|
||||
status, message = channel.check()
|
||||
assert status == "ok"
|
||||
assert "twitter-cli" in message
|
||||
assert channel.active_backend == "twitter-cli"
|
||||
|
||||
|
||||
# --- broken install (stale venv shim) ---
|
||||
|
||||
def test_check_twitter_cli_broken_reports_error_with_reinstall_hint():
|
||||
"""which 命中但 exec 抛 FileNotFoundError(venv 断链)→ error + 重装处方。"""
|
||||
channel = TwitterChannel()
|
||||
with patch(
|
||||
"shutil.which",
|
||||
side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None,
|
||||
), patch("subprocess.run", side_effect=FileNotFoundError("/usr/local/bin/twitter")):
|
||||
status, message = channel.check()
|
||||
assert status == "error"
|
||||
assert "无法执行" in message
|
||||
assert "uv tool install --force twitter-cli" in message
|
||||
assert "pipx reinstall twitter-cli" in message
|
||||
assert channel.active_backend is None
|
||||
|
||||
|
||||
def test_check_twitter_cli_broken_falls_back_to_bird():
|
||||
"""twitter-cli 断链但 bird 健康 → 回退到 bird,后端正确归属。"""
|
||||
channel = TwitterChannel()
|
||||
|
||||
def which_side_effect(name):
|
||||
if name in ("twitter", "bird"):
|
||||
return f"/usr/local/bin/{name}"
|
||||
return None
|
||||
|
||||
def run_side_effect(cmd, **kwargs):
|
||||
if "twitter" in cmd[0]:
|
||||
raise FileNotFoundError(cmd[0])
|
||||
return _cp(stdout="Authenticated as @user\n", returncode=0)
|
||||
|
||||
with patch("shutil.which", side_effect=which_side_effect), patch(
|
||||
"subprocess.run", side_effect=run_side_effect
|
||||
):
|
||||
status, message = channel.check()
|
||||
assert status == "ok"
|
||||
assert "bird" in message
|
||||
assert channel.active_backend == "bird CLI (legacy)"
|
||||
|
||||
Reference in New Issue
Block a user