Upgrade generic graph navigation
This commit is contained in:
parent
8ac4fe2a67
commit
5e77cd2adb
13 changed files with 657 additions and 65 deletions
|
|
@ -11,7 +11,14 @@ from mcp.client.stdio import stdio_client
|
|||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
|
||||
from docforge.index import ProjectIndex
|
||||
from docforge.mcp_server import ALL_TOOLS, CONTENT_WARNING, PROPOSAL_TOOLS, create_server
|
||||
from docforge.mcp_server import (
|
||||
ALL_TOOLS,
|
||||
CONTENT_WARNING,
|
||||
PROPOSAL_TOOLS,
|
||||
DocForgeService,
|
||||
_create_bound_server,
|
||||
create_server,
|
||||
)
|
||||
from docforge.project import Project
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
|
@ -62,10 +69,16 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
("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
|
||||
) as session:
|
||||
results = [await session.call_tool(name, arguments) for name, arguments in calls]
|
||||
service = DocForgeService(Project.open(root))
|
||||
try:
|
||||
async with create_connected_server_and_client_session(
|
||||
_create_bound_server(service, read_only=True), raise_exceptions=True
|
||||
) as session:
|
||||
results = [
|
||||
await session.call_tool(name, arguments) for name, arguments in calls
|
||||
]
|
||||
finally:
|
||||
service.visualization.stop()
|
||||
|
||||
for result in results:
|
||||
self.assertFalse(result.isError)
|
||||
|
|
@ -88,7 +101,8 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
visualization = results[11].structuredContent["visualization"]
|
||||
self.assertTrue(visualization["read_only"])
|
||||
self.assertTrue(visualization["project_bound"])
|
||||
self.assertEqual("graph-browser@4", visualization["template"])
|
||||
self.assertEqual("graph-browser@5", visualization["template"])
|
||||
self.assertEqual("browser_lease", visualization["lifetime"]["policy"])
|
||||
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
|
||||
context = results[8].structuredContent
|
||||
self.assertLessEqual(context["estimated_tokens"], 180)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import json
|
|||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
|
|
@ -76,6 +77,55 @@ class VisualizationTests(unittest.TestCase):
|
|||
self.assertEqual("", result.stderr)
|
||||
self.assertEqual(0, result.returncode)
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "Node.js is required for topology validation")
|
||||
def test_embedded_topology_roles_hops_and_shading_are_deterministic(self) -> None:
|
||||
script = _GRAPH_BROWSER_HTML.split("<script>", 1)[1].split("</script>", 1)[0]
|
||||
topology_logic = script.split("function analyzeTopology", 1)[1].split(
|
||||
"function renderNeighborhood", 1
|
||||
)[0]
|
||||
harness = (
|
||||
"function analyzeTopology"
|
||||
+ topology_logic
|
||||
+ """
|
||||
const data = {
|
||||
root: "primary",
|
||||
depth: 2,
|
||||
nodes: [
|
||||
{node_id: "primary"},
|
||||
{node_id: "child-one"},
|
||||
{node_id: "child-two"},
|
||||
{node_id: "incoming"},
|
||||
],
|
||||
edges: [
|
||||
{source_id: "primary", relation: "contains", target_id: "child-one"},
|
||||
{source_id: "child-one", relation: "contains", target_id: "child-two"},
|
||||
{source_id: "incoming", relation: "references", target_id: "primary"},
|
||||
],
|
||||
};
|
||||
const topology = analyzeTopology(data);
|
||||
const positions = layoutNodes(data.nodes, data.root, topology);
|
||||
const fail = (message) => { throw new Error(message); };
|
||||
if (topology.get("primary").role !== "primary") fail("root role");
|
||||
if (topology.get("child-one").role !== "child") fail("direct child role");
|
||||
if (topology.get("child-two").role !== "child") fail("descendant role");
|
||||
if (topology.get("incoming").role !== "edge") fail("incoming edge role");
|
||||
if (topology.get("child-two").hop !== 2) fail("descendant hop");
|
||||
if (Math.hypot(positions.get("child-two").x, positions.get("child-two").y)
|
||||
<= Math.hypot(positions.get("child-one").x, positions.get("child-one").y)) {
|
||||
fail("hop rings");
|
||||
}
|
||||
if (nodePalette("child", 2).fill === nodePalette("child", 1).fill) fail("hop shading");
|
||||
"""
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual("", result.stderr)
|
||||
self.assertEqual(0, result.returncode)
|
||||
|
||||
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))
|
||||
|
|
@ -91,6 +141,8 @@ class VisualizationTests(unittest.TestCase):
|
|||
|
||||
self.assertEqual(VISUALIZATION_TEMPLATE, first["template"])
|
||||
self.assertTrue(first["read_only"])
|
||||
self.assertEqual("browser_lease", first["lifetime"]["policy"])
|
||||
self.assertFalse(runner._thread.daemon)
|
||||
self.assertEqual(first_url.netloc, second_url.netloc)
|
||||
self.assertEqual(first_url.path, second_url.path)
|
||||
|
||||
|
|
@ -103,12 +155,24 @@ class VisualizationTests(unittest.TestCase):
|
|||
self.assertIn('id="reset-view"', html)
|
||||
self.assertIn('id="node-dialog"', html)
|
||||
self.assertIn('id="explore-node"', html)
|
||||
self.assertIn('id="left-resizer"', html)
|
||||
self.assertIn('id="right-resizer"', html)
|
||||
self.assertIn('id="neighborhood-sections"', html)
|
||||
self.assertIn(".empty[hidden] { display: none; }", html)
|
||||
self.assertIn("resize: both", html)
|
||||
self.assertNotIn("backdrop-filter", html)
|
||||
self.assertIn('addEventListener("wheel"', html)
|
||||
self.assertIn('addEventListener("pointermove"', html)
|
||||
self.assertIn("inspectNode(node.node_id)", html)
|
||||
self.assertIn("dialog.showModal()", html)
|
||||
self.assertIn("await loadNode(nodeId)", html)
|
||||
self.assertIn("beginDialogDrag", html)
|
||||
self.assertIn('setupPanelResizer("left")', html)
|
||||
self.assertIn('setupPanelResizer("right")', html)
|
||||
self.assertIn("Primary focus", html)
|
||||
self.assertIn("Edge & context", html)
|
||||
self.assertIn("distanceShade", html)
|
||||
self.assertIn("renewViewerLease", html)
|
||||
pointerdown = html.split('$("graph").addEventListener("pointerdown"', 1)[1].split(
|
||||
'$("graph").addEventListener("pointermove"', 1
|
||||
)[0]
|
||||
|
|
@ -128,6 +192,11 @@ class VisualizationTests(unittest.TestCase):
|
|||
self.assertEqual(3, overview["node_count"])
|
||||
self.assertEqual(20, overview["max_results"])
|
||||
|
||||
with urllib.request.urlopen(f"{base}api/heartbeat", timeout=2) as response:
|
||||
heartbeat = json.load(response)
|
||||
self.assertEqual("alive", heartbeat["viewer"])
|
||||
self.assertEqual(runner.lease_seconds, heartbeat["lease_seconds"])
|
||||
|
||||
search_query = urllib.parse.urlencode(
|
||||
{"q": "canonical nodes", "family": "", "limit": overview["max_results"]}
|
||||
)
|
||||
|
|
@ -170,6 +239,37 @@ class VisualizationTests(unittest.TestCase):
|
|||
finally:
|
||||
runner.stop()
|
||||
|
||||
def test_browser_lease_keeps_listener_alive_then_closes_it(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
index = ProjectIndex(Project.open(root))
|
||||
index.build()
|
||||
runner = VisualizationRunner(
|
||||
index,
|
||||
initial_grace_seconds=0.2,
|
||||
lease_seconds=0.2,
|
||||
monitor_interval_seconds=0.02,
|
||||
)
|
||||
try:
|
||||
result = runner.start()
|
||||
parsed = urllib.parse.urlparse(str(result["url"]))
|
||||
base = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
||||
heartbeat = f"{base}api/heartbeat"
|
||||
with urllib.request.urlopen(heartbeat, timeout=2) as response:
|
||||
self.assertEqual("alive", json.load(response)["viewer"])
|
||||
time.sleep(0.12)
|
||||
with urllib.request.urlopen(heartbeat, timeout=2) as response:
|
||||
self.assertEqual("alive", json.load(response)["viewer"])
|
||||
|
||||
deadline = time.monotonic() + 2
|
||||
while runner._server is not None and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertIsNone(runner._server)
|
||||
with self.assertRaises(OSError):
|
||||
urllib.request.urlopen(base, timeout=0.2)
|
||||
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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue