mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-23 08:25:57 +00:00
fix(harness): resolve runtime paths from project root (#2642)
* fix(harness): resolve runtime paths from project root * docs(config): update * fix(config): address runtime path review feedback * test(config): fix skills path e2e root * test(config): cover legacy config fallback when project root lacks config files Verifies that when DEER_FLOW_PROJECT_ROOT is unset and cwd has no config.yaml/extensions_config.json, AppConfig and ExtensionsConfig fall back to the legacy backend/repo-root candidates — the backward-compat path requested in PR #2642 review. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import json
|
||||
import os
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
@@ -94,12 +95,18 @@ def e2e_env(tmp_path, monkeypatch):
|
||||
"""Isolated filesystem environment for E2E tests.
|
||||
|
||||
- DEER_FLOW_HOME → tmp_path (all thread data lands in a temp dir)
|
||||
- DEER_FLOW_PROJECT_ROOT → repository root (shared skills/config assets
|
||||
still resolve correctly when tests run from backend/)
|
||||
- Singletons reset so they pick up the new env
|
||||
- Title/memory/summarization disabled to avoid extra LLM calls
|
||||
- AppConfig built programmatically (avoids config.yaml param-name issues)
|
||||
"""
|
||||
# 1. Filesystem isolation
|
||||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||||
monkeypatch.setenv(
|
||||
"DEER_FLOW_PROJECT_ROOT",
|
||||
str(Path(__file__).resolve().parents[2]),
|
||||
)
|
||||
monkeypatch.setattr("deerflow.config.paths._paths", None)
|
||||
monkeypatch.setattr("deerflow.sandbox.sandbox_provider._default_sandbox_provider", None)
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Runtime path policy tests for standalone harness usage."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from deerflow.config import app_config as app_config_module
|
||||
from deerflow.config import extensions_config as extensions_config_module
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.extensions_config import ExtensionsConfig
|
||||
from deerflow.config.paths import Paths
|
||||
from deerflow.config.runtime_paths import project_root
|
||||
from deerflow.config.skills_config import SkillsConfig
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
|
||||
|
||||
def _clear_path_env(monkeypatch):
|
||||
for name in (
|
||||
"DEER_FLOW_CONFIG_PATH",
|
||||
"DEER_FLOW_EXTENSIONS_CONFIG_PATH",
|
||||
"DEER_FLOW_HOME",
|
||||
"DEER_FLOW_PROJECT_ROOT",
|
||||
"DEER_FLOW_SKILLS_PATH",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
def test_default_runtime_paths_resolve_from_current_project(tmp_path: Path, monkeypatch):
|
||||
_clear_path_env(monkeypatch)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
yaml.safe_dump({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "extensions_config.json").write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
|
||||
|
||||
assert AppConfig.resolve_config_path() == tmp_path / "config.yaml"
|
||||
assert ExtensionsConfig.resolve_config_path() == tmp_path / "extensions_config.json"
|
||||
assert Paths().base_dir == tmp_path / ".deer-flow"
|
||||
assert SkillsConfig().get_skills_path() == tmp_path / "skills"
|
||||
assert get_or_new_skill_storage(skills_path=SkillsConfig().get_skills_path()).get_skills_root_path() == tmp_path / "skills"
|
||||
|
||||
|
||||
def test_deer_flow_project_root_overrides_current_directory(tmp_path: Path, monkeypatch):
|
||||
_clear_path_env(monkeypatch)
|
||||
project_root = tmp_path / "project"
|
||||
other_cwd = tmp_path / "other"
|
||||
project_root.mkdir()
|
||||
other_cwd.mkdir()
|
||||
monkeypatch.chdir(other_cwd)
|
||||
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project_root))
|
||||
|
||||
(project_root / "config.yaml").write_text(
|
||||
yaml.safe_dump({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(project_root / "mcp_config.json").write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
|
||||
|
||||
assert AppConfig.resolve_config_path() == project_root / "config.yaml"
|
||||
assert ExtensionsConfig.resolve_config_path() == project_root / "mcp_config.json"
|
||||
assert Paths().base_dir == project_root / ".deer-flow"
|
||||
assert SkillsConfig(path="custom-skills").get_skills_path() == project_root / "custom-skills"
|
||||
|
||||
|
||||
def test_deer_flow_skills_path_overrides_project_default(tmp_path: Path, monkeypatch):
|
||||
_clear_path_env(monkeypatch)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("DEER_FLOW_SKILLS_PATH", "team-skills")
|
||||
|
||||
assert SkillsConfig().get_skills_path() == tmp_path / "team-skills"
|
||||
assert get_or_new_skill_storage(skills_path=SkillsConfig().get_skills_path()).get_skills_root_path() == tmp_path / "team-skills"
|
||||
|
||||
|
||||
def test_deer_flow_project_root_must_exist(tmp_path: Path, monkeypatch):
|
||||
_clear_path_env(monkeypatch)
|
||||
missing_root = tmp_path / "missing"
|
||||
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(missing_root))
|
||||
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
project_root()
|
||||
|
||||
|
||||
def test_deer_flow_project_root_must_be_directory(tmp_path: Path, monkeypatch):
|
||||
_clear_path_env(monkeypatch)
|
||||
project_root_file = tmp_path / "project-root"
|
||||
project_root_file.write_text("", encoding="utf-8")
|
||||
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project_root_file))
|
||||
|
||||
with pytest.raises(ValueError, match="not a directory"):
|
||||
project_root()
|
||||
|
||||
|
||||
def test_app_config_falls_back_to_legacy_when_project_root_lacks_config(tmp_path: Path, monkeypatch):
|
||||
"""When DEER_FLOW_PROJECT_ROOT is unset and cwd has no config.yaml, the
|
||||
legacy backend/repo-root candidates must be used for monorepo compatibility."""
|
||||
_clear_path_env(monkeypatch)
|
||||
cwd = tmp_path / "cwd"
|
||||
cwd.mkdir()
|
||||
monkeypatch.chdir(cwd)
|
||||
|
||||
legacy_backend = tmp_path / "legacy-backend"
|
||||
legacy_repo = tmp_path / "legacy-repo"
|
||||
legacy_backend.mkdir()
|
||||
legacy_repo.mkdir()
|
||||
legacy_backend_config = legacy_backend / "config.yaml"
|
||||
legacy_backend_config.write_text(
|
||||
yaml.safe_dump({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
repo_root_config = legacy_repo / "config.yaml"
|
||||
repo_root_config.write_text("", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
app_config_module,
|
||||
"_legacy_config_candidates",
|
||||
lambda: (legacy_backend_config, repo_root_config),
|
||||
)
|
||||
|
||||
assert AppConfig.resolve_config_path() == legacy_backend_config
|
||||
|
||||
|
||||
def test_extensions_config_falls_back_to_legacy_when_project_root_lacks_file(tmp_path: Path, monkeypatch):
|
||||
"""ExtensionsConfig should hit the legacy backend/repo-root locations when
|
||||
the caller project root has no extensions_config.json/mcp_config.json."""
|
||||
_clear_path_env(monkeypatch)
|
||||
cwd = tmp_path / "cwd"
|
||||
cwd.mkdir()
|
||||
monkeypatch.chdir(cwd)
|
||||
|
||||
fake_backend = tmp_path / "fake-backend"
|
||||
fake_repo = tmp_path / "fake-repo"
|
||||
fake_backend.mkdir()
|
||||
fake_repo.mkdir()
|
||||
legacy_extensions = fake_backend / "extensions_config.json"
|
||||
legacy_extensions.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
|
||||
|
||||
fake_paths_module_file = fake_backend / "packages" / "harness" / "deerflow" / "config" / "extensions_config.py"
|
||||
fake_paths_module_file.parent.mkdir(parents=True)
|
||||
fake_paths_module_file.write_text("", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(extensions_config_module, "__file__", str(fake_paths_module_file))
|
||||
|
||||
assert ExtensionsConfig.resolve_config_path() == legacy_extensions
|
||||
@@ -14,12 +14,25 @@ def _write_skill(skill_dir: Path, name: str, description: str) -> None:
|
||||
(skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def test_get_skills_root_path_points_to_project_root_skills():
|
||||
"""get_skills_root_path() should point to deer-flow/skills (sibling of backend/), not backend/packages/skills."""
|
||||
def test_get_skills_root_path_points_to_current_project_skills(tmp_path: Path, monkeypatch):
|
||||
"""get_skills_root_path() should point to the caller project skills directory."""
|
||||
monkeypatch.delenv("DEER_FLOW_SKILLS_PATH", raising=False)
|
||||
monkeypatch.delenv("DEER_FLOW_PROJECT_ROOT", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
app_config = SimpleNamespace(skills=SkillsConfig())
|
||||
path = get_or_new_skill_storage(app_config=app_config).get_skills_root_path()
|
||||
assert path.name == "skills", f"Expected 'skills', got '{path.name}'"
|
||||
assert (path.parent / "backend").is_dir(), f"Expected skills path's parent to be project root containing 'backend/', but got {path}"
|
||||
assert path == tmp_path / "skills"
|
||||
|
||||
|
||||
def test_get_skills_root_path_honors_env_override(tmp_path: Path, monkeypatch):
|
||||
"""DEER_FLOW_SKILLS_PATH should override the caller project skills directory."""
|
||||
skills_root = tmp_path / "team-skills"
|
||||
monkeypatch.setenv("DEER_FLOW_SKILLS_PATH", str(skills_root))
|
||||
|
||||
app_config = SimpleNamespace(skills=SkillsConfig())
|
||||
path = get_or_new_skill_storage(app_config=app_config).get_skills_root_path()
|
||||
assert path == skills_root
|
||||
|
||||
|
||||
def test_load_skills_discovers_nested_skills_and_sets_container_paths(tmp_path: Path):
|
||||
|
||||
Reference in New Issue
Block a user