mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
* fix(doctor): skip tool checks when tools block is empty Follow-up to #5296, which fixed this for `models:`. The same defect remains for `tools:`: `.get("tools", [])` returns None when the key is present but empty, because the default only applies when the key is absent. Iterating that None raises TypeError, which the surrounding broad handler renders as a check result: ! web search configured ('NoneType' object is not iterable) ! web fetch configured ('NoneType' object is not iterable) ! web capture configured ('NoneType' object is not iterable) ! image search configured ('NoneType' object is not iterable) ✗ sandbox configured ('NoneType' object is not iterable) Line 476 is reached by all four web/image checks through the shared check_web_tool helper, and line 645 by check_sandbox. Unlike the models case, a default install does not hit this: `make config` ships ten real tool entries, so a user has to empty or comment out that block first. The web checks now fall through to their normal "no tool in config" warning and the sandbox check evaluates normally. Parentheses on the comprehension are for readability; `or` already binds correctly there. Regression tests use the commented-out `tools:` shape that reproduces the failure, matching the tests added in #5296. Fixes #5300 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(doctor): skip non-mapping tool entries, tighten regression tests Review follow-ups on the line this PR already changes. A `tools:` list holding a scalar (`tools:\n - web_search`) reached `t.get("name")` and raised AttributeError, which the broad handler rendered as the check result: ! web search configured ('str' object has no attribute 'get') That is the same leakage this PR removes for the null case, so it is fixed here rather than deferred. `check_sandbox` already guards the same way via `isinstance(tool, dict)`. The empty-tools test asserted that "NoneType" was absent from the detail, which pins the failure mode rather than the behaviour — it would still pass if the detail became some other internal error text. Both tests now assert the expected message directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(doctor): assert sandbox outcome instead of the failure mode Review follow-up, same class as the web-tool assertion fixed earlier in this PR. The sandbox regression test still asserted that "NoneType" was absent from the detail, which pins the failure mode rather than the outcome — it would keep passing if some other internal error text leaked out of the broad handler. On this config the path is deterministic: an empty `tools:` means no bash tool, so exactly one result. Assert the fields directly (`CheckResult` has no `__eq__`, so whole instances cannot be compared by value). Verified against `main`'s scripts/doctor.py, where the same config yields status=fail and detail="'NoneType' object is not iterable", so the new assertions are red there and green here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
733 lines
33 KiB
Python
733 lines
33 KiB
Python
"""Unit tests for scripts/doctor.py.
|
|
|
|
Run from repo root:
|
|
cd backend && uv run pytest tests/test_doctor.py -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import doctor
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _load_script(path: Path, name: str):
|
|
assert path.exists(), f"{path} must exist"
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
assert spec is not None
|
|
assert spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_python
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckPython:
|
|
def test_current_python_passes(self):
|
|
result = doctor.check_python()
|
|
assert sys.version_info >= (3, 12)
|
|
assert result.status == "ok"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_pnpm
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckPnpm:
|
|
def test_resolves_shared_runner_from_relative_script_path(self, monkeypatch):
|
|
# Load the script as `scripts/doctor.py`, as a user would from the
|
|
# repository root. The derived paths must not depend on that relative
|
|
# invocation path.
|
|
monkeypatch.chdir(REPO_ROOT)
|
|
relative_doctor = _load_script(Path("scripts/doctor.py"), "deerflow_doctor_relative")
|
|
|
|
assert relative_doctor.PNPM_SCRIPT_PATH == REPO_ROOT / "scripts" / "pnpm.py"
|
|
assert relative_doctor.PNPM_SCRIPT_PATH.is_absolute()
|
|
assert relative_doctor.FRONTEND_DIR == REPO_ROOT / "frontend"
|
|
assert relative_doctor.FRONTEND_DIR.is_absolute()
|
|
|
|
def test_uses_shared_runner_from_frontend(self, monkeypatch):
|
|
captured = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
captured["kwargs"] = kwargs
|
|
return doctor.subprocess.CompletedProcess(cmd, 0, stdout="10.26.2\n", stderr="")
|
|
|
|
monkeypatch.setattr(doctor.subprocess, "run", fake_run)
|
|
|
|
result = doctor.check_pnpm()
|
|
|
|
expected_runner = doctor.Path(doctor.__file__).with_name("pnpm.py")
|
|
assert result.status == "ok"
|
|
assert result.detail == "10.26.2"
|
|
assert captured["cmd"] == [sys.executable, str(expected_runner), "-v"]
|
|
assert captured["kwargs"]["cwd"] == expected_runner.parent.parent / "frontend"
|
|
assert captured["kwargs"]["shell"] is False
|
|
assert captured["kwargs"]["check"] is False
|
|
|
|
def test_runner_failure_is_reported_as_failure(self, monkeypatch):
|
|
def fake_run(cmd, **kwargs):
|
|
return doctor.subprocess.CompletedProcess(
|
|
cmd,
|
|
42,
|
|
stdout="",
|
|
stderr="Error: pnpm command failed with exit status 42.\n",
|
|
)
|
|
|
|
monkeypatch.setattr(doctor.subprocess, "run", fake_run)
|
|
|
|
result = doctor.check_pnpm()
|
|
|
|
assert result.status == "fail"
|
|
assert "exit status 42" in result.detail
|
|
assert result.fix is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_config_exists
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckConfigExists:
|
|
def test_missing_config(self, tmp_path):
|
|
result = doctor.check_config_exists(tmp_path / "config.yaml")
|
|
assert result.status == "fail"
|
|
assert result.fix is not None
|
|
|
|
def test_present_config(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\n")
|
|
result = doctor.check_config_exists(cfg)
|
|
assert result.status == "ok"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_config_version
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckConfigVersion:
|
|
def test_up_to_date(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\n")
|
|
example = tmp_path / "config.example.yaml"
|
|
example.write_text("config_version: 5\n")
|
|
result = doctor.check_config_version(cfg, tmp_path)
|
|
assert result.status == "ok"
|
|
|
|
def test_outdated(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 3\n")
|
|
example = tmp_path / "config.example.yaml"
|
|
example.write_text("config_version: 5\n")
|
|
result = doctor.check_config_version(cfg, tmp_path)
|
|
assert result.status == "warn"
|
|
assert result.fix is not None
|
|
|
|
def test_missing_config_skipped(self, tmp_path):
|
|
result = doctor.check_config_version(tmp_path / "config.yaml", tmp_path)
|
|
assert result.status == "skip"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_config_loadable
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckConfigLoadable:
|
|
def test_loadable_config(self, tmp_path, monkeypatch):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\n")
|
|
monkeypatch.setattr(doctor, "_load_app_config", lambda _path: object())
|
|
result = doctor.check_config_loadable(cfg)
|
|
assert result.status == "ok"
|
|
|
|
def test_invalid_config(self, tmp_path, monkeypatch):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\n")
|
|
|
|
def fail(_path):
|
|
raise ValueError("bad config")
|
|
|
|
monkeypatch.setattr(doctor, "_load_app_config", fail)
|
|
result = doctor.check_config_loadable(cfg)
|
|
assert result.status == "fail"
|
|
assert "bad config" in result.detail
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_models_configured
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckModelsConfigured:
|
|
def test_no_models(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\nmodels: []\n")
|
|
result = doctor.check_models_configured(cfg)
|
|
assert result.status == "fail"
|
|
|
|
def test_one_model(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\nmodels:\n - name: default\n use: langchain_openai:ChatOpenAI\n model: gpt-4o\n api_key: $OPENAI_API_KEY\n")
|
|
result = doctor.check_models_configured(cfg)
|
|
assert result.status == "ok"
|
|
|
|
def test_missing_config_skipped(self, tmp_path):
|
|
result = doctor.check_models_configured(tmp_path / "config.yaml")
|
|
assert result.status == "skip"
|
|
|
|
def test_commented_out_models_block(self, tmp_path):
|
|
# config.example.yaml ships a `models:` key whose entries are all
|
|
# commented out, so it parses as None rather than an empty list.
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\nmodels:\n # - name: default\n")
|
|
result = doctor.check_models_configured(cfg)
|
|
assert result.status == "fail"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_llm_api_key
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckLLMApiKey:
|
|
def test_key_set(self, tmp_path, monkeypatch):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\nmodels:\n - name: default\n use: langchain_openai:ChatOpenAI\n model: gpt-4o\n api_key: $OPENAI_API_KEY\n")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
results = doctor.check_llm_api_key(cfg)
|
|
assert any(r.status == "ok" for r in results)
|
|
assert all(r.status != "fail" for r in results)
|
|
|
|
def test_key_missing(self, tmp_path, monkeypatch):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\nmodels:\n - name: default\n use: langchain_openai:ChatOpenAI\n model: gpt-4o\n api_key: $OPENAI_API_KEY\n")
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
results = doctor.check_llm_api_key(cfg)
|
|
assert any(r.status == "fail" for r in results)
|
|
failed = [r for r in results if r.status == "fail"]
|
|
assert all(r.fix is not None for r in failed)
|
|
assert any("OPENAI_API_KEY" in (r.fix or "") for r in failed)
|
|
|
|
def test_missing_config_returns_empty(self, tmp_path):
|
|
results = doctor.check_llm_api_key(tmp_path / "config.yaml")
|
|
assert results == []
|
|
|
|
def test_commented_out_models_block_returns_empty(self, tmp_path):
|
|
# Regression: iterating a null `models:` raised TypeError, which the
|
|
# broad handler rendered as "('NoneType' object is not iterable)".
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\nmodels:\n # - name: default\n")
|
|
results = doctor.check_llm_api_key(cfg)
|
|
assert results == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_llm_auth
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckLLMAuth:
|
|
def test_codex_auth_file_missing_fails(self, tmp_path, monkeypatch):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\nmodels:\n - name: codex\n use: deerflow.models.openai_codex_provider:CodexChatModel\n model: gpt-5.4\n")
|
|
monkeypatch.setenv("CODEX_AUTH_PATH", str(tmp_path / "missing-auth.json"))
|
|
results = doctor.check_llm_auth(cfg)
|
|
assert any(result.status == "fail" and "Codex CLI auth available" in result.label for result in results)
|
|
|
|
def test_claude_oauth_env_passes(self, tmp_path, monkeypatch):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\nmodels:\n - name: claude\n use: deerflow.models.claude_provider:ClaudeChatModel\n model: claude-sonnet-4-6\n")
|
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "token")
|
|
results = doctor.check_llm_auth(cfg)
|
|
assert any(result.status == "ok" and "Claude auth available" in result.label for result in results)
|
|
|
|
def test_commented_out_models_block_returns_empty(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\nmodels:\n # - name: default\n")
|
|
assert doctor.check_llm_auth(cfg) == []
|
|
assert doctor.check_llm_package(cfg) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_web_search
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckWebSearch:
|
|
def test_ddg_always_ok(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text(
|
|
"config_version: 5\nmodels:\n - name: default\n use: langchain_openai:ChatOpenAI\n model: gpt-4o\n api_key: $OPENAI_API_KEY\ntools:\n - name: web_search\n use: deerflow.community.ddg_search.tools:web_search_tool\n"
|
|
)
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "ok"
|
|
assert "DuckDuckGo" in result.detail
|
|
|
|
def test_commented_out_tools_block_warns_without_traceback(self, tmp_path):
|
|
# config.example.yaml ships a `tools:` key whose entries can all be
|
|
# commented out, so it parses as None rather than an empty list.
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n # - name: web_search\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "warn"
|
|
assert result.detail == "no web_search tool in config"
|
|
|
|
def test_scalar_tools_entry_warns_without_traceback(self, tmp_path):
|
|
# A bare string entry is not a mapping; `t.get("name")` used to raise
|
|
# AttributeError, which the broad handler rendered as the check result.
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - web_search\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "warn"
|
|
assert result.detail == "no web_search tool in config"
|
|
|
|
def test_tavily_with_key_ok(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("TAVILY_API_KEY", "tvly-test")
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.tavily.tools:web_search_tool\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "ok"
|
|
|
|
def test_tavily_without_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.tavily.tools:web_search_tool\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "warn"
|
|
assert result.fix is not None
|
|
assert "make setup" in result.fix
|
|
|
|
def test_brave_with_key_ok(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "bsa-test")
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.brave.tools:web_search_tool\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "ok"
|
|
|
|
def test_brave_without_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.brave.tools:web_search_tool\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "warn"
|
|
assert result.fix is not None
|
|
assert "BRAVE_SEARCH_API_KEY" in result.fix
|
|
|
|
def test_brave_with_inline_api_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text('config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.brave.tools:web_search_tool\n api_key: "inline-key"\n')
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "warn"
|
|
assert "literal api_key set in config" in result.detail
|
|
assert "BRAVE_SEARCH_API_KEY" in (result.fix or "")
|
|
|
|
def test_brave_with_api_key_env_ref_ok(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "bsa-test")
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.brave.tools:web_search_tool\n api_key: $BRAVE_SEARCH_API_KEY\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "ok"
|
|
assert "BRAVE_SEARCH_API_KEY set from config" in result.detail
|
|
|
|
def test_sofya_with_key_ok(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("SOFYA_API_KEY", "test-key")
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.sofya.tools:web_search_tool\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "ok"
|
|
assert "sofya" in result.detail
|
|
|
|
def test_sofya_without_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("SOFYA_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.sofya.tools:web_search_tool\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "warn"
|
|
assert "SOFYA_API_KEY" in (result.fix or "")
|
|
|
|
def test_serper_with_key_ok(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("SERPER_API_KEY", "test-key")
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.serper.tools:web_search_tool\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "ok"
|
|
assert "serper" in result.detail
|
|
|
|
def test_serper_without_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("SERPER_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.serper.tools:web_search_tool\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "warn"
|
|
assert "SERPER_API_KEY" in (result.fix or "")
|
|
|
|
def test_serper_inline_api_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("SERPER_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.serper.tools:web_search_tool\n api_key: inline-key\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "warn"
|
|
assert "literal api_key set in config" in result.detail
|
|
assert "SERPER_API_KEY" in (result.fix or "")
|
|
|
|
def test_serper_config_env_ref_ok(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("SERPER_API_KEY", "test-key")
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.serper.tools:web_search_tool\n api_key: $SERPER_API_KEY\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "ok"
|
|
assert "SERPER_API_KEY set from config" in result.detail
|
|
|
|
def test_serper_unresolved_env_ref_falls_back_to_default_var(self, tmp_path, monkeypatch):
|
|
# The referenced $VAR is unset, but the default SERPER_API_KEY is set,
|
|
# which the tool uses as a runtime fallback; report ok rather than warn.
|
|
monkeypatch.delenv("MY_CUSTOM_SERPER_KEY", raising=False)
|
|
monkeypatch.setenv("SERPER_API_KEY", "test-key")
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.serper.tools:web_search_tool\n api_key: $MY_CUSTOM_SERPER_KEY\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "ok"
|
|
assert "SERPER_API_KEY set" in result.detail
|
|
|
|
def test_serper_unresolved_env_ref_without_default_warns(self, tmp_path, monkeypatch):
|
|
# Neither the referenced $VAR nor the default SERPER_API_KEY is set.
|
|
monkeypatch.delenv("MY_CUSTOM_SERPER_KEY", raising=False)
|
|
monkeypatch.delenv("SERPER_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.serper.tools:web_search_tool\n api_key: $MY_CUSTOM_SERPER_KEY\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "warn"
|
|
assert "SERPER_API_KEY" in (result.fix or "")
|
|
|
|
def test_tencent_wsa_without_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("TENCENTCLOUD_WSA_APIKEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.tencent_wsa.tools:web_search_tool\n")
|
|
|
|
result = doctor.check_web_search(cfg)
|
|
|
|
assert result.status == "warn"
|
|
assert "tencent_wsa configured but TENCENTCLOUD_WSA_APIKEY not set" in result.detail
|
|
assert "TENCENTCLOUD_WSA_APIKEY" in (result.fix or "")
|
|
|
|
def test_serply_with_key_ok(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("SERPLY_API_KEY", "test-key")
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.serply.tools:web_search_tool\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "ok"
|
|
assert "serply" in result.detail
|
|
|
|
def test_serply_without_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("SERPLY_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.serply.tools:web_search_tool\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "warn"
|
|
assert "SERPLY_API_KEY" in (result.fix or "")
|
|
|
|
def test_no_search_tool_warns(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools: []\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "warn"
|
|
assert result.fix is not None
|
|
assert "make setup" in result.fix
|
|
|
|
def test_missing_config_skipped(self, tmp_path):
|
|
result = doctor.check_web_search(tmp_path / "config.yaml")
|
|
assert result.status == "skip"
|
|
|
|
def test_invalid_provider_use_fails(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.not_real.tools:web_search_tool\n")
|
|
result = doctor.check_web_search(cfg)
|
|
assert result.status == "fail"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_web_fetch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckWebFetch:
|
|
def test_jina_always_ok(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_fetch\n use: deerflow.community.jina_ai.tools:web_fetch_tool\n")
|
|
result = doctor.check_web_fetch(cfg)
|
|
assert result.status == "ok"
|
|
assert "Jina AI" in result.detail
|
|
|
|
def test_firecrawl_without_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_fetch\n use: deerflow.community.firecrawl.tools:web_fetch_tool\n")
|
|
result = doctor.check_web_fetch(cfg)
|
|
assert result.status == "warn"
|
|
assert "FIRECRAWL_API_KEY" in (result.fix or "")
|
|
|
|
def test_sofya_without_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("SOFYA_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_fetch\n use: deerflow.community.sofya.tools:web_fetch_tool\n")
|
|
result = doctor.check_web_fetch(cfg)
|
|
assert result.status == "warn"
|
|
assert "SOFYA_API_KEY" in (result.fix or "")
|
|
|
|
def test_no_fetch_tool_warns(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools: []\n")
|
|
result = doctor.check_web_fetch(cfg)
|
|
assert result.status == "warn"
|
|
assert result.fix is not None
|
|
|
|
def test_invalid_provider_use_fails(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_fetch\n use: deerflow.community.not_real.tools:web_fetch_tool\n")
|
|
result = doctor.check_web_fetch(cfg)
|
|
assert result.status == "fail"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_web_capture
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckWebCapture:
|
|
def test_browserless_self_host_without_token_ok(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("BROWSERLESS_TOKEN", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: http://localhost:3032\n")
|
|
|
|
result = doctor.check_web_capture(cfg)
|
|
|
|
assert result.status == "ok"
|
|
assert "self-hosted" in result.detail
|
|
|
|
def test_browserless_token_env_ref_ok(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("BROWSERLESS_TOKEN", "browserless-test")
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: https://production-sfo.browserless.io\n token: $BROWSERLESS_TOKEN\n")
|
|
|
|
result = doctor.check_web_capture(cfg)
|
|
|
|
assert result.status == "ok"
|
|
assert "BROWSERLESS_TOKEN set from config" in result.detail
|
|
|
|
def test_browserless_cloud_without_token_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("BROWSERLESS_TOKEN", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: https://production-sfo.browserless.io\n")
|
|
|
|
result = doctor.check_web_capture(cfg)
|
|
|
|
assert result.status == "warn"
|
|
assert "BROWSERLESS_TOKEN" in (result.fix or "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_image_search
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckImageSearch:
|
|
def test_ddg_always_ok(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.image_search.tools:image_search_tool\n")
|
|
result = doctor.check_image_search(cfg)
|
|
assert result.status == "ok"
|
|
assert "DuckDuckGo" in result.detail
|
|
|
|
def test_serper_with_key_ok(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("SERPER_API_KEY", "test-key")
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.serper.tools:image_search_tool\n")
|
|
result = doctor.check_image_search(cfg)
|
|
assert result.status == "ok"
|
|
assert "serper" in result.detail
|
|
|
|
def test_serper_without_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("SERPER_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.serper.tools:image_search_tool\n")
|
|
result = doctor.check_image_search(cfg)
|
|
assert result.status == "warn"
|
|
assert "SERPER_API_KEY" in (result.fix or "")
|
|
|
|
def test_serper_inline_api_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("SERPER_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.serper.tools:image_search_tool\n api_key: inline-key\n")
|
|
result = doctor.check_image_search(cfg)
|
|
assert result.status == "warn"
|
|
assert "literal api_key set in config" in result.detail
|
|
assert "SERPER_API_KEY" in (result.fix or "")
|
|
|
|
def test_serper_config_env_ref_without_env_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("SERPER_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.serper.tools:image_search_tool\n api_key: $SERPER_API_KEY\n")
|
|
result = doctor.check_image_search(cfg)
|
|
assert result.status == "warn"
|
|
assert "SERPER_API_KEY" in (result.fix or "")
|
|
|
|
def test_brave_image_search_with_key_ok(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "bsa-test")
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.brave.tools:image_search_tool\n")
|
|
result = doctor.check_image_search(cfg)
|
|
assert result.status == "ok"
|
|
assert "brave" in result.detail
|
|
|
|
def test_brave_image_search_without_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.brave.tools:image_search_tool\n")
|
|
result = doctor.check_image_search(cfg)
|
|
assert result.status == "warn"
|
|
assert "BRAVE_SEARCH_API_KEY" in (result.fix or "")
|
|
|
|
def test_brave_image_search_inline_api_key_warns(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.brave.tools:image_search_tool\n api_key: inline-key\n")
|
|
result = doctor.check_image_search(cfg)
|
|
assert result.status == "warn"
|
|
assert "literal api_key set in config" in result.detail
|
|
assert "BRAVE_SEARCH_API_KEY" in (result.fix or "")
|
|
|
|
def test_infoquest_with_key_ok(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("INFOQUEST_API_KEY", "test-key")
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.infoquest.tools:image_search_tool\n")
|
|
result = doctor.check_image_search(cfg)
|
|
assert result.status == "ok"
|
|
assert "infoquest" in result.detail
|
|
|
|
def test_no_image_search_tool_warns(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools: []\n")
|
|
result = doctor.check_image_search(cfg)
|
|
assert result.status == "warn"
|
|
assert result.fix is not None
|
|
|
|
def test_invalid_provider_use_fails(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.not_real.tools:image_search_tool\n")
|
|
result = doctor.check_image_search(cfg)
|
|
assert result.status == "fail"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_env_file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckEnvFile:
|
|
def test_missing(self, tmp_path):
|
|
result = doctor.check_env_file(tmp_path)
|
|
assert result.status == "warn"
|
|
|
|
def test_present(self, tmp_path):
|
|
(tmp_path / ".env").write_text("KEY=val\n")
|
|
result = doctor.check_env_file(tmp_path)
|
|
assert result.status == "ok"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_frontend_env
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckFrontendEnv:
|
|
def test_missing(self, tmp_path):
|
|
result = doctor.check_frontend_env(tmp_path)
|
|
assert result.status == "warn"
|
|
|
|
def test_present(self, tmp_path):
|
|
frontend_dir = tmp_path / "frontend"
|
|
frontend_dir.mkdir()
|
|
(frontend_dir / ".env").write_text("KEY=val\n")
|
|
result = doctor.check_frontend_env(tmp_path)
|
|
assert result.status == "ok"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_sandbox
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckSandbox:
|
|
def test_missing_sandbox_fails(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\n")
|
|
results = doctor.check_sandbox(cfg)
|
|
assert results[0].status == "fail"
|
|
|
|
def test_commented_out_tools_block_reports_no_traceback(self, tmp_path):
|
|
# Regression: iterating a null `tools:` raised TypeError, which the
|
|
# broad handler rendered as "('NoneType' object is not iterable)".
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\nsandbox:\n use: deerflow.sandbox.local:LocalSandboxProvider\ntools:\n # - name: bash\n")
|
|
results = doctor.check_sandbox(cfg)
|
|
# Empty `tools:` means no bash tool, so the path is deterministic.
|
|
assert len(results) == 1
|
|
assert results[0].status == "ok"
|
|
assert results[0].detail == "Local sandbox"
|
|
|
|
def test_local_sandbox_with_disabled_host_bash_warns(self, tmp_path):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\nsandbox:\n use: deerflow.sandbox.local:LocalSandboxProvider\n allow_host_bash: false\ntools:\n - name: bash\n use: deerflow.sandbox.tools:bash_tool\n")
|
|
results = doctor.check_sandbox(cfg)
|
|
assert any(result.status == "warn" for result in results)
|
|
|
|
def test_container_sandbox_without_runtime_warns(self, tmp_path, monkeypatch):
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text("config_version: 5\nsandbox:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\ntools: []\n")
|
|
monkeypatch.setattr(doctor.shutil, "which", lambda _name: None)
|
|
results = doctor.check_sandbox(cfg)
|
|
assert any(result.label == "container runtime available" and result.status == "warn" for result in results)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# main() exit code
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMainExitCode:
|
|
def test_returns_int(self, tmp_path, monkeypatch, capsys):
|
|
"""main() should return 0 or 1 without raising."""
|
|
repo_root = tmp_path / "repo"
|
|
scripts_dir = repo_root / "scripts"
|
|
scripts_dir.mkdir(parents=True)
|
|
fake_doctor = scripts_dir / "doctor.py"
|
|
fake_doctor.write_text("# test-only shim for __file__ resolution\n")
|
|
|
|
monkeypatch.chdir(repo_root)
|
|
monkeypatch.setattr(doctor, "__file__", str(fake_doctor))
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
|
|
|
exit_code = doctor.main()
|
|
|
|
captured = capsys.readouterr()
|
|
output = captured.out + captured.err
|
|
|
|
assert exit_code in (0, 1)
|
|
assert output
|
|
assert "config.yaml" in output
|
|
assert ".env" in output
|