mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-18 13:46:02 +00:00
0bbbbc06f4
* feat(community): add Serper Google Images provider for image_search Add a Serper-backed `image_search` tool alongside the existing Serper `web_search` provider, so users with a SERPER_API_KEY can pull Google Images results as reference images for downstream image generation. - Share request/response handling between web_search and image_search via `_serper_post` / `_response_items`, with bounded `max_results` (capped at 10) and query normalization. - Add a best-effort SSRF guard (`_safe_public_url`) that rejects non-http(s), localhost and private/non-global IP image URLs; filtered entries are dropped and never consume the result limit. - doctor: flag literal `api_key` values in config as a warning and steer users toward `.env` + `$SERPER_API_KEY`. - Docs/config: document the Serper image_search provider and SERPER_API_KEY, and discourage committing literal keys to config.yaml. - Tests: cover the provider end-to-end (100% line coverage on tools.py) and the doctor literal-key warning path. * fix(community): block obfuscated IPv4 literals in Serper image SSRF guard The image_search SSRF guard only rejected dotted-decimal IP literals; encoded forms such as decimal (http://2130706433/), hex (0x7f000001) and octal (0177.0.0.1) raised ValueError in ip_address() and were allowed through, even though many HTTP clients resolve them to private addresses like 127.0.0.1. Add _decode_ipv4() to permissively decode these inet_aton-style encodings and apply the same is_global check; hostnames that do not decode to an IP (e.g. cafe.com) are still treated as hosts and left to fetch-time re-validation. Addresses PR review feedback. Tests cover decimal/hex/octal loopback and private encodings plus non-IP edge cases; tools.py stays at 100% line coverage. * test(community): cover IPv4-mapped IPv6 URL filtering * fix(community): address Serper image search review feedback - Block trailing-dot hostname SSRF bypass (localhost./127.0.0.1.) in _safe_public_url by stripping the FQDN root label before checks. - Keep a filtered image/thumbnail URL empty instead of collapsing onto its counterpart, preserving the high-res/preview contract. - Evaluate the SSRF guard once per field rather than twice. - Treat a null-typed organic/images field as "no results" rather than a malformed payload. - doctor.py: when a config $VAR is unset, fall through to the default env var before reporting it as not set.
498 lines
22 KiB
Python
498 lines
22 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 sys
|
|
|
|
import doctor
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_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_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_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_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_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_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_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
|