1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages
DocForge2/tests/test_adapter_launcher.py

326 lines
12 KiB
Python

from __future__ import annotations
import json
import os
import shutil
import tempfile
import tomllib
import unittest
from dataclasses import replace
from pathlib import Path
from unittest import mock
from jsonschema import Draft202012Validator
from referencing import Registry, Resource
from docforge.adapter_contract import AdapterNode, AdapterProject, AdapterProjection
from docforge.adapter_launcher import AdapterLauncherV1
from docforge.client_config import (
_validate_adapter_configuration_result,
generate_adapter_client_configuration,
generate_client_configuration,
)
from docforge.errors import DocForgeError
from docforge.models import Node
from docforge.project import Project
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
SCHEMAS = ROOT / "schemas"
LAUNCHER_SCHEMA = json.loads((SCHEMAS / "adapter-launcher.schema.json").read_text(encoding="utf-8"))
ADAPTER_CONFIGURATION_SCHEMA = json.loads(
(SCHEMAS / "adapter-client-configuration.schema.json").read_text(encoding="utf-8")
)
GENERIC_CONFIGURATION_SCHEMA = json.loads(
(SCHEMAS / "client-configuration.schema.json").read_text(encoding="utf-8")
)
SCHEMA_REGISTRY = Registry().with_resources(
(
(LAUNCHER_SCHEMA["$id"], Resource.from_contents(LAUNCHER_SCHEMA)),
(
GENERIC_CONFIGURATION_SCHEMA["$id"],
Resource.from_contents(GENERIC_CONFIGURATION_SCHEMA),
),
(
ADAPTER_CONFIGURATION_SCHEMA["$id"],
Resource.from_contents(ADAPTER_CONFIGURATION_SCHEMA),
),
)
)
ADAPTER_CONFIGURATION_VALIDATOR = Draft202012Validator(
ADAPTER_CONFIGURATION_SCHEMA,
registry=SCHEMA_REGISTRY,
)
class Loader:
def __init__(self, projection: AdapterProjection) -> None:
self.projection = projection
self.load_calls = 0
def load_projection(self) -> AdapterProjection:
self.load_calls += 1
return self.projection
class DriftingLoader(Loader):
def __init__(
self,
projection: AdapterProjection,
changed: AdapterProjection,
) -> None:
super().__init__(projection)
self.changed = changed
def load_projection(self) -> AdapterProjection:
self.load_calls += 1
return self.projection if self.load_calls < 3 else self.changed
class AdapterLauncherTests(unittest.TestCase):
def copy_fixture(self, destination: Path) -> Path:
root = destination / "alpha"
shutil.copytree(FIXTURES / "alpha", root)
return root
def projection(
self,
root: Path,
*,
source_hash: str = "3" * 64,
) -> AdapterProjection:
node = Node(
node_id="source.entry",
title="Entry",
family="source",
authority="derived",
status="active",
tags=("python",),
summary="Reference adapter entry.",
content="Reference adapter content.",
source_path="src/entry.py",
source_anchor="L1",
content_hash="2" * 64,
)
return AdapterProjection(
project_id="adapter-client-fixture",
title="Adapter client fixture",
adapter_id="fixture-client",
adapter_version="1",
root=root,
revision="fixture-revision",
source_hash=source_hash,
nodes=(AdapterNode(node),),
edges=(),
)
def project(
self,
root: Path,
*,
loader: Loader | None = None,
) -> tuple[AdapterProject, Loader]:
effective_loader = loader or Loader(self.projection(root))
return (
AdapterProject(
effective_loader,
cache_root=root / ".docforge-cache" / "adapter-client",
),
effective_loader,
)
def test_launcher_and_all_client_fragments_are_deterministic_and_schema_valid(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
project, _ = self.project(root)
launcher = AdapterLauncherV1.for_project(
project,
module="fixture_adapter.mcp_server",
)
Draft202012Validator(LAUNCHER_SCHEMA).validate(launcher.as_dict())
previous = os.environ.get("DOCFORGE_ADAPTER_LAUNCHER_SECRET")
os.environ["DOCFORGE_ADAPTER_LAUNCHER_SECRET"] = "must-not-appear"
try:
with mock.patch("docforge.client_config.subprocess.run") as executed:
results: dict[str, dict[str, object]] = {}
for client in ("codex", "claude", "openclaw"):
first = generate_adapter_client_configuration(
project,
launcher,
client,
no_ast=True,
)
second = generate_adapter_client_configuration(
project,
launcher,
client,
no_ast=True,
)
self.assertEqual(first, second)
ADAPTER_CONFIGURATION_VALIDATOR.validate(first)
self.assertEqual(launcher.launcher_hash, first["launcher_hash"])
self.assertEqual(
launcher.launcher_hash,
first["binding"]["launcher_hash"],
)
self.assertEqual(
[
"-I",
"-m",
"fixture_adapter.mcp_server",
"--project-root",
str(root),
"--capability-mode",
"read",
"--no-ast",
],
first["binding"]["args"],
)
self.assertEqual({}, first["binding"]["environment"])
self.assertNotIn("cwd", first["binding"])
self.assertNotIn(
"must-not-appear",
json.dumps(first, sort_keys=True),
)
results[client] = first
executed.assert_not_called()
finally:
if previous is None:
os.environ.pop("DOCFORGE_ADAPTER_LAUNCHER_SECRET", None)
else:
os.environ["DOCFORGE_ADAPTER_LAUNCHER_SECRET"] = previous
self.assertIn(
"mcp_servers",
tomllib.loads(results["codex"]["artifact"]["content"]),
)
self.assertIn(
"mcpServers",
json.loads(results["claude"]["artifact"]["content"]),
)
self.assertIn(
"mcp",
json.loads(results["openclaw"]["artifact"]["content"]),
)
def test_launcher_rejects_module_path_entry_point_and_argument_injection(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
project, _ = self.project(root)
launcher = AdapterLauncherV1.for_project(
project,
module="fixture_adapter.mcp_server",
)
for module in (
"-m",
"fixture_adapter.mcp_server --debug",
"fixture_adapter:mcp_server",
"fixture_adapter/mcp_server",
".fixture_adapter",
"docforge.mcp_server",
"fixture_adapter.$server",
):
with self.subTest(module=module), self.assertRaises(DocForgeError) as captured:
replace(launcher, module=module)
self.assertEqual("invalid_adapter_launcher", captured.exception.code)
with self.assertRaises(DocForgeError):
replace(launcher, project_root=Path("relative/project"))
with self.assertRaises(DocForgeError):
replace(launcher, entry_point="console-script") # type: ignore[arg-type]
payload = launcher.as_dict()
payload["arguments"] = ["--shell", "command"]
with self.assertRaises(TypeError):
AdapterLauncherV1(**payload) # type: ignore[arg-type]
result = generate_adapter_client_configuration(project, launcher, "codex")
result["binding"]["args"].append("--arbitrary")
with self.assertRaisesRegex(AssertionError, "Generated adapter client"):
_validate_adapter_configuration_result(
result,
project=project,
launcher=launcher,
source_availability=project_source_availability(result),
)
def test_wrong_project_descriptor_and_source_drift_fail_closed(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
first_root = (parent / "first").resolve()
second_root = (parent / "second").resolve()
first_root.mkdir()
second_root.mkdir()
first, _ = self.project(first_root)
second, _ = self.project(second_root)
launcher = AdapterLauncherV1.for_project(
first,
module="fixture_adapter.mcp_server",
)
with self.assertRaises(DocForgeError) as wrong_project:
generate_adapter_client_configuration(second, launcher, "codex")
self.assertEqual("adapter_launcher_mismatch", wrong_project.exception.code)
with self.assertRaises(DocForgeError) as descriptor_drift:
generate_adapter_client_configuration(
first,
replace(launcher, descriptor_hash="0" * 64),
"codex",
)
self.assertEqual("adapter_launcher_mismatch", descriptor_drift.exception.code)
base = self.projection(first_root)
changed = replace(base, source_hash="4" * 64)
drifting, _ = self.project(
first_root,
loader=DriftingLoader(base, changed),
)
drifting_launcher = AdapterLauncherV1.for_project(
drifting,
module="fixture_adapter.mcp_server",
)
with self.assertRaises(DocForgeError) as source_drift:
generate_adapter_client_configuration(
drifting,
drifting_launcher,
"openclaw",
)
self.assertEqual("source_changed", source_drift.exception.code)
def test_generic_configuration_remains_separate_and_compatible(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
generic = Project.open(root)
result = generate_client_configuration(generic, "codex", no_ast=True)
Draft202012Validator(GENERIC_CONFIGURATION_SCHEMA).validate(result)
self.assertEqual("client.configure", result["operation"])
self.assertNotIn("launcher", result)
with self.assertRaises(DocForgeError) as generic_launcher:
AdapterLauncherV1.for_project(
generic,
module="fixture_adapter.mcp_server",
)
self.assertEqual("adapter_launcher_unavailable", generic_launcher.exception.code)
custom_root = (Path(directory) / "custom").resolve()
custom_root.mkdir()
custom, _ = self.project(custom_root)
with self.assertRaises(DocForgeError) as generic_api:
generate_client_configuration(custom, "codex")
self.assertEqual("missing_config", generic_api.exception.code)
def project_source_availability(result: dict[str, object]):
from docforge.adapter_launcher import AdapterSourceAvailabilityV1
payload = result["source_availability"]
return AdapterSourceAvailabilityV1(
schema_version=payload["schema_version"],
status=payload["status"],
method=payload["method"],
revision=payload["revision"],
source_hash=payload["source_hash"],
)
if __name__ == "__main__":
unittest.main()