Add project-bound graph visualization
This commit is contained in:
parent
6b5c3a939a
commit
195a57210a
13 changed files with 1298 additions and 22 deletions
|
|
@ -60,6 +60,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
("docforge_get_context", {"profile": "active", "budget": 180}),
|
||||
("docforge_validate_project", {}),
|
||||
("docforge_render_status", {}),
|
||||
("docforge_visualize", {"node_id": "guide.workflow", "depth": 1}),
|
||||
)
|
||||
async with create_connected_server_and_client_session(
|
||||
create_server(root), raise_exceptions=True
|
||||
|
|
@ -84,6 +85,11 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertFalse(contract["proposal_access"]["enabled"])
|
||||
self.assertTrue(results[10].structuredContent["configured"])
|
||||
self.assertEqual("stale", results[10].structuredContent["state"])
|
||||
visualization = results[11].structuredContent["visualization"]
|
||||
self.assertTrue(visualization["read_only"])
|
||||
self.assertTrue(visualization["project_bound"])
|
||||
self.assertEqual("graph-browser@1", visualization["template"])
|
||||
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
|
||||
context = results[8].structuredContent
|
||||
self.assertLessEqual(context["estimated_tokens"], 180)
|
||||
self.assertTrue(context["omissions"])
|
||||
|
|
|
|||
191
tests/test_visualization.py
Normal file
191
tests/test_visualization.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from docforge.errors import DocForgeError
|
||||
from docforge.index import ProjectIndex
|
||||
from docforge.project import Project
|
||||
from docforge.visualization import (
|
||||
VISUALIZATION_TEMPLATE,
|
||||
VisualizationIndexSnapshot,
|
||||
VisualizationRunner,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
|
||||
|
||||
class VisualizationTests(unittest.TestCase):
|
||||
def copy_fixture(self, name: str, destination: Path) -> Path:
|
||||
root = destination / name
|
||||
shutil.copytree(FIXTURES / name, root)
|
||||
return root
|
||||
|
||||
def test_overview_and_neighborhood_are_deterministic_and_bounded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
index = ProjectIndex(Project.open(root))
|
||||
index.build()
|
||||
snapshot = VisualizationIndexSnapshot(index, index.check())
|
||||
|
||||
overview = snapshot.overview()
|
||||
first = snapshot.node("guide.workflow", depth=2, limit=2)
|
||||
second = snapshot.node("guide.workflow", depth=2, limit=2)
|
||||
|
||||
self.assertEqual(3, overview["node_count"])
|
||||
self.assertEqual(2, overview["edge_count"])
|
||||
self.assertEqual(
|
||||
[
|
||||
{"value": "guide", "count": 2},
|
||||
{"value": "proof", "count": 1},
|
||||
],
|
||||
overview["families"],
|
||||
)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual("guide.workflow", first["root"])
|
||||
self.assertLessEqual(len(first["edges"]), 2)
|
||||
self.assertIn(
|
||||
"guide.workflow",
|
||||
{node["node_id"] for node in first["nodes"]},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(DocForgeError, "safety boundary"):
|
||||
snapshot.node("guide.workflow", depth=1, limit=401)
|
||||
|
||||
def test_runner_serves_only_token_bound_read_only_graph_endpoints(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
index = ProjectIndex(Project.open(root))
|
||||
index.build()
|
||||
runner = VisualizationRunner(index)
|
||||
try:
|
||||
first = runner.start(node_id="guide.workflow", depth=2)
|
||||
second = runner.start(query="canonical nodes")
|
||||
first_url = urllib.parse.urlparse(str(first["url"]))
|
||||
second_url = urllib.parse.urlparse(str(second["url"]))
|
||||
base = f"{first_url.scheme}://{first_url.netloc}{first_url.path}"
|
||||
|
||||
self.assertEqual(VISUALIZATION_TEMPLATE, first["template"])
|
||||
self.assertTrue(first["read_only"])
|
||||
self.assertEqual(first_url.netloc, second_url.netloc)
|
||||
self.assertEqual(first_url.path, second_url.path)
|
||||
|
||||
with urllib.request.urlopen(base, timeout=2) as response:
|
||||
html = response.read().decode("utf-8")
|
||||
headers = response.headers
|
||||
self.assertIn("DocForge graph", html)
|
||||
self.assertIn("default-src 'none'", headers["Content-Security-Policy"])
|
||||
self.assertEqual("no-store", headers["Cache-Control"])
|
||||
self.assertEqual("DENY", headers["X-Frame-Options"])
|
||||
|
||||
with urllib.request.urlopen(f"{base}api/overview", timeout=2) as response:
|
||||
overview = json.load(response)
|
||||
self.assertEqual("alpha-docs", overview["project_id"])
|
||||
self.assertEqual(3, overview["node_count"])
|
||||
self.assertEqual(20, overview["max_results"])
|
||||
|
||||
search_query = urllib.parse.urlencode(
|
||||
{"q": "canonical nodes", "family": "", "limit": overview["max_results"]}
|
||||
)
|
||||
with urllib.request.urlopen(
|
||||
f"{base}api/search?{search_query}", timeout=2
|
||||
) as response:
|
||||
search = json.load(response)
|
||||
self.assertEqual("alpha-docs", search["project_id"])
|
||||
self.assertGreaterEqual(search["count"], 1)
|
||||
|
||||
node_query = urllib.parse.urlencode(
|
||||
{"id": "guide.workflow", "depth": 1, "limit": 20}
|
||||
)
|
||||
with urllib.request.urlopen(f"{base}api/node?{node_query}", timeout=2) as response:
|
||||
node = json.load(response)
|
||||
self.assertEqual("guide.workflow", node["node"]["node_id"])
|
||||
self.assertEqual("guide.workflow", node["root"])
|
||||
|
||||
wrong_token = f"{first_url.scheme}://{first_url.netloc}/wrong-token/api/overview"
|
||||
with self.assertRaises(urllib.error.HTTPError) as missing:
|
||||
urllib.request.urlopen(wrong_token, timeout=2)
|
||||
self.assertEqual(404, missing.exception.code)
|
||||
missing.exception.close()
|
||||
|
||||
request = urllib.request.Request(
|
||||
f"{base}api/overview",
|
||||
data=b"{}",
|
||||
method="POST",
|
||||
)
|
||||
with self.assertRaises(urllib.error.HTTPError) as rejected:
|
||||
urllib.request.urlopen(request, timeout=2)
|
||||
self.assertEqual(405, rejected.exception.code)
|
||||
try:
|
||||
self.assertEqual(
|
||||
"method_not_allowed",
|
||||
json.loads(rejected.exception.read())["error"]["code"],
|
||||
)
|
||||
finally:
|
||||
rejected.exception.close()
|
||||
finally:
|
||||
runner.stop()
|
||||
|
||||
def test_runner_rejects_ambiguous_targets_and_changed_index_snapshot(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
index = ProjectIndex(Project.open(root))
|
||||
index.build()
|
||||
runner = VisualizationRunner(index)
|
||||
try:
|
||||
with self.assertRaisesRegex(DocForgeError, "either one exact"):
|
||||
runner.start(node_id="guide.workflow", query="workflow")
|
||||
result = runner.start()
|
||||
parsed = urllib.parse.urlparse(str(result["url"]))
|
||||
with index.path.open("ab") as handle:
|
||||
handle.write(b"\n")
|
||||
endpoint = f"{parsed.scheme}://{parsed.netloc}{parsed.path}api/overview"
|
||||
with self.assertRaises(urllib.error.HTTPError) as stale:
|
||||
urllib.request.urlopen(endpoint, timeout=2)
|
||||
self.assertEqual(409, stale.exception.code)
|
||||
try:
|
||||
self.assertEqual(
|
||||
"visualization_stale",
|
||||
json.loads(stale.exception.read())["error"]["code"],
|
||||
)
|
||||
finally:
|
||||
stale.exception.close()
|
||||
finally:
|
||||
runner.stop()
|
||||
|
||||
def test_two_visualizations_remain_project_bound(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent = Path(directory)
|
||||
alpha_root = self.copy_fixture("alpha", parent / "alpha")
|
||||
beta_root = self.copy_fixture("beta", parent / "beta")
|
||||
alpha_index = ProjectIndex(Project.open(alpha_root))
|
||||
beta_index = ProjectIndex(Project.open(beta_root))
|
||||
alpha_index.build()
|
||||
beta_index.build()
|
||||
alpha = VisualizationRunner(alpha_index)
|
||||
beta = VisualizationRunner(beta_index)
|
||||
try:
|
||||
alpha_url = urllib.parse.urlparse(str(alpha.start()["url"]))
|
||||
beta_url = urllib.parse.urlparse(str(beta.start()["url"]))
|
||||
self.assertNotEqual(alpha_url.netloc, beta_url.netloc)
|
||||
for parsed, expected in (
|
||||
(alpha_url, "alpha-docs"),
|
||||
(beta_url, "beta-notes"),
|
||||
):
|
||||
endpoint = f"{parsed.scheme}://{parsed.netloc}{parsed.path}api/overview"
|
||||
with urllib.request.urlopen(endpoint, timeout=2) as response:
|
||||
self.assertEqual(expected, json.load(response)["project_id"])
|
||||
finally:
|
||||
alpha.stop()
|
||||
beta.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue