Track bundled vendor runtime sources
This commit is contained in:
183
vendor/Agent-Reach/tests/test_channel_contracts.py
vendored
Normal file
183
vendor/Agent-Reach/tests/test_channel_contracts.py
vendored
Normal file
@@ -0,0 +1,183 @@
|
||||
# -*- 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"
|
||||
names = [ch.name for ch in channels]
|
||||
assert len(names) == len(set(names)), "channel names must be unique"
|
||||
|
||||
for ch in channels:
|
||||
assert isinstance(ch.name, str) and ch.name
|
||||
assert isinstance(ch.description, str) and ch.description
|
||||
assert isinstance(ch.backends, list)
|
||||
assert ch.tier in {0, 1, 2}
|
||||
|
||||
|
||||
def test_channel_check_contract_with_minimal_runtime(monkeypatch, tmp_path):
|
||||
# Keep contract tests deterministic by simulating "deps mostly absent".
|
||||
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
||||
config = Config(config_path=tmp_path / "config.yaml")
|
||||
|
||||
for ch in get_all_channels():
|
||||
status, message = ch.check(config)
|
||||
assert status in {"ok", "warn", "off", "error"}
|
||||
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
|
||||
|
||||
def fake_which(cmd):
|
||||
if cmd == "yt-dlp":
|
||||
return "/usr/bin/yt-dlp"
|
||||
if cmd == "node":
|
||||
return "/usr/bin/node"
|
||||
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"))
|
||||
|
||||
ch = YouTubeChannel()
|
||||
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):
|
||||
"""Windows guidance should use a PowerShell-style yt-dlp config command."""
|
||||
from agent_reach.channels.youtube import YouTubeChannel
|
||||
|
||||
def fake_which(cmd):
|
||||
if cmd == "yt-dlp":
|
||||
return "C:/yt-dlp.exe"
|
||||
if cmd == "node":
|
||||
return "C:/node.exe"
|
||||
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"))
|
||||
|
||||
ch = YouTubeChannel()
|
||||
status, message = ch.check()
|
||||
assert status == "warn"
|
||||
assert "Select-String" in message
|
||||
assert "--js-runtimes node" in message
|
||||
|
||||
|
||||
def test_youtube_ok_when_deno_installed(monkeypatch):
|
||||
"""YouTube should return ok when Deno is installed (no config needed)."""
|
||||
from agent_reach.channels.youtube import YouTubeChannel
|
||||
|
||||
def fake_which(cmd):
|
||||
if cmd == "yt-dlp":
|
||||
return "/usr/bin/yt-dlp"
|
||||
if cmd == "deno":
|
||||
return "/usr/bin/deno"
|
||||
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():
|
||||
url_samples = {
|
||||
"github": "https://github.com/panniantong/agent-reach",
|
||||
"twitter": "https://x.com/user/status/1",
|
||||
"youtube": "https://youtube.com/watch?v=abc",
|
||||
"reddit": "https://reddit.com/r/python",
|
||||
"bilibili": "https://www.bilibili.com/video/BV1xx411",
|
||||
"xiaohongshu": "https://www.xiaohongshu.com/explore/123",
|
||||
"linkedin": "https://www.linkedin.com/in/test",
|
||||
"rss": "https://example.com/feed.xml",
|
||||
"xueqiu": "https://xueqiu.com/S/SH600519",
|
||||
"exa_search": "https://example.com",
|
||||
"web": "https://example.com",
|
||||
}
|
||||
for ch in get_all_channels():
|
||||
sample = url_samples.get(ch.name, "https://example.com")
|
||||
result = ch.can_handle(sample)
|
||||
assert isinstance(result, bool)
|
||||
1211
vendor/Agent-Reach/tests/test_channels.py
vendored
Normal file
1211
vendor/Agent-Reach/tests/test_channels.py
vendored
Normal file
File diff suppressed because it is too large
Load Diff
224
vendor/Agent-Reach/tests/test_cli.py
vendored
Normal file
224
vendor/Agent-Reach/tests/test_cli.py
vendored
Normal file
@@ -0,0 +1,224 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for Agent Reach CLI."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import agent_reach.cli as cli
|
||||
from agent_reach.cli import main
|
||||
|
||||
|
||||
class TestCLI:
|
||||
def test_version(self, capsys):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with patch("sys.argv", ["agent-reach", "version"]):
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "Agent Reach v" in captured.out
|
||||
|
||||
def test_no_command_shows_help(self, capsys):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
with patch("sys.argv", ["agent-reach"]):
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
def test_doctor_runs(self, capsys):
|
||||
with patch("sys.argv", ["agent-reach", "doctor"]):
|
||||
main()
|
||||
captured = capsys.readouterr()
|
||||
assert "Agent Reach" in captured.out
|
||||
assert "✅" in captured.out
|
||||
|
||||
def test_transcribe_command_prints_text(self, capsys):
|
||||
with patch("agent_reach.transcribe.transcribe", return_value="hello transcript"):
|
||||
with patch("sys.argv", ["agent-reach", "transcribe", "audio.mp3"]):
|
||||
main()
|
||||
captured = capsys.readouterr()
|
||||
assert "hello transcript" in captured.out
|
||||
|
||||
def test_transcribe_command_writes_output_file(self, capsys, tmp_path):
|
||||
out_file = tmp_path / "t.txt"
|
||||
with patch("agent_reach.transcribe.transcribe", return_value="saved text"):
|
||||
with patch("sys.argv", ["agent-reach", "transcribe", "audio.mp3", "-o", str(out_file)]):
|
||||
main()
|
||||
assert out_file.read_text(encoding="utf-8").strip() == "saved text"
|
||||
assert "Transcript written" in capsys.readouterr().out
|
||||
|
||||
def test_parse_twitter_cookie_input_separate_values(self):
|
||||
auth_token, ct0 = cli._parse_twitter_cookie_input("token123 ct0abc")
|
||||
assert auth_token == "token123"
|
||||
assert ct0 == "ct0abc"
|
||||
|
||||
def test_parse_twitter_cookie_input_cookie_header(self):
|
||||
auth_token, ct0 = cli._parse_twitter_cookie_input(
|
||||
"auth_token=token123; ct0=ct0abc; other=value"
|
||||
)
|
||||
assert auth_token == "token123"
|
||||
assert ct0 == "ct0abc"
|
||||
|
||||
def test_install_rdt_cli_prefers_github_source(self, monkeypatch, capsys):
|
||||
state = {"rdt_installed": False}
|
||||
commands = []
|
||||
|
||||
def fake_which(name):
|
||||
if name == "rdt":
|
||||
return "/usr/local/bin/rdt" if state["rdt_installed"] else None
|
||||
if name == "pipx":
|
||||
return "/usr/local/bin/pipx"
|
||||
return None
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
commands.append(cmd)
|
||||
state["rdt_installed"] = True
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
monkeypatch.setattr(shutil, "which", fake_which)
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
cli._install_rdt_cli()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert commands == [["pipx", "install", cli._RDT_GIT_SOURCE]]
|
||||
assert "✅ rdt-cli installed" in out
|
||||
|
||||
def test_install_reddit_deps_routes_by_environment(self, monkeypatch):
|
||||
"""桌面 → OpenCLI;服务器 → rdt-cli(钉 git 源)。"""
|
||||
calls = []
|
||||
monkeypatch.setattr(cli, "_install_opencli_deps", lambda: calls.append("opencli"))
|
||||
monkeypatch.setattr(cli, "_install_rdt_cli", lambda: calls.append("rdt"))
|
||||
monkeypatch.setattr(shutil, "which", lambda _: None)
|
||||
|
||||
monkeypatch.setattr(cli, "_detect_environment", lambda: "local")
|
||||
cli._install_reddit_deps()
|
||||
assert calls == ["opencli"]
|
||||
|
||||
calls.clear()
|
||||
monkeypatch.setattr(cli, "_detect_environment", lambda: "server")
|
||||
cli._install_reddit_deps()
|
||||
assert calls == ["rdt"]
|
||||
|
||||
|
||||
class TestCheckUpdateRetry:
|
||||
def test_retry_timeout_classification(self):
|
||||
sleeps = []
|
||||
|
||||
def fake_sleep(seconds):
|
||||
sleeps.append(seconds)
|
||||
|
||||
with patch("requests.get", side_effect=requests.exceptions.Timeout("timed out")):
|
||||
resp, err, attempts = cli._github_get_with_retry(
|
||||
"https://api.github.com/test",
|
||||
timeout=1,
|
||||
retries=3,
|
||||
sleeper=fake_sleep,
|
||||
)
|
||||
|
||||
assert resp is None
|
||||
assert err == "timeout"
|
||||
assert attempts == 3
|
||||
assert sleeps == [1, 2]
|
||||
|
||||
def test_retry_dns_classification(self):
|
||||
error = requests.exceptions.ConnectionError("getaddrinfo failed for api.github.com")
|
||||
with patch("requests.get", side_effect=error):
|
||||
resp, err, attempts = cli._github_get_with_retry(
|
||||
"https://api.github.com/test",
|
||||
retries=1,
|
||||
sleeper=lambda _x: None,
|
||||
)
|
||||
assert resp is None
|
||||
assert err == "dns"
|
||||
assert attempts == 1
|
||||
|
||||
def test_retry_rate_limit_then_success(self):
|
||||
sleeps = []
|
||||
|
||||
class R:
|
||||
def __init__(self, code, payload=None, headers=None):
|
||||
self.status_code = code
|
||||
self._payload = payload or {}
|
||||
self.headers = headers or {}
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
sequence = [
|
||||
R(429, headers={"Retry-After": "3"}),
|
||||
R(200, payload={"tag_name": "v1.5.0"}),
|
||||
]
|
||||
|
||||
with patch("requests.get", side_effect=sequence):
|
||||
resp, err, attempts = cli._github_get_with_retry(
|
||||
"https://api.github.com/test",
|
||||
retries=3,
|
||||
sleeper=lambda s: sleeps.append(s),
|
||||
)
|
||||
|
||||
assert err is None
|
||||
assert resp is not None
|
||||
assert resp.status_code == 200
|
||||
assert attempts == 2
|
||||
assert sleeps == [3.0]
|
||||
|
||||
def test_classify_rate_limit_from_403(self):
|
||||
class R:
|
||||
status_code = 403
|
||||
headers = {"X-RateLimit-Remaining": "0"}
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {"message": "API rate limit exceeded"}
|
||||
|
||||
assert cli._classify_github_response_error(R()) == "rate_limit"
|
||||
|
||||
def test_check_update_reports_classified_error(self, capsys):
|
||||
with patch("agent_reach.cli._github_get_with_retry", return_value=(None, "timeout", 3)):
|
||||
result = cli._cmd_check_update()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert result == "error"
|
||||
assert "网络超时" in captured.out
|
||||
assert "已重试 3 次" in captured.out
|
||||
|
||||
|
||||
class TestVersionCompare:
|
||||
def test_newer_remote_triggers_update(self):
|
||||
assert cli._is_newer_version("1.5.0", "1.4.2") is True
|
||||
|
||||
def test_equal_versions_no_update(self):
|
||||
assert cli._is_newer_version("1.5.0", "1.5.0") is False
|
||||
|
||||
def test_local_ahead_of_release_no_downgrade_prompt(self):
|
||||
"""发版窗口期本地装了 main(更新)时,不能提示"有更新"诱导降级。"""
|
||||
assert cli._is_newer_version("1.4.2", "1.5.0") is False
|
||||
|
||||
def test_unparseable_falls_back_to_inequality(self):
|
||||
assert cli._is_newer_version("2026.06-beta", "1.5.0") is True
|
||||
assert cli._is_newer_version("1.5.0", "1.5.0-dev") is True
|
||||
|
||||
|
||||
class TestWatchVersionCompare:
|
||||
def test_watch_does_not_prompt_downgrade(self, monkeypatch, capsys):
|
||||
"""watch 与 check-update 同语义:本地领先远端 release 时不提示更新。"""
|
||||
class R:
|
||||
status_code = 200
|
||||
headers = {}
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {"tag_name": "v1.4.2", "body": ""}
|
||||
|
||||
monkeypatch.setattr(cli, "_github_get_with_retry", lambda *a, **k: (R(), None, 1))
|
||||
monkeypatch.setattr(
|
||||
"agent_reach.doctor.check_all",
|
||||
lambda config: {"web": {"status": "ok", "name": "任意网页", "message": "ok",
|
||||
"tier": 0, "backends": ["Jina Reader"], "active_backend": "Jina Reader"}},
|
||||
)
|
||||
cli._cmd_watch()
|
||||
out = capsys.readouterr().out
|
||||
assert "新版本可用" not in out
|
||||
assert "全部正常" in out
|
||||
88
vendor/Agent-Reach/tests/test_config.py
vendored
Normal file
88
vendor/Agent-Reach/tests/test_config.py
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for Agent Reach config module."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from agent_reach.config import Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path):
|
||||
"""Create a Config with a temporary directory."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
return Config(config_path=config_file)
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_init_creates_dir(self, tmp_path):
|
||||
config_file = tmp_path / "subdir" / "config.yaml"
|
||||
config = Config(config_path=config_file)
|
||||
assert config_file.parent.exists()
|
||||
|
||||
def test_set_and_get(self, tmp_config):
|
||||
tmp_config.set("test_key", "test_value")
|
||||
assert tmp_config.get("test_key") == "test_value"
|
||||
|
||||
def test_get_default(self, tmp_config):
|
||||
assert tmp_config.get("nonexistent") is None
|
||||
assert tmp_config.get("nonexistent", "default") == "default"
|
||||
|
||||
def test_get_from_env(self, tmp_config, monkeypatch):
|
||||
monkeypatch.setenv("TEST_ENV_KEY", "env_value")
|
||||
assert tmp_config.get("test_env_key") == "env_value"
|
||||
|
||||
def test_config_file_priority_over_env(self, tmp_config, monkeypatch):
|
||||
monkeypatch.setenv("MY_KEY", "from_env")
|
||||
tmp_config.set("my_key", "from_config")
|
||||
assert tmp_config.get("my_key") == "from_config"
|
||||
|
||||
def test_save_and_load(self, tmp_config):
|
||||
tmp_config.set("key1", "value1")
|
||||
tmp_config.set("key2", 42)
|
||||
|
||||
# Create new config from same file
|
||||
config2 = Config(config_path=tmp_config.config_path)
|
||||
assert config2.get("key1") == "value1"
|
||||
assert config2.get("key2") == 42
|
||||
|
||||
def test_delete(self, tmp_config):
|
||||
tmp_config.set("to_delete", "value")
|
||||
assert tmp_config.get("to_delete") == "value"
|
||||
tmp_config.delete("to_delete")
|
||||
assert tmp_config.get("to_delete") is None
|
||||
|
||||
def test_is_configured(self, tmp_config):
|
||||
assert not tmp_config.is_configured("exa_search")
|
||||
tmp_config.set("exa_api_key", "test-key")
|
||||
assert tmp_config.is_configured("exa_search")
|
||||
|
||||
def test_get_configured_features(self, tmp_config):
|
||||
features = tmp_config.get_configured_features()
|
||||
assert isinstance(features, dict)
|
||||
assert "exa_search" in features
|
||||
assert all(v is False for v in features.values())
|
||||
|
||||
def test_to_dict_masks_sensitive(self, tmp_config):
|
||||
tmp_config.set("exa_api_key", "super-secret-key-12345")
|
||||
tmp_config.set("normal_setting", "visible")
|
||||
masked = tmp_config.to_dict()
|
||||
assert masked["exa_api_key"] == "super-se..."
|
||||
assert masked["normal_setting"] == "visible"
|
||||
|
||||
def test_save_creates_file_with_restricted_permissions(self, tmp_path):
|
||||
import stat
|
||||
import sys
|
||||
config_file = tmp_path / "secure_config.yaml"
|
||||
config = Config(config_path=config_file)
|
||||
config.set("secret_key", "my-secret")
|
||||
|
||||
if sys.platform != "win32":
|
||||
mode = config_file.stat().st_mode
|
||||
# File should be owner-only read/write (0o600)
|
||||
assert not (mode & stat.S_IRGRP), "group read should not be set"
|
||||
assert not (mode & stat.S_IROTH), "other read should not be set"
|
||||
89
vendor/Agent-Reach/tests/test_cookie_extract_perms.py
vendored
Normal file
89
vendor/Agent-Reach/tests/test_cookie_extract_perms.py
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Verify credential files written by cookie sync helpers and CLI helpers
|
||||
are owner-only (0o600) and that values containing shell metacharacters do
|
||||
not break the shell-sourceable env file produced by _sync_bird_env().
|
||||
|
||||
Companion to tests/test_config.py::test_save_creates_file_with_restricted_permissions —
|
||||
the same threat-model claim ("Cookie/Token only stored locally, 600
|
||||
permissions") covers these paths.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_reach.cookie_extract import _sync_bird_env, _sync_xfetch_session
|
||||
|
||||
|
||||
def _owner_only(path: str) -> bool:
|
||||
mode = os.stat(path).st_mode
|
||||
return not (mode & (stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH | stat.S_IWOTH))
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX perm semantics only")
|
||||
def test_sync_xfetch_session_writes_0600(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
_sync_xfetch_session("auth_xxx", "ct0_yyy")
|
||||
session_path = tmp_path / ".config" / "xfetch" / "session.json"
|
||||
assert session_path.exists(), "expected ~/.config/xfetch/session.json"
|
||||
assert _owner_only(str(session_path)), "session.json must be 0o600"
|
||||
# Round-trip the content so we know we didn't accidentally corrupt JSON.
|
||||
data = json.loads(session_path.read_text(encoding="utf-8"))
|
||||
assert data["authToken"] == "auth_xxx"
|
||||
assert data["ct0"] == "ct0_yyy"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX perm semantics only")
|
||||
def test_sync_bird_env_writes_0600(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
_sync_bird_env("auth_xxx", "ct0_yyy")
|
||||
env_path = tmp_path / ".config" / "bird" / "credentials.env"
|
||||
assert env_path.exists(), "expected ~/.config/bird/credentials.env"
|
||||
assert _owner_only(str(env_path)), "credentials.env must be 0o600"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX sh needed for sourcing")
|
||||
def test_sync_bird_env_quotes_shell_metachars(tmp_path, monkeypatch):
|
||||
"""Tokens containing ", $, `, ; etc. must not break out of the assignment.
|
||||
|
||||
Prior implementation used `f'AUTH_TOKEN="{auth_token}"'` which an attacker-
|
||||
controlled cookie containing a literal `"` could break out of, turning a
|
||||
later `source ~/.config/bird/credentials.env` into arbitrary shell.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
# Side-effect markers live under tmp_path (auto-cleaned by pytest) rather
|
||||
# than a shared absolute /tmp path — otherwise one vulnerable run leaves a
|
||||
# marker behind that fails every later run on the same machine/CI runner.
|
||||
pwn_auth = tmp_path / "pwn-auth"
|
||||
pwn_ct0 = tmp_path / "pwn-ct0"
|
||||
hostile_auth = f'inj"; touch {pwn_auth}; #'
|
||||
hostile_ct0 = f"ct0_$(touch {pwn_ct0})"
|
||||
_sync_bird_env(hostile_auth, hostile_ct0)
|
||||
env_path = tmp_path / ".config" / "bird" / "credentials.env"
|
||||
|
||||
# Sourcing the file must NOT execute the injected payload. Read back the
|
||||
# exported values from a subshell instead — they should equal the originals.
|
||||
probe = (
|
||||
f". {env_path}; "
|
||||
f'printf "AUTH=%s\\nCT0=%s\\n" "$AUTH_TOKEN" "$CT0"'
|
||||
)
|
||||
result = subprocess.run(
|
||||
["sh", "-c", probe],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
lines = dict(
|
||||
line.split("=", 1) for line in result.stdout.strip().splitlines() if "=" in line
|
||||
)
|
||||
assert lines["AUTH"] == hostile_auth, "auth_token round-trip broke — injection possible"
|
||||
assert lines["CT0"] == hostile_ct0, "ct0 round-trip broke — injection possible"
|
||||
# And no side-effect files materialised.
|
||||
assert not pwn_auth.exists()
|
||||
assert not pwn_ct0.exists()
|
||||
29
vendor/Agent-Reach/tests/test_core.py
vendored
Normal file
29
vendor/Agent-Reach/tests/test_core.py
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for AgentReach core class."""
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_reach.config import Config
|
||||
from agent_reach.core import AgentReach
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def eyes(tmp_path):
|
||||
config = Config(config_path=tmp_path / "config.yaml")
|
||||
return AgentReach(config=config)
|
||||
|
||||
|
||||
class TestAgentReach:
|
||||
def test_init(self, eyes):
|
||||
assert eyes.config is not None
|
||||
|
||||
def test_doctor(self, eyes):
|
||||
results = eyes.doctor()
|
||||
assert isinstance(results, dict)
|
||||
assert "web" in results
|
||||
assert "github" in results
|
||||
|
||||
def test_doctor_report(self, eyes):
|
||||
report = eyes.doctor_report()
|
||||
assert isinstance(report, str)
|
||||
assert "Agent Reach" in report
|
||||
126
vendor/Agent-Reach/tests/test_doctor.py
vendored
Normal file
126
vendor/Agent-Reach/tests/test_doctor.py
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for doctor module."""
|
||||
|
||||
import pytest
|
||||
|
||||
import agent_reach.doctor as doctor
|
||||
from agent_reach.config import Config
|
||||
|
||||
|
||||
class _StubChannel:
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path):
|
||||
return Config(config_path=tmp_path / "config.yaml")
|
||||
|
||||
|
||||
class TestDoctor:
|
||||
def test_check_all_collects_channel_results(self, tmp_config, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
doctor,
|
||||
"get_all_channels",
|
||||
lambda: [
|
||||
_StubChannel("web", "网页", 0, "ok", "可抓取网页", ["requests"],
|
||||
active_backend="requests"),
|
||||
_StubChannel("github", "GitHub", 0, "warn", "gh 未安装", ["gh"]),
|
||||
_StubChannel("exa_search", "全网语义搜索", 1, "off", "mcporter 未配置", ["Exa"]),
|
||||
],
|
||||
)
|
||||
|
||||
results = doctor.check_all(tmp_config)
|
||||
|
||||
assert results == {
|
||||
"web": {
|
||||
"status": "ok",
|
||||
"name": "网页",
|
||||
"message": "可抓取网页",
|
||||
"tier": 0,
|
||||
"backends": ["requests"],
|
||||
"active_backend": "requests",
|
||||
},
|
||||
"github": {
|
||||
"status": "warn",
|
||||
"name": "GitHub",
|
||||
"message": "gh 未安装",
|
||||
"tier": 0,
|
||||
"backends": ["gh"],
|
||||
"active_backend": None,
|
||||
},
|
||||
"exa_search": {
|
||||
"status": "off",
|
||||
"name": "全网语义搜索",
|
||||
"message": "mcporter 未配置",
|
||||
"tier": 1,
|
||||
"backends": ["Exa"],
|
||||
"active_backend": None,
|
||||
},
|
||||
}
|
||||
|
||||
def test_format_report(self):
|
||||
report = doctor.format_report(
|
||||
{
|
||||
"web": {
|
||||
"status": "ok",
|
||||
"name": "网页",
|
||||
"message": "可抓取网页",
|
||||
"tier": 0,
|
||||
"backends": ["requests"],
|
||||
},
|
||||
"exa_search": {
|
||||
"status": "off",
|
||||
"name": "全网语义搜索",
|
||||
"message": "mcporter 未配置",
|
||||
"tier": 1,
|
||||
"backends": ["Exa"],
|
||||
},
|
||||
"xiaohongshu": {
|
||||
"status": "warn",
|
||||
"name": "小红书",
|
||||
"message": "MCP 已配置,但健康检查超时",
|
||||
"tier": 2,
|
||||
"backends": ["mcporter"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Strip Rich markup tags for assertion (PR #170 added [bold], [yellow] etc.)
|
||||
import re
|
||||
plain = re.sub(r"\[[^\]]*\]", "", report)
|
||||
assert "Agent Reach" in plain
|
||||
assert "装好即用:" in plain
|
||||
assert "1/3 个渠道可用" in plain
|
||||
# Inactive optional channels should be summarized in one line
|
||||
assert "可选渠道可以解锁" in plain
|
||||
|
||||
|
||||
def test_stale_active_backend_does_not_leak_into_errored_result(monkeypatch):
|
||||
"""渠道单例上一轮的 active_backend 不得泄漏进本轮异常结果(Codex review 发现)。"""
|
||||
from agent_reach import doctor
|
||||
|
||||
class _ExplodingChannel:
|
||||
name = "boom"
|
||||
description = "爆炸渠道"
|
||||
tier = 0
|
||||
backends = ["a", "b"]
|
||||
active_backend = "a" # 上一轮成功的残留
|
||||
|
||||
def check(self, config=None):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(doctor, "get_all_channels", lambda: [_ExplodingChannel()])
|
||||
results = doctor.check_all(config=None)
|
||||
assert results["boom"]["status"] == "error"
|
||||
assert results["boom"]["active_backend"] is None
|
||||
100
vendor/Agent-Reach/tests/test_opencli_backend.py
vendored
Normal file
100
vendor/Agent-Reach/tests/test_opencli_backend.py
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the OpenCLI cross-channel backend probing."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent_reach.backends import opencli_status, opencli_summary
|
||||
from agent_reach.probe import ProbeResult
|
||||
|
||||
|
||||
def _status_with(version_probe, daemon_probe=None, ext_on_disk=False):
|
||||
"""Run opencli_status with probe_command and disk check patched."""
|
||||
calls = []
|
||||
|
||||
def fake_probe(cmd, args=("--version",), **kwargs):
|
||||
calls.append(list(args))
|
||||
if list(args) == ["--version"]:
|
||||
return version_probe
|
||||
return daemon_probe or ProbeResult("missing")
|
||||
|
||||
with patch("agent_reach.backends.opencli.probe_command", side_effect=fake_probe), \
|
||||
patch(
|
||||
"agent_reach.backends.opencli._extension_installed_on_disk",
|
||||
return_value=ext_on_disk,
|
||||
):
|
||||
return opencli_status(), calls
|
||||
|
||||
|
||||
def test_not_installed():
|
||||
st, _ = _status_with(ProbeResult("missing"))
|
||||
assert not st.installed
|
||||
assert not st.ready
|
||||
assert "未安装" in opencli_summary(st)
|
||||
|
||||
|
||||
def test_broken_node_env_gives_npm_hint():
|
||||
st, _ = _status_with(ProbeResult("broken", hint="x"))
|
||||
assert st.installed and st.broken
|
||||
assert "npm install -g @jackwener/opencli" in st.hint
|
||||
assert not st.ready
|
||||
|
||||
|
||||
def test_daemon_running_extension_connected_is_ready():
|
||||
daemon_out = "Daemon: running (PID 37389)\nVersion: v1.8.3\nExtension: connected\n"
|
||||
st, _ = _status_with(
|
||||
ProbeResult("ok", output="1.8.3"),
|
||||
ProbeResult("ok", output=daemon_out),
|
||||
)
|
||||
assert st.installed and st.daemon_running and st.extension_connected
|
||||
assert st.ready
|
||||
assert "1.8.3" in opencli_summary(st)
|
||||
|
||||
|
||||
def test_extension_never_installed_not_ready_with_store_guide():
|
||||
daemon_out = "Daemon: running (PID 1)\nExtension: disconnected\n"
|
||||
st, _ = _status_with(
|
||||
ProbeResult("ok", output="1.8.3"),
|
||||
ProbeResult("ok", output=daemon_out),
|
||||
ext_on_disk=False,
|
||||
)
|
||||
assert st.daemon_running and not st.extension_connected
|
||||
assert not st.ready
|
||||
assert "chromewebstore.google.com" in st.hint
|
||||
|
||||
|
||||
def test_sleeping_extension_counts_as_ready():
|
||||
"""实测:扩展 service worker 睡眠时 daemon status 报 disconnected,
|
||||
但任何真实命令会唤醒它——装在磁盘上即视为可用。"""
|
||||
daemon_out = "Daemon: running (PID 1)\nExtension: disconnected\n"
|
||||
st, _ = _status_with(
|
||||
ProbeResult("ok", output="1.8.3"),
|
||||
ProbeResult("ok", output=daemon_out),
|
||||
ext_on_disk=True,
|
||||
)
|
||||
assert not st.extension_connected
|
||||
assert st.extension_installed
|
||||
assert st.ready
|
||||
assert "唤醒" in opencli_summary(st)
|
||||
assert st.hint == ""
|
||||
|
||||
|
||||
def test_daemon_not_running_parsed_correctly():
|
||||
st, _ = _status_with(
|
||||
ProbeResult("ok", output="1.8.3"),
|
||||
ProbeResult("ok", output="Daemon: not running\n"),
|
||||
)
|
||||
assert st.installed
|
||||
assert not st.daemon_running
|
||||
assert not st.extension_connected
|
||||
assert "自动启动" in opencli_summary(st)
|
||||
|
||||
|
||||
def test_probe_uses_daemon_status_not_doctor():
|
||||
"""`opencli doctor` auto-starts the daemon (side effect) — health checks
|
||||
must only ever call `daemon status`."""
|
||||
_, calls = _status_with(
|
||||
ProbeResult("ok", output="1.8.3"),
|
||||
ProbeResult("ok", output="Daemon: not running\n"),
|
||||
)
|
||||
assert ["daemon", "status"] in calls
|
||||
assert ["doctor"] not in calls
|
||||
90
vendor/Agent-Reach/tests/test_probe.py
vendored
Normal file
90
vendor/Agent-Reach/tests/test_probe.py
vendored
Normal file
@@ -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
|
||||
18
vendor/Agent-Reach/tests/test_process.py
vendored
Normal file
18
vendor/Agent-Reach/tests/test_process.py
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
from agent_reach.utils.process import mcporter_utf8_env_args, utf8_subprocess_env
|
||||
|
||||
|
||||
def test_utf8_subprocess_env_forces_python_utf8():
|
||||
env = utf8_subprocess_env({"PYTHONUTF8": "0", "OTHER": "value"})
|
||||
|
||||
assert env["PYTHONUTF8"] == "1"
|
||||
assert env["PYTHONIOENCODING"] == "utf-8"
|
||||
assert env["OTHER"] == "value"
|
||||
|
||||
|
||||
def test_mcporter_utf8_env_args():
|
||||
assert mcporter_utf8_env_args() == [
|
||||
"--env",
|
||||
"PYTHONUTF8=1",
|
||||
"--env",
|
||||
"PYTHONIOENCODING=utf-8",
|
||||
]
|
||||
125
vendor/Agent-Reach/tests/test_skill_command.py
vendored
Normal file
125
vendor/Agent-Reach/tests/test_skill_command.py
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for 'agent-reach skill' command and _install_skill / _uninstall_skill."""
|
||||
|
||||
import importlib.resources
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent_reach.cli import _install_skill, _uninstall_skill
|
||||
|
||||
|
||||
class TestSkillCommand(unittest.TestCase):
|
||||
"""Test skill install and uninstall via CLI helpers."""
|
||||
|
||||
def test_skill_resources_include_both_locales(self):
|
||||
"""Package resources should expose both default and English skill markdown files."""
|
||||
skill_dir = importlib.resources.files("agent_reach").joinpath("skill")
|
||||
|
||||
default_skill = skill_dir.joinpath("SKILL.md").read_text(encoding="utf-8")
|
||||
english_skill = skill_dir.joinpath("SKILL_en.md").read_text(encoding="utf-8")
|
||||
|
||||
self.assertTrue(default_skill.strip())
|
||||
self.assertTrue(english_skill.strip())
|
||||
|
||||
def test_install_skill_creates_skill_md(self):
|
||||
"""_install_skill should create SKILL.md in the first available skill dir."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = os.path.join(tmpdir, "skills")
|
||||
os.makedirs(skill_dir)
|
||||
|
||||
with patch(
|
||||
"agent_reach.cli.os.path.expanduser",
|
||||
side_effect=lambda p: p.replace("~", tmpdir),
|
||||
), patch.dict(os.environ, {}, clear=False):
|
||||
# Remove OPENCLAW_HOME to avoid interference
|
||||
env = os.environ.copy()
|
||||
env.pop("OPENCLAW_HOME", None)
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
_install_skill()
|
||||
|
||||
# Check at least one known skill dir pattern
|
||||
for dirpath, _, filenames in os.walk(tmpdir):
|
||||
if "SKILL.md" in filenames:
|
||||
# Verify content is non-empty
|
||||
with open(os.path.join(dirpath, "SKILL.md"), encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
self.assertIn("Agent Reach", content)
|
||||
# _install_skill may or may not find dirs depending on mock; just ensure no crash
|
||||
# The important test is that the function runs without error
|
||||
|
||||
def test_uninstall_skill_removes_dir(self):
|
||||
"""_uninstall_skill should remove skill directories."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create a fake skill installation
|
||||
skill_path = os.path.join(tmpdir, ".openclaw", "skills", "agent-reach")
|
||||
os.makedirs(skill_path)
|
||||
with open(os.path.join(skill_path, "SKILL.md"), "w", encoding="utf-8") as f:
|
||||
f.write("test")
|
||||
|
||||
self.assertTrue(os.path.exists(skill_path))
|
||||
|
||||
with patch(
|
||||
"agent_reach.cli.os.path.expanduser",
|
||||
side_effect=lambda p: p.replace("~", tmpdir),
|
||||
), patch.dict(os.environ, {}, clear=False):
|
||||
env = os.environ.copy()
|
||||
env.pop("OPENCLAW_HOME", None)
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
_uninstall_skill()
|
||||
|
||||
self.assertFalse(os.path.exists(skill_path))
|
||||
|
||||
def test_install_creates_dir_if_parent_exists(self):
|
||||
"""_install_skill should create agent-reach dir inside existing skill dir."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create the .openclaw/skills parent but not agent-reach subdir
|
||||
skill_parent = os.path.join(tmpdir, ".openclaw", "skills")
|
||||
os.makedirs(skill_parent)
|
||||
|
||||
with patch(
|
||||
"agent_reach.cli.os.path.expanduser",
|
||||
side_effect=lambda p: p.replace("~", tmpdir),
|
||||
), patch.dict(os.environ, {}, clear=False):
|
||||
env = os.environ.copy()
|
||||
env.pop("OPENCLAW_HOME", None)
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
_install_skill()
|
||||
|
||||
target = os.path.join(skill_parent, "agent-reach", "SKILL.md")
|
||||
self.assertTrue(os.path.exists(target))
|
||||
with open(target, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
self.assertIn("Agent Reach", content)
|
||||
|
||||
def test_install_uses_english_skill_for_english_locale(self):
|
||||
"""_install_skill should install the English skill file for English locales."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_parent = os.path.join(tmpdir, ".openclaw", "skills")
|
||||
os.makedirs(skill_parent)
|
||||
|
||||
with patch(
|
||||
"agent_reach.cli.os.path.expanduser",
|
||||
side_effect=lambda p: p.replace("~", tmpdir),
|
||||
):
|
||||
env = os.environ.copy()
|
||||
env.pop("OPENCLAW_HOME", None)
|
||||
env["LANG"] = "en_US.UTF-8"
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
_install_skill()
|
||||
|
||||
target = os.path.join(skill_parent, "agent-reach", "SKILL.md")
|
||||
self.assertTrue(os.path.exists(target))
|
||||
with open(target, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
self.assertTrue(content.strip())
|
||||
self.assertIn("Xiaoyuzhou Podcast, LinkedIn", content)
|
||||
self.assertNotIn("搜推特", content)
|
||||
self.assertTrue(
|
||||
os.path.exists(os.path.join(skill_parent, "agent-reach", "references"))
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
262
vendor/Agent-Reach/tests/test_transcribe.py
vendored
Normal file
262
vendor/Agent-Reach/tests/test_transcribe.py
vendored
Normal file
@@ -0,0 +1,262 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for agent_reach.transcribe — provider routing, fallback, and errors."""
|
||||
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_reach import transcribe as tr
|
||||
from agent_reach.config import Config
|
||||
|
||||
# --- Fixtures ----------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_config(tmp_path, monkeypatch):
|
||||
"""A Config that writes to a temp dir and never touches the user's HOME."""
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
monkeypatch.setattr(Config, "CONFIG_DIR", tmp_path)
|
||||
monkeypatch.setattr(Config, "CONFIG_FILE", cfg_path)
|
||||
cfg = Config(config_path=cfg_path)
|
||||
return cfg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chunk_file(tmp_path):
|
||||
p = tmp_path / "chunk.m4a"
|
||||
p.write_bytes(b"\x00fake-m4a-bytes")
|
||||
return p
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code: int, text: str = ""):
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return 200 <= self.status_code < 300
|
||||
|
||||
|
||||
# --- transcribe_chunk: provider routing -------------------------------- #
|
||||
|
||||
|
||||
class TestTranscribeChunk:
|
||||
def test_routes_to_groq_endpoint(self, monkeypatch, fake_config, chunk_file):
|
||||
fake_config.set("groq_api_key", "gsk_test")
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
captured["model"] = data["model"]
|
||||
return FakeResponse(200, "hello world")
|
||||
|
||||
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||
text = tr.transcribe_chunk(chunk_file, "groq", config=fake_config)
|
||||
assert text == "hello world"
|
||||
assert captured["url"] == tr.PROVIDERS["groq"]["endpoint"]
|
||||
assert captured["model"] == "whisper-large-v3"
|
||||
assert captured["headers"]["Authorization"] == "Bearer gsk_test"
|
||||
|
||||
def test_routes_to_openai_endpoint(self, monkeypatch, fake_config, chunk_file):
|
||||
fake_config.set("openai_api_key", "sk-test")
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||
captured["url"] = url
|
||||
captured["model"] = data["model"]
|
||||
return FakeResponse(200, "openai output")
|
||||
|
||||
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||
text = tr.transcribe_chunk(chunk_file, "openai", config=fake_config)
|
||||
assert text == "openai output"
|
||||
assert captured["url"] == tr.PROVIDERS["openai"]["endpoint"]
|
||||
assert captured["model"] == "whisper-1"
|
||||
|
||||
def test_raises_when_key_missing(self, fake_config, chunk_file):
|
||||
with pytest.raises(tr.NoProviderConfigured):
|
||||
tr.transcribe_chunk(chunk_file, "groq", config=fake_config)
|
||||
|
||||
def test_raises_on_http_error(self, monkeypatch, fake_config, chunk_file):
|
||||
fake_config.set("groq_api_key", "gsk_test")
|
||||
monkeypatch.setattr(
|
||||
tr.requests,
|
||||
"post",
|
||||
lambda *a, **k: FakeResponse(429, "rate limited"),
|
||||
)
|
||||
with pytest.raises(tr.TranscribeError, match="HTTP 429"):
|
||||
tr.transcribe_chunk(chunk_file, "groq", config=fake_config)
|
||||
|
||||
def test_unknown_provider(self, fake_config, chunk_file):
|
||||
with pytest.raises(tr.TranscribeError, match="unknown provider"):
|
||||
tr.transcribe_chunk(chunk_file, "azure", config=fake_config)
|
||||
|
||||
|
||||
# --- _transcribe_with_fallback ----------------------------------------- #
|
||||
|
||||
|
||||
class TestFallback:
|
||||
def test_groq_succeeds_no_openai_call(self, monkeypatch, fake_config, chunk_file):
|
||||
fake_config.set("groq_api_key", "gsk_test")
|
||||
fake_config.set("openai_api_key", "sk-test")
|
||||
calls: List[str] = []
|
||||
|
||||
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||
calls.append(url)
|
||||
return FakeResponse(200, "from-groq")
|
||||
|
||||
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||
text = tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
|
||||
assert text == "from-groq"
|
||||
assert calls == [tr.PROVIDERS["groq"]["endpoint"]]
|
||||
|
||||
def test_groq_429_falls_back_to_openai(self, monkeypatch, fake_config, chunk_file):
|
||||
fake_config.set("groq_api_key", "gsk_test")
|
||||
fake_config.set("openai_api_key", "sk-test")
|
||||
calls: List[str] = []
|
||||
|
||||
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||
calls.append(url)
|
||||
if url == tr.PROVIDERS["groq"]["endpoint"]:
|
||||
return FakeResponse(429, "rate limited")
|
||||
return FakeResponse(200, "from-openai")
|
||||
|
||||
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||
text = tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
|
||||
assert text == "from-openai"
|
||||
assert calls == [
|
||||
tr.PROVIDERS["groq"]["endpoint"],
|
||||
tr.PROVIDERS["openai"]["endpoint"],
|
||||
]
|
||||
|
||||
def test_skip_unconfigured_provider(self, monkeypatch, fake_config, chunk_file):
|
||||
# Only openai key configured — fallback should skip groq silently.
|
||||
fake_config.set("openai_api_key", "sk-test")
|
||||
calls: List[str] = []
|
||||
|
||||
def fake_post(url, headers=None, files=None, data=None, timeout=None):
|
||||
calls.append(url)
|
||||
return FakeResponse(200, "via-openai")
|
||||
|
||||
monkeypatch.setattr(tr.requests, "post", fake_post)
|
||||
text = tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
|
||||
assert text == "via-openai"
|
||||
assert calls == [tr.PROVIDERS["openai"]["endpoint"]]
|
||||
|
||||
def test_all_fail_raises_with_last_error(self, monkeypatch, fake_config, chunk_file):
|
||||
fake_config.set("groq_api_key", "gsk_test")
|
||||
fake_config.set("openai_api_key", "sk-test")
|
||||
monkeypatch.setattr(
|
||||
tr.requests,
|
||||
"post",
|
||||
lambda *a, **k: FakeResponse(500, "boom"),
|
||||
)
|
||||
with pytest.raises(tr.TranscribeError, match="all providers failed"):
|
||||
tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
|
||||
|
||||
|
||||
# --- transcribe (orchestrator) ---------------------------------------- #
|
||||
|
||||
|
||||
class TestOrchestrator:
|
||||
def test_local_file_skips_yt_dlp(self, monkeypatch, fake_config, tmp_path, chunk_file):
|
||||
fake_config.set("groq_api_key", "gsk_test")
|
||||
|
||||
def boom_download(*a, **k):
|
||||
raise AssertionError("yt-dlp must not be called for local files")
|
||||
|
||||
# Stub heavy external steps to no-ops that keep file paths valid.
|
||||
compressed = tmp_path / "compressed.m4a"
|
||||
compressed.write_bytes(b"x" * 1024)
|
||||
|
||||
def fake_compress(src, out_dir):
|
||||
return compressed
|
||||
|
||||
monkeypatch.setattr(tr, "download_audio", boom_download)
|
||||
monkeypatch.setattr(tr, "compress_audio", fake_compress)
|
||||
monkeypatch.setattr(
|
||||
tr.requests,
|
||||
"post",
|
||||
lambda *a, **k: FakeResponse(200, "transcript text"),
|
||||
)
|
||||
|
||||
text = tr.transcribe(
|
||||
str(chunk_file),
|
||||
out_dir=tmp_path / "work",
|
||||
config=fake_config,
|
||||
)
|
||||
assert text == "transcript text"
|
||||
|
||||
def test_chunks_concatenated_with_newlines(
|
||||
self, monkeypatch, fake_config, tmp_path, chunk_file
|
||||
):
|
||||
fake_config.set("groq_api_key", "gsk_test")
|
||||
# Force the "needs chunking" path by writing a file above the size limit.
|
||||
big = tmp_path / "compressed.m4a"
|
||||
big.write_bytes(b"x" * (tr.SIZE_LIMIT_BYTES + 1))
|
||||
monkeypatch.setattr(tr, "compress_audio", lambda src, out_dir: big)
|
||||
c1 = tmp_path / "chunk_001.m4a"
|
||||
c2 = tmp_path / "chunk_002.m4a"
|
||||
c1.write_bytes(b"a")
|
||||
c2.write_bytes(b"b")
|
||||
monkeypatch.setattr(tr, "chunk_audio", lambda src, out_dir: [c1, c2])
|
||||
|
||||
responses = iter(["part one ", "part two "])
|
||||
monkeypatch.setattr(
|
||||
tr.requests,
|
||||
"post",
|
||||
lambda *a, **k: FakeResponse(200, next(responses)),
|
||||
)
|
||||
|
||||
text = tr.transcribe(
|
||||
str(chunk_file),
|
||||
out_dir=tmp_path / "work",
|
||||
config=fake_config,
|
||||
)
|
||||
assert text == "part one\npart two"
|
||||
|
||||
def test_no_provider_configured_fails_fast(self, fake_config, chunk_file):
|
||||
with pytest.raises(tr.NoProviderConfigured):
|
||||
tr.transcribe(str(chunk_file), config=fake_config)
|
||||
|
||||
def test_invalid_provider_string(self, fake_config, chunk_file):
|
||||
with pytest.raises(tr.TranscribeError, match="unknown provider"):
|
||||
tr.transcribe(str(chunk_file), provider="azure", config=fake_config)
|
||||
|
||||
|
||||
# --- YouTubeChannel integration --------------------------------------- #
|
||||
|
||||
|
||||
class TestYouTubeChannelTranscribe:
|
||||
def test_delegates_to_transcribe(self, monkeypatch, fake_config):
|
||||
from agent_reach.channels.youtube import YouTubeChannel
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_transcribe(source, *, provider="auto", out_dir=None, config=None):
|
||||
captured["source"] = source
|
||||
captured["provider"] = provider
|
||||
captured["config"] = config
|
||||
return "delegated text"
|
||||
|
||||
monkeypatch.setattr(tr, "transcribe", fake_transcribe)
|
||||
out = YouTubeChannel().transcribe(
|
||||
"https://youtu.be/abc", provider="groq", config=fake_config
|
||||
)
|
||||
assert out == "delegated text"
|
||||
assert captured["source"] == "https://youtu.be/abc"
|
||||
assert captured["provider"] == "groq"
|
||||
assert captured["config"] is fake_config
|
||||
|
||||
|
||||
# --- Config feature requirement --------------------------------------- #
|
||||
|
||||
|
||||
class TestConfigOpenAIWhisper:
|
||||
def test_openai_whisper_feature_registered(self, fake_config):
|
||||
assert "openai_whisper" in Config.FEATURE_REQUIREMENTS
|
||||
assert Config.FEATURE_REQUIREMENTS["openai_whisper"] == ["openai_api_key"]
|
||||
assert not fake_config.is_configured("openai_whisper")
|
||||
fake_config.set("openai_api_key", "sk-test")
|
||||
assert fake_config.is_configured("openai_whisper")
|
||||
184
vendor/Agent-Reach/tests/test_twitter_channel.py
vendored
Normal file
184
vendor/Agent-Reach/tests/test_twitter_channel.py
vendored
Normal file
@@ -0,0 +1,184 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
from agent_reach.channels.twitter import TwitterChannel
|
||||
|
||||
|
||||
def _cp(stdout="", stderr="", returncode=0):
|
||||
m = Mock()
|
||||
m.stdout = stdout
|
||||
m.stderr = stderr
|
||||
m.returncode = returncode
|
||||
return m
|
||||
|
||||
|
||||
# --- twitter-cli tests ---
|
||||
|
||||
def test_check_twitter_cli_found_and_auth_ok():
|
||||
"""twitter-cli found + twitter status ok → ok."""
|
||||
channel = TwitterChannel()
|
||||
with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None), patch(
|
||||
"subprocess.run",
|
||||
return_value=_cp(stdout="ok: true\nusername: testuser\n", returncode=0),
|
||||
):
|
||||
status, message = channel.check()
|
||||
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():
|
||||
"""twitter-cli found + not_authenticated → warn about auth."""
|
||||
channel = TwitterChannel()
|
||||
with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None), patch(
|
||||
"subprocess.run",
|
||||
return_value=_cp(
|
||||
stderr="ok: false\nerror:\n code: not_authenticated\n",
|
||||
returncode=1,
|
||||
),
|
||||
):
|
||||
status, message = channel.check()
|
||||
assert status == "warn"
|
||||
assert "未认证" in message
|
||||
# 未认证是业务态:工具进程活着,后端仍可用
|
||||
assert channel.active_backend == "twitter-cli"
|
||||
|
||||
|
||||
# --- bird CLI fallback tests ---
|
||||
|
||||
def test_check_bird_fallback_auth_ok():
|
||||
"""No twitter-cli, but bird found + bird check ok → ok."""
|
||||
channel = TwitterChannel()
|
||||
def which_side_effect(name):
|
||||
if name == "bird":
|
||||
return "/usr/local/bin/bird"
|
||||
return None
|
||||
with patch("shutil.which", side_effect=which_side_effect), patch(
|
||||
"subprocess.run",
|
||||
return_value=_cp(stdout="Authenticated as @user\n", returncode=0),
|
||||
):
|
||||
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():
|
||||
"""No twitter-cli, bird found but Missing credentials → warn."""
|
||||
channel = TwitterChannel()
|
||||
def which_side_effect(name):
|
||||
if name == "bird":
|
||||
return "/usr/local/bin/bird"
|
||||
return None
|
||||
with patch("shutil.which", side_effect=which_side_effect), patch(
|
||||
"subprocess.run",
|
||||
return_value=_cp(stderr="Missing credentials\n", returncode=1),
|
||||
):
|
||||
status, message = channel.check()
|
||||
assert status == "warn"
|
||||
assert "未配置认证" in message
|
||||
|
||||
|
||||
# --- neither installed ---
|
||||
|
||||
def test_check_nothing_installed():
|
||||
"""Neither twitter-cli nor bird → warn with install hint."""
|
||||
channel = TwitterChannel()
|
||||
with patch("shutil.which", return_value=None):
|
||||
status, message = channel.check()
|
||||
assert status == "warn"
|
||||
assert "twitter-cli" in message
|
||||
assert channel.active_backend is None
|
||||
|
||||
|
||||
# --- twitter-cli preferred over bird ---
|
||||
|
||||
def test_twitter_cli_preferred_over_bird():
|
||||
"""When both are installed, twitter-cli is used."""
|
||||
channel = TwitterChannel()
|
||||
def which_side_effect(name):
|
||||
if name == "twitter":
|
||||
return "/usr/local/bin/twitter"
|
||||
if name == "bird":
|
||||
return "/usr/local/bin/bird"
|
||||
return None
|
||||
with patch("shutil.which", side_effect=which_side_effect), patch(
|
||||
"subprocess.run",
|
||||
return_value=_cp(stdout="ok: true\n", returncode=0),
|
||||
):
|
||||
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)"
|
||||
|
||||
|
||||
def test_unauthenticated_twitter_cli_does_not_block_working_opencli():
|
||||
"""warn 候选不得屏蔽排在后面的 ok 候选(Codex review 发现)。"""
|
||||
channel = TwitterChannel()
|
||||
with patch.object(
|
||||
TwitterChannel, "_check_twitter_cli",
|
||||
return_value=("warn", "twitter-cli 已安装但未认证"),
|
||||
), patch.object(
|
||||
TwitterChannel, "_check_opencli",
|
||||
return_value=("ok", "OpenCLI 可用(复用浏览器登录态)"),
|
||||
), patch.object(TwitterChannel, "_check_bird", return_value=None):
|
||||
status, msg = channel.check()
|
||||
assert status == "ok"
|
||||
assert channel.active_backend == "OpenCLI"
|
||||
|
||||
|
||||
def test_all_warn_falls_back_to_first_warn():
|
||||
channel = TwitterChannel()
|
||||
with patch.object(
|
||||
TwitterChannel, "_check_twitter_cli",
|
||||
return_value=("warn", "twitter-cli 未认证"),
|
||||
), patch.object(
|
||||
TwitterChannel, "_check_opencli",
|
||||
return_value=("warn", "扩展未连接"),
|
||||
), patch.object(TwitterChannel, "_check_bird", return_value=None):
|
||||
status, msg = channel.check()
|
||||
assert status == "warn"
|
||||
assert channel.active_backend == "twitter-cli"
|
||||
assert "未认证" in msg
|
||||
133
vendor/Agent-Reach/tests/test_xhs_format.py
vendored
Normal file
133
vendor/Agent-Reach/tests/test_xhs_format.py
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for XiaoHongShu output formatter (issue #134)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from agent_reach.channels.xiaohongshu import format_xhs_result
|
||||
|
||||
|
||||
class TestFormatXhsResult(unittest.TestCase):
|
||||
"""Test format_xhs_result strips redundant fields."""
|
||||
|
||||
SAMPLE_NOTE = {
|
||||
"id": "abc123",
|
||||
"title": "测试笔记",
|
||||
"desc": "这是正文内容",
|
||||
"type": "normal",
|
||||
"xsec_token": "tok_xxx",
|
||||
"user": {
|
||||
"nickname": "小红",
|
||||
"user_id": "u123",
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
"extra_field": "should be dropped",
|
||||
},
|
||||
"interact_info": {
|
||||
"liked_count": "100",
|
||||
"collected_count": "50",
|
||||
"comment_count": "20",
|
||||
"share_count": "10",
|
||||
"sticky_count": "0",
|
||||
"relation": "none",
|
||||
},
|
||||
"image_list": [
|
||||
{
|
||||
"url": "https://img.example.com/1.jpg",
|
||||
"info_list": [{"url": "https://img.example.com/1_small.jpg", "image_scene": "WB_DFT"}],
|
||||
"width": 1080,
|
||||
"height": 1440,
|
||||
"trace_id": "tr_123",
|
||||
},
|
||||
{
|
||||
"url": "https://img.example.com/2.jpg",
|
||||
"info_list": [{"url": "https://img.example.com/2_small.jpg"}],
|
||||
"width": 1080,
|
||||
"height": 1080,
|
||||
},
|
||||
],
|
||||
"tag_list": [
|
||||
{"id": "t1", "name": "旅行", "type": "topic"},
|
||||
{"id": "t2", "name": "美食", "type": "topic"},
|
||||
],
|
||||
"at_user_list": [],
|
||||
"geo_info": {"latitude": 0, "longitude": 0},
|
||||
"audit_info": {"audit_status": 0},
|
||||
"model_type": None,
|
||||
"note_flow_source": "search",
|
||||
}
|
||||
|
||||
def test_single_note_keeps_useful_fields(self):
|
||||
result = format_xhs_result(self.SAMPLE_NOTE)
|
||||
self.assertEqual(result["id"], "abc123")
|
||||
self.assertEqual(result["title"], "测试笔记")
|
||||
self.assertEqual(result["desc"], "这是正文内容")
|
||||
self.assertEqual(result["type"], "normal")
|
||||
self.assertEqual(result["user"]["nickname"], "小红")
|
||||
self.assertEqual(result["liked_count"], "100")
|
||||
self.assertEqual(result["collected_count"], "50")
|
||||
self.assertEqual(result["images"], [
|
||||
"https://img.example.com/1.jpg",
|
||||
"https://img.example.com/2.jpg",
|
||||
])
|
||||
self.assertEqual(result["tags"], ["旅行", "美食"])
|
||||
|
||||
def test_single_note_drops_useless_fields(self):
|
||||
result = format_xhs_result(self.SAMPLE_NOTE)
|
||||
self.assertNotIn("at_user_list", result)
|
||||
self.assertNotIn("geo_info", result)
|
||||
self.assertNotIn("audit_info", result)
|
||||
self.assertNotIn("model_type", result)
|
||||
self.assertNotIn("note_flow_source", result)
|
||||
# User should not have extra fields
|
||||
self.assertNotIn("avatar", result.get("user", {}))
|
||||
self.assertNotIn("extra_field", result.get("user", {}))
|
||||
|
||||
def test_search_results_wrapper(self):
|
||||
"""Handle {"items": [...]} wrapper from search_feeds."""
|
||||
wrapped = {"items": [self.SAMPLE_NOTE, self.SAMPLE_NOTE]}
|
||||
result = format_xhs_result(wrapped)
|
||||
self.assertIsInstance(result, list)
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertEqual(result[0]["title"], "测试笔记")
|
||||
|
||||
def test_list_input(self):
|
||||
result = format_xhs_result([self.SAMPLE_NOTE])
|
||||
self.assertIsInstance(result, list)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["title"], "测试笔记")
|
||||
|
||||
def test_note_card_wrapper(self):
|
||||
"""Handle notes nested under 'note_card'."""
|
||||
wrapped = {"note_card": self.SAMPLE_NOTE}
|
||||
result = format_xhs_result(wrapped)
|
||||
self.assertEqual(result["title"], "测试笔记")
|
||||
|
||||
def test_with_comments(self):
|
||||
note = dict(self.SAMPLE_NOTE)
|
||||
note["comments"] = [
|
||||
{
|
||||
"content": "写得好!",
|
||||
"user_info": {"nickname": "路人甲", "user_id": "u456"},
|
||||
"like_count": 5,
|
||||
"sub_comment_count": 1,
|
||||
"ip_location": "上海",
|
||||
"status": 0,
|
||||
}
|
||||
]
|
||||
result = format_xhs_result(note)
|
||||
self.assertEqual(len(result["comments"]), 1)
|
||||
self.assertEqual(result["comments"][0]["content"], "写得好!")
|
||||
self.assertEqual(result["comments"][0]["user"], "路人甲")
|
||||
self.assertEqual(result["comments"][0]["like_count"], 5)
|
||||
self.assertNotIn("ip_location", result["comments"][0])
|
||||
|
||||
def test_empty_input(self):
|
||||
self.assertEqual(format_xhs_result({}), {})
|
||||
self.assertEqual(format_xhs_result([]), [])
|
||||
|
||||
def test_non_dict_passthrough(self):
|
||||
self.assertEqual(format_xhs_result("hello"), "hello")
|
||||
self.assertIsNone(format_xhs_result(None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
21
vendor/Agent-Reach/tests/test_xiaoyuzhou_install.py
vendored
Normal file
21
vendor/Agent-Reach/tests/test_xiaoyuzhou_install.py
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import agent_reach.cli as cli
|
||||
|
||||
|
||||
class _DummyConfig:
|
||||
def get(self, _key):
|
||||
return None
|
||||
|
||||
|
||||
def test_install_xiaoyuzhou_deps_does_not_raise_when_no_groq_key(capsys):
|
||||
with patch("agent_reach.config.Config", return_value=_DummyConfig()), \
|
||||
patch("os.path.isfile", side_effect=lambda p: True if str(p).endswith("transcribe.sh") else False), \
|
||||
patch("shutil.which", return_value=None):
|
||||
cli._install_xiaoyuzhou_deps()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Xiaoyuzhou" in out
|
||||
assert "Groq API key not set" in out
|
||||
Reference in New Issue
Block a user