Files
dify/api/tests/unit_tests/dev/test_cached_console_openapi.py
T
Stephen Zhouandautofix-ci[bot] 04ba877c1a feat(web): export OpenAPI mapper types and compatible contract subpaths (#44915)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-10-28 14:39:37 +00:00

234 lines
9.0 KiB
Python

"""Exercise the actual cache client/server, with small isolated backend modules."""
import concurrent.futures
import json
import os
import py_compile
import shutil
import subprocess
import sys
import tempfile
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
import pytest
pytestmark = pytest.mark.skipif(not hasattr(os, "fork"), reason="The memory export server uses POSIX isolation")
@dataclass
class CacheClient:
api: Path
env: dict[str, str]
used_envs: list[dict[str, str]] = field(default_factory=list)
def request(self, *, setting: str | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
request_env = self.env if setting is None else {**self.env, "OPENAPI_TEST_SETTING": setting}
self.used_envs.append(request_env)
return subprocess.run(
[sys.executable, str(self.api / "dev/cached_console_openapi.py")],
cwd=self.api.parent,
env=request_env,
capture_output=True,
text=True,
timeout=30,
check=check,
)
def export(self, *, setting: str | None = None) -> dict[str, str | int | None]:
return json.loads(self.request(setting=setting).stdout)["console"]
@pytest.fixture
def backend(tmp_path: Path) -> Iterator[CacheClient]:
api = tmp_path / "api"
(api / "dev").mkdir(parents=True)
script = api / "dev/cached_console_openapi.py"
original = Path(__file__).resolve().parents[3] / "dev/cached_console_openapi.py"
shutil.copyfile(original, script)
modules = {
"dev/__init__.py": "",
"dev/generate_swagger_specs.py": "def apply_runtime_defaults(): pass\n",
"models.py": "import secrets\nVALUE = 'v1'\nSNAPSHOT = secrets.token_hex(8)\n",
"controllers/fixture.py": "FIELD = 'integer'\n",
"core/mcp/types.py": "",
"dify_agent/protocol/schemas.py": "",
"graphon/nodes/parameter_extractor/entities.py": "",
"services/knowledge_fs/product_dto.py": "",
"dev/export_console_openapi.py": """
import json, os
def main():
from controllers.fixture import FIELD
from models import VALUE, SNAPSHOT
print(json.dumps({'console': {'field': FIELD, 'model': VALUE, 'pid': os.getpid(),
'snapshot': SNAPSHOT, 'setting': os.getenv('OPENAPI_TEST_SETTING')}, 'fast': {}}))
if __name__ == '__main__': main()
""",
}
for filename, content in modules.items():
path = api / filename
path.parent.mkdir(parents=True, exist_ok=True)
for parent in path.parents:
if parent == api:
break
(parent / "__init__.py").touch()
path.write_text(content)
client = CacheClient(api, {**os.environ, "PYTHONPATH": str(api)})
yield client
for request_env in client.used_envs:
subprocess.run(
[sys.executable, str(script), "--stop"],
cwd=tmp_path,
env=request_env,
capture_output=True,
timeout=10,
check=True,
)
def test_memory_cache_is_shared_and_ignores_test_files(backend: CacheClient) -> None:
api, call = backend.api, backend.export
first = call()
assert call() == first
(api / "tests").mkdir()
(api / "tests/test_unrelated.py").write_text("# unrelated test\n")
assert call() == first
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
assert all(result == first for result in pool.map(lambda _: call(), range(4)))
assert not list(api.rglob("*.json"))
assert not list(api.rglob("*.pyc"))
def test_changed_controller_is_fresh_and_preloaded_dependency_restarts_snapshot(backend: CacheClient) -> None:
api, call = backend.api, backend.export
first = call()
controller = api / "controllers/fixture.py"
controller.write_text("FIELD = 'number'\n")
second = call()
assert second["field"] == "number"
assert second["pid"] != first["pid"]
assert second["snapshot"] == first["snapshot"]
# Same-size edit with unchanged mtime must not revive a stale .pyc or snapshot.
model = api / "models.py"
stat = model.stat()
model.write_text(model.read_text().replace("'v1'", "'v2'"))
os.utime(model, ns=(stat.st_atime_ns, stat.st_mtime_ns))
third = call()
assert third["model"] == "v2"
assert third["snapshot"] != second["snapshot"]
assert third["field"] == "number"
def test_export_errors_never_return_old_contracts_and_recover_after_fix(backend: CacheClient) -> None:
api, call = backend.api, backend.export
first = call()
controller = api / "controllers/fixture.py"
controller.write_text("FIELD = syntax error\n")
failure = backend.request(check=False)
assert failure.returncode != 0
assert not failure.stdout
assert "SyntaxError" in failure.stderr
controller.write_text("FIELD = 'string'\n")
fixed = call()
assert fixed["field"] == "string"
assert fixed["pid"] != first["pid"]
def test_environment_and_file_additions_invalidate_memory_cache(backend: CacheClient) -> None:
api, call = backend.api, backend.export
first = call()
variant = call(setting="variant")
assert variant["setting"] == "variant"
assert variant["snapshot"] != first["snapshot"]
(api / "new_dependency.py").write_text("VALUE = 1\n")
added = call()
assert added["snapshot"] != first["snapshot"]
(api / "new_dependency.py").unlink()
assert call()["snapshot"] != added["snapshot"]
def test_sibling_python_models_refresh_cached_exports_without_stale_bytecode(backend: CacheClient) -> None:
package = backend.api.parent / "dify-agent/src/citation_fixture"
package.mkdir(parents=True)
(package / "__init__.py").touch()
model = package / "model.py"
model.write_text("FIELD = 'string'\n")
compiled = py_compile.compile(str(model), doraise=True)
assert compiled is not None
bytecode = Path(compiled).read_bytes()
# This module is imported by the export worker, not by the preloaded snapshot.
(backend.api / "controllers/fixture.py").write_text("from citation_fixture.model import FIELD\n")
backend.env["PYTHONPATH"] = os.pathsep.join([str(backend.api), str(package.parent)])
first = backend.export()
assert first["field"] == "string"
assert backend.export() == first
stat = model.stat()
model.write_text("FIELD = 'number'\n")
os.utime(model, ns=(stat.st_atime_ns, stat.st_mtime_ns))
changed = backend.export()
assert changed["field"] == "number"
assert changed["pid"] != first["pid"]
added = package / "new_model.py"
added.write_text("VALUE = 1\n")
with_added = backend.export()
assert with_added["pid"] != changed["pid"]
added.unlink()
assert backend.export()["pid"] != with_added["pid"]
assert Path(compiled).read_bytes() == bytecode
def test_native_threads_use_fresh_exports_and_propagate_failures(backend: CacheClient) -> None:
model = backend.api / "models.py"
model.write_text(
model.read_text()
+ "import threading, time\n"
+ "threading.Thread(target=time.sleep, args=(60,), daemon=True).start()\n"
)
first = backend.export()
controller = backend.api / "controllers/fixture.py"
controller.write_text("FIELD = 'string'\n")
changed = backend.export()
assert changed["field"] == "string"
assert changed["snapshot"] != first["snapshot"]
controller.write_text("FIELD = syntax error\n")
failure = backend.request(check=False)
assert failure.returncode != 0
assert not failure.stdout
assert "SyntaxError" in failure.stderr
def test_invalid_export_payload_does_not_replace_a_valid_contract(backend: CacheClient) -> None:
first = backend.export()
exporter = backend.api / "dev/export_console_openapi.py"
original = exporter.read_text()
exporter.write_text("def main(): print('{\"unexpected\": {}}')\n")
failure = backend.request(check=False)
assert failure.returncode != 0
assert not failure.stdout
assert "Invalid Console export response" in failure.stderr
exporter.write_text(original)
assert backend.export()["field"] == first["field"]
def test_failed_preload_falls_back_to_fresh_exports(backend: CacheClient) -> None:
(backend.api / "core/mcp/types.py").write_text("raise RuntimeError('Cannot preload this dependency')\n")
first = backend.export()
(backend.api / "controllers/fixture.py").write_text("FIELD = 'string'\n")
changed = backend.export()
assert changed["field"] == "string"
assert changed["snapshot"] != first["snapshot"]
def test_socket_directory_must_be_private(monkeypatch: pytest.MonkeyPatch) -> None:
from dev.cached_console_openapi import socket_path
# Keep this short enough for the Unix socket path limit on macOS.
with tempfile.TemporaryDirectory(prefix="dify-ipc-", dir="/tmp") as directory:
monkeypatch.setattr(tempfile, "tempdir", directory)
path = socket_path()
assert path.parent.stat().st_mode & 0o077 == 0
path.parent.chmod(0o755)
with pytest.raises(PermissionError, match="private to the current user"):
socket_path()