ChatDev/tests/test_server_main_reload.py
voidborne-d 7c69226bcf fix(server): scope --reload watching to source dirs (#569)
When ``python server_main.py --reload`` was used, uvicorn's default
reload_dirs is the current working directory and StatReload walks the
whole tree for ``*.py`` files. Agent-generated code under
``WareHouse/session_<uuid>/code_workspace/<file>.py`` therefore triggers
a server restart mid-workflow; the webui is left waiting indefinitely
and the in-flight session is cancelled.

The project already ships a warning in both READMEs telling users to
drop ``--reload``, but that is exactly the tool dev loops need.

Fix: pass an explicit ``reload_dirs`` list containing only the server's
Python source folders (check, entity, functions, mcp_example, runtime,
schema_registry, server, tools, utils, workflow) and a matching
``reload_excludes`` set (WareHouse, logs, data, temp, node_modules) so
watchfiles-backed installs also stop observing output directories.

Users can override either list via repeatable ``--reload-dir`` and
``--reload-exclude`` flags.

The reload-kwargs construction is extracted into a pure helper so the
behaviour is unit-tested without spinning up a real server; nine new
tests cover the default behaviour, user overrides, argparse wiring, and
that the returned lists are defensive copies.

README / README-zh have been updated to reflect the new default.

Fixes #569
2026-04-23 20:07:30 +08:00

135 lines
4.8 KiB
Python

"""Tests for ``server_main.build_reload_kwargs``.
Regression coverage for issue #569: when ``--reload`` is active the default
watch configuration must exclude the WareHouse/ output directory, otherwise
agent-generated files trigger a StatReload restart mid-workflow and the
webui hangs indefinitely.
The tests load ``server_main`` through an isolated ``importlib`` spec so
that the stubs we inject for its heavy dependencies (``runtime.bootstrap``
and ``server.app``) are cleaned up automatically and do not leak into the
``sys.modules`` cache shared with the rest of the test suite.
"""
import argparse
import importlib.util
import sys
from pathlib import Path
from types import ModuleType
from unittest.mock import MagicMock
import pytest
SERVER_MAIN_PATH = Path(__file__).resolve().parent.parent / "server_main.py"
@pytest.fixture
def server_main(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
"""Load ``server_main`` with heavy imports stubbed, cleaning up after."""
stubs = {}
def _stub(name: str) -> ModuleType:
stub = MagicMock(name=name)
stubs[name] = stub
return stub
for name in (
"runtime",
"runtime.bootstrap",
"runtime.bootstrap.schema",
"server",
"server.app",
):
monkeypatch.setitem(sys.modules, name, _stub(name))
# Expose ensure_schema_registry_populated as a no-op callable on its module.
stubs["runtime.bootstrap.schema"].ensure_schema_registry_populated = lambda: None
stubs["server.app"].app = MagicMock(name="app")
spec = importlib.util.spec_from_file_location(
"server_main_under_test", SERVER_MAIN_PATH
)
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
yield module
finally:
sys.modules.pop("server_main_under_test", None)
def _args(**overrides) -> argparse.Namespace:
defaults = dict(
reload=False,
reload_dir=None,
reload_exclude=None,
)
defaults.update(overrides)
return argparse.Namespace(**defaults)
class TestBuildReloadKwargs:
def test_reload_disabled_returns_empty(self, server_main):
assert server_main.build_reload_kwargs(_args(reload=False)) == {}
def test_default_watches_source_dirs_only(self, server_main):
kw = server_main.build_reload_kwargs(_args(reload=True))
assert "server" in kw["reload_dirs"]
assert "runtime" in kw["reload_dirs"]
# The output dir that caused the bug must not be watched.
assert "WareHouse" not in kw["reload_dirs"]
def test_default_excludes_cover_output_dirs(self, server_main):
kw = server_main.build_reload_kwargs(_args(reload=True))
excludes = kw["reload_excludes"]
# Core regression: WareHouse/ writes must be ignored by watchfiles.
assert any("WareHouse" in p for p in excludes)
# Logs, data, temp, node_modules live in .gitignore too.
assert any("logs" in p for p in excludes)
def test_returned_lists_are_copies(self, server_main):
"""Callers mutating the result must not poison the defaults."""
kw = server_main.build_reload_kwargs(_args(reload=True))
kw["reload_dirs"].append("WareHouse")
kw["reload_excludes"].append("junk")
fresh = server_main.build_reload_kwargs(_args(reload=True))
assert "WareHouse" not in fresh["reload_dirs"]
assert "junk" not in fresh["reload_excludes"]
def test_user_reload_dir_overrides_default(self, server_main):
kw = server_main.build_reload_kwargs(
_args(reload=True, reload_dir=["app", "lib"])
)
assert kw["reload_dirs"] == ["app", "lib"]
# Excludes keep their defaults.
assert any("WareHouse" in p for p in kw["reload_excludes"])
def test_user_reload_exclude_overrides_default(self, server_main):
kw = server_main.build_reload_kwargs(
_args(reload=True, reload_exclude=["*.md"])
)
assert kw["reload_excludes"] == ["*.md"]
# Dirs keep their defaults.
assert "server" in kw["reload_dirs"]
class TestParserFlags:
def test_reload_dir_is_repeatable(self, server_main):
parser = server_main.build_parser()
args = parser.parse_args(["--reload", "--reload-dir", "a", "--reload-dir", "b"])
assert args.reload_dir == ["a", "b"]
def test_reload_exclude_is_repeatable(self, server_main):
parser = server_main.build_parser()
args = parser.parse_args(
["--reload", "--reload-exclude", "x/*", "--reload-exclude", "y/*"]
)
assert args.reload_exclude == ["x/*", "y/*"]
def test_defaults_produce_empty_override_slots(self, server_main):
parser = server_main.build_parser()
args = parser.parse_args([])
assert args.reload is False
assert args.reload_dir is None
assert args.reload_exclude is None