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

1053 lines
48 KiB
Python

from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.parse
import urllib.request
from contextlib import contextmanager
from pathlib import Path
from unittest import mock
from docforge.errors import DocForgeError
from docforge.index import ProjectIndex
from docforge.mcp_server import DocForgeService
from docforge.models import ProjectState
from docforge.project import Project
from docforge.viewer_manager import ViewerManager, ViewerManagerClient, _ManagedWorker
from docforge.visualization import (
_GRAPH_BROWSER_CSS,
_GRAPH_BROWSER_HTML,
_GRAPH_BROWSER_JAVASCRIPT,
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
@contextmanager
def running_manager(
self,
state_path: Path,
*,
idle_timeout_seconds: float = 60,
check_interval_seconds: float = 0.02,
):
manager = ViewerManager(
state_path,
idle_timeout_seconds=idle_timeout_seconds,
check_interval_seconds=check_interval_seconds,
)
thread = threading.Thread(target=manager.serve_forever, daemon=True)
thread.start()
deadline = time.monotonic() + 2
while not state_path.exists() and time.monotonic() < deadline:
time.sleep(0.01)
self.assertTrue(state_path.exists())
try:
yield manager
finally:
manager.shutdown()
thread.join(timeout=2)
def test_snapshot_spec_binds_the_exact_validated_index_publication(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())
spec = snapshot.spec()
self.assertEqual(1, spec["schema_version"])
self.assertEqual(1, spec["index_signature"]["schema_version"])
self.assertEqual("current", VisualizationIndexSnapshot.from_spec(spec).index_state())
malformed = {**spec, "index_signature": {"schema_version": 1}}
with self.assertRaises(DocForgeError) as invalid:
VisualizationIndexSnapshot.from_spec(malformed)
self.assertEqual("invalid_index", invalid.exception.code)
wrong_fingerprint = {
**spec,
"identity": {
**spec["identity"],
"project_root_fingerprint": "0" * 16,
},
}
with self.assertRaises(DocForgeError) as invalid_fingerprint:
VisualizationIndexSnapshot.from_spec(wrong_fingerprint)
self.assertEqual("invalid_index", invalid_fingerprint.exception.code)
string_count = {
**spec,
"identity": {
**spec["identity"],
"node_count": str(spec["identity"]["node_count"]),
},
}
with self.assertRaises(DocForgeError) as invalid_count:
VisualizationIndexSnapshot.from_spec(string_count)
self.assertEqual("invalid_index", invalid_count.exception.code)
with index.path.open("ab") as stream:
stream.write(b"\n")
self.assertEqual("stale", snapshot.index_state())
with self.assertRaises(DocForgeError) as stale:
VisualizationIndexSnapshot.from_spec(spec)
self.assertEqual("visualization_stale", stale.exception.code)
def test_snapshot_index_state_rejects_missing_and_symlinked_publications(self) -> None:
for mutation in ("delete", "replace", "symlink"):
with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
index.build()
snapshot = VisualizationIndexSnapshot(index, index.check())
if mutation == "delete":
index.path.unlink()
elif mutation == "replace":
replacement = index.path.with_suffix(".replacement")
shutil.copy2(index.path, replacement)
replacement.replace(index.path)
else:
backup = index.path.with_suffix(".backup")
index.path.rename(backup)
index.path.symlink_to(backup)
self.assertEqual("stale", snapshot.index_state())
def test_health_is_stat_only_and_reports_stale_without_renewing_activity(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:
result = runner.start()
url = str(result["url"]).split("?", 1)[0] + "api/health"
with (
mock.patch(
"docforge.visualization.sqlite3.connect",
side_effect=AssertionError("health opened SQLite"),
),
urllib.request.urlopen(url, timeout=2) as response,
):
current = json.load(response)
self.assertEqual("current", current["index_state"])
with index.path.open("ab") as stream:
stream.write(b"\n")
with (
mock.patch(
"docforge.visualization.sqlite3.connect",
side_effect=AssertionError("health opened SQLite"),
),
urllib.request.urlopen(url, timeout=2) as response,
):
stale = json.load(response)
self.assertEqual("stale", stale["index_state"])
self.assertEqual(current["last_activity_at"], stale["last_activity_at"])
finally:
runner.stop()
def test_manager_health_rejects_malformed_and_identity_mismatched_payloads(self) -> None:
snapshot = {
"project_id": "alpha-docs",
"project_root_fingerprint": "0" * 16,
"revision": "revision",
"source_hash": "a" * 64,
"adapter": "generic",
"node_count": 3,
"edge_count": 2,
}
worker = _ManagedWorker(
process=mock.Mock(),
port=12345,
token="token",
snapshot=snapshot,
last_activity_at=1.0,
)
valid = {
"status": "ok",
"viewer": "alive",
"last_activity_at": 1.0,
"index_state": "current",
**snapshot,
}
invalid_payloads = (
{key: value for key, value in valid.items() if key != "status"},
{**valid, "project_id": "other"},
{**valid, "last_activity_at": True},
{**valid, "last_activity_at": float("nan")},
{key: value for key, value in valid.items() if key != "index_state"},
)
for payload in invalid_payloads:
with self.subTest(payload=payload):
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = json.dumps(payload).encode()
with mock.patch(
"docforge.viewer_manager.urllib.request.urlopen",
return_value=response,
):
self.assertIsNone(ViewerManager._health(worker))
def test_manager_status_separates_lifecycle_index_and_source_freshness(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
index = ProjectIndex(project)
index.build()
state_path = Path(directory) / "viewer-manager.json"
with self.running_manager(state_path) as manager:
client = ViewerManagerClient(index, state_path=state_path)
first = client.start()
with (
mock.patch.object(
project,
"load",
side_effect=AssertionError("status loaded the project"),
),
mock.patch.object(
index,
"check",
side_effect=AssertionError("status checked the index"),
),
mock.patch.object(
index,
"build",
side_effect=AssertionError("status built the index"),
),
mock.patch.object(
index,
"synchronize",
side_effect=AssertionError("status synchronized the index"),
),
):
current = client.status()
self.assertEqual("running", current["state"])
self.assertEqual("current", current["snapshot_state"])
self.assertEqual(
{"index": "current", "source": "current"},
current["freshness"],
)
self.assertEqual(first["snapshot"]["source_hash"], current["source_hash"])
service = DocForgeService(project, diagnostics=True)
service.visualization = ViewerManagerClient(
service.index,
state_path=state_path,
)
mcp_current = service.visualization_status()
counters = mcp_current["diagnostics"]["counters"]
self.assertEqual(0, counters["project_loads"])
self.assertEqual(0, counters["source_files_parsed"])
self.assertEqual(0, counters["index_checks"])
self.assertEqual(0, counters["index_synchronizations"])
self.assertEqual(0, counters["index_builds"])
self.assertEqual(1, counters["viewer_manager_requests"])
project.generation_path.unlink()
unknown = client.status()
self.assertEqual("running", unknown["state"])
self.assertEqual("unknown", unknown["snapshot_state"])
self.assertEqual("unknown", unknown["freshness"]["source"])
index.build()
stale = client.status()
self.assertEqual("running", stale["state"])
self.assertEqual("stale", stale["snapshot_state"])
self.assertEqual("stale", stale["freshness"]["index"])
time.sleep(0.06)
self.assertEqual(1, len(manager._workers))
restarted = client.start()
self.assertFalse(restarted["reused"])
self.assertNotEqual(
str(first["url"]).split("?", 1)[0],
str(restarted["url"]).split("?", 1)[0],
)
self.assertEqual("current", client.status()["snapshot_state"])
def test_client_source_freshness_distinguishes_mismatch_and_unknown(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
index = ProjectIndex(project)
checked = index.build()
client = ViewerManagerClient(index)
response = {
"status": "ok",
"state": "running",
"index_state": "current",
"snapshot": {
"revision": checked["revision"],
"source_hash": checked["source_hash"],
},
"project_id": project.descriptor.project_id,
"project_root_fingerprint": "test",
"adapter": project.descriptor.adapter,
}
with mock.patch.object(client, "_lifecycle_request", return_value=response):
with mock.patch.object(
project,
"incremental_state",
return_value=ProjectState(source_hash="f" * 64, revision="changed"),
):
stale = client.status()
self.assertEqual("stale", stale["freshness"]["source"])
self.assertEqual("stale", stale["snapshot_state"])
with mock.patch.object(project, "incremental_state", return_value=None):
unknown = client.status()
self.assertEqual("unknown", unknown["freshness"]["source"])
self.assertEqual("unknown", unknown["snapshot_state"])
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)
filtered = snapshot.filter_nodes(category="tag", value="canonical", limit=2)
searched = snapshot.search(
query="",
family="guide",
kind="canonical",
language=None,
capability="source",
limit=2,
)
source = snapshot.source("guide.workflow")
flow = snapshot.lineage("guide.workflow", limit=20)
web = snapshot.web("guide.workflow", depth=2, limit=20)
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.assertEqual(1, filtered["count"])
self.assertEqual(1, filtered["total"])
self.assertFalse(filtered["truncated"])
self.assertEqual("guide.foundation", filtered["results"][0]["node_id"])
self.assertEqual(1, searched["count"])
self.assertEqual("guide.foundation", searched["results"][0]["node_id"])
self.assertIn({"value": "canonical", "count": 1}, overview["tags"])
self.assertIn({"value": "source", "count": 3}, overview["capabilities"])
self.assertIn({"value": "logic", "count": 0}, overview["capabilities"])
self.assertLessEqual(len(first["edges"]), 2)
self.assertEqual("docs/content/workflow.md", source["source_path"])
self.assertIn("Editors change canonical nodes", source["content"])
self.assertIn(
"guide.workflow",
{node["node_id"] for node in first["nodes"]},
)
self.assertIn(
{
"source_id": "guide.foundation",
"relation": "depends_on",
"target_id": "guide.workflow",
"stored_source_id": "guide.workflow",
"stored_target_id": "guide.foundation",
"reversed": True,
},
flow["edges"],
)
self.assertEqual(flow["edges"], web["edges"])
self.assertEqual(
{"guide.foundation": 1, "guide.workflow": 0, "proof.validation": 1},
web["hops"],
)
with self.assertRaisesRegex(DocForgeError, "safety boundary"):
snapshot.node("guide.workflow", depth=1, limit=401)
with self.assertRaisesRegex(DocForgeError, "category is unsupported"):
snapshot.filter_nodes(category="relation", value="depends_on", limit=2)
def test_snapshot_source_never_mixes_pinned_graph_with_newer_canonical_text(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())
before = snapshot.source("guide.workflow")
source = root / "docs/content/workflow.md"
source.write_text(
source.read_text(encoding="utf-8") + "\nNewer unindexed source text.\n",
encoding="utf-8",
)
after = snapshot.source("guide.workflow")
self.assertEqual(before["content"], after["content"])
self.assertNotIn("Newer unindexed source text", after["content"])
self.assertEqual("index_snapshot", after["source_provenance"])
def test_flow_reverses_imports_into_a_complete_structural_path(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
descriptor = root / ".docforge" / "project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8").replace(
'"depends_on", "proves",',
'"depends_on", "proves", "imports", "contains",',
),
encoding="utf-8",
)
workflow = root / "docs" / "content" / "workflow.md"
workflow.write_text(
workflow.read_text(encoding="utf-8").replace(
'depends_on = ["guide.foundation"]',
'depends_on = ["guide.foundation"]\n'
'imports = ["guide.foundation"]\n'
'contains = ["tests.example.example-tests.test-default"]',
),
encoding="utf-8",
)
(root / "docs" / "content" / "test-method.md").write_text(
"""+++
schema_version = 1
id = "tests.example.example-tests.test-default"
title = "Default behavior test"
family = "proof"
authority = "derived"
status = "approved"
tags = ["test"]
summary = "Exercises the default behavior."
+++
The test verifies the default behavior.
""",
encoding="utf-8",
)
index = ProjectIndex(Project.open(root))
index.build()
snapshot = VisualizationIndexSnapshot(index, index.check())
flow = snapshot.lineage(
"tests.example.example-tests.test-default",
limit=20,
)
edge_keys = {
(
edge["source_id"],
edge["relation"],
edge["target_id"],
edge["reversed"],
)
for edge in flow["edges"]
}
self.assertIn(
(
"guide.foundation",
"imports",
"guide.workflow",
True,
),
edge_keys,
)
self.assertIn(
(
"guide.workflow",
"contains",
"tests.example.example-tests.test-default",
False,
),
edge_keys,
)
self.assertEqual(
0,
flow["hops"]["tests.example.example-tests.test-default"],
)
self.assertEqual(1, flow["hops"]["guide.workflow"])
self.assertEqual(2, flow["hops"]["guide.foundation"])
web = snapshot.web("guide.workflow", depth=2, limit=20)
web_edges = {
(
edge["source_id"],
edge["relation"],
edge["target_id"],
edge["reversed"],
)
for edge in web["edges"]
}
self.assertIn(
(
"tests.example.example-tests.test-default",
"contains",
"guide.workflow",
True,
),
web_edges,
)
@unittest.skipUnless(shutil.which("node"), "Node.js is required for browser script validation")
def test_browser_javascript_is_valid(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "graph-browser.js"
path.write_text(_GRAPH_BROWSER_JAVASCRIPT, encoding="utf-8")
result = subprocess.run(
["node", "--check", str(path)],
check=False,
capture_output=True,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
def test_browser_contains_hiding_source_navigation_and_scrollable_inspector(self) -> None:
self.assertIn('id="restore-hidden"', _GRAPH_BROWSER_HTML)
self.assertIn('id="view-web"', _GRAPH_BROWSER_HTML)
self.assertIn('id="view-logic"', _GRAPH_BROWSER_HTML)
self.assertIn('id="kind"', _GRAPH_BROWSER_HTML)
self.assertIn('id="language"', _GRAPH_BROWSER_HTML)
self.assertIn('id="capability"', _GRAPH_BROWSER_HTML)
self.assertIn('data-preset="logic"', _GRAPH_BROWSER_HTML)
self.assertIn('id="open-node-source"', _GRAPH_BROWSER_HTML)
self.assertIn('id="hide-node"', _GRAPH_BROWSER_HTML)
self.assertIn('id="source-dialog"', _GRAPH_BROWSER_HTML)
self.assertIn("state.hiddenNodes.add(nodeId)", _GRAPH_BROWSER_JAVASCRIPT)
self.assertIn("applyTraceHighlight", _GRAPH_BROWSER_JAVASCRIPT)
self.assertIn("layoutLogic", _GRAPH_BROWSER_JAVASCRIPT)
self.assertIn("trace-connected", _GRAPH_BROWSER_CSS)
self.assertIn(
"grid-template-rows: auto minmax(0, 1fr) auto",
_GRAPH_BROWSER_CSS,
)
@unittest.skipUnless(shutil.which("node"), "Node.js is required for topology validation")
def test_topology_roles_hops_and_shading_are_deterministic(self) -> None:
script = _GRAPH_BROWSER_JAVASCRIPT
viewport_logic = script.split("function viewportForPositions", 1)[1].split(
"function selectNode", 1
)[0]
relation_constants = script.split("const relationStyles", 1)[1].split("const $", 1)[0]
node_label_logic = script.split("const escapeText", 1)[1].split("function relationHash", 1)[
0
]
relation_logic = script.split("function relationHash", 1)[1].split(
"function applyViewport", 1
)[0]
topology_logic = script.split("function analyzeTopology", 1)[1].split(
"function renderNeighborhood", 1
)[0]
harness = (
"const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});\n"
"const relationStyles"
+ relation_constants
+ "const escapeText"
+ node_label_logic
+ "function relationHash"
+ relation_logic
+ "function viewportForPositions"
+ viewport_logic
+ "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("composition", 2).fill === nodePalette("composition", 1).fill) {
fail("hop shading");
}
const qualified = {
node_id: "py.symbol.tests.test_settings.settingstests.test_default_settings_load",
title: "tests.test_settings.SettingsTests.test_default_settings_load",
family: "code.test",
tags: ["method", "python", "test"],
};
if (nodeDisplayName(qualified) !== "test_default_settings_load") fail("leaf display name");
if (nodeKindLabel(qualified) !== "Test method") fail("node kind label");
if (categoryForRelation("contains") !== "composition") fail("composition category");
if (categoryForRelation("inherits") !== "behavior") fail("behavior category");
if (categoryForRelation("imports") !== "dependency") fail("dependency category");
const singleViewport = viewportForPositions(new Map([["primary", {x: 0, y: 0}]]));
if (singleViewport.width !== 440) fail("single-node fit");
if (singleViewport.x !== -220 || singleViewport.y !== -singleViewport.height / 2) {
fail("single-node centered");
}
const centered = viewportCenteredOn(
positions.get("child-two"),
{x: 20, y: 30, width: 500, height: 300},
);
if (centered.width !== 500 || centered.height !== 300) fail("center preserves zoom");
if (centered.x !== positions.get("child-two").x - 250) fail("center x");
if (centered.y !== positions.get("child-two").y - 150) fail("center y");
if (relationStyle("calls").family !== "Execution") fail("calls family");
if (relationStyle("unknown_relation").family !== "Other") fail("fallback relation family");
const flowData = {
root: "primary",
nodes: [
{node_id: "primary"},
{node_id: "package"},
{node_id: "module"},
{node_id: "test-class"},
{node_id: "test-file"},
{node_id: "evidence"},
],
edges: [
{source_id: "test-class", relation: "contains", target_id: "primary"},
{source_id: "test-file", relation: "contains", target_id: "test-class"},
{source_id: "module", relation: "contains", target_id: "test-file"},
{source_id: "package", relation: "contains", target_id: "module"},
{source_id: "evidence", relation: "verifies", target_id: "primary"},
],
};
const flow = buildFlowGraph(flowData);
const flowIds = new Set(flow.nodes.map((node) => node.node_id));
if (!flowIds.has("package") || !flowIds.has("module") || !flowIds.has("test-class")
|| !flowIds.has("test-file") || !flowIds.has("evidence")) fail("full lineage membership");
const flowPositions = layoutFlow(flow.nodes, flow.root, flow.topology);
if (flowPositions.get("primary").x !== 0) fail("flow destination position");
if (flowPositions.get("package").x >= flowPositions.get("primary").x) {
fail("flow upstream direction");
}
const containmentEdge = flow.edges.find((edge) => edge.relation === "contains"
&& edge.source_id === "test-class");
if (!containmentEdge || containmentEdge.target_id !== "primary") {
fail("containment ancestry direction");
}
const webData = {
root: "focus",
hops: {focus: 0, downstream: 1, hidden: 2, ancestor: 3, alternate: 1},
nodes: [
{node_id: "focus"},
{node_id: "downstream"},
{node_id: "hidden"},
{node_id: "ancestor"},
{node_id: "alternate"},
],
edges: [
{source_id: "downstream", relation: "contains", target_id: "focus"},
{source_id: "hidden", relation: "contains", target_id: "downstream"},
{source_id: "ancestor", relation: "contains", target_id: "hidden"},
{source_id: "alternate", relation: "verifies", target_id: "focus"},
],
};
const web = buildWebGraph(webData);
const pruned = pruneConvergenceGraph(web, new Set(["hidden"]));
const prunedIds = new Set(pruned.nodes.map((node) => node.node_id));
if (prunedIds.has("hidden") || prunedIds.has("ancestor")) fail("hidden upstream pruning");
if (!prunedIds.has("downstream") || !prunedIds.has("alternate") || !prunedIds.has("focus")) {
fail("downstream convergence preservation");
}
if (pruned.prunedCount !== 2) fail("pruned node count");
"""
)
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))
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("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)
with urllib.request.urlopen(base, timeout=2) as response:
html = response.read().decode("utf-8")
headers = response.headers
with urllib.request.urlopen(f"{base}assets/graph.css", timeout=2) as response:
css = response.read().decode("utf-8")
self.assertEqual("text/css; charset=utf-8", response.headers["Content-Type"])
with urllib.request.urlopen(f"{base}assets/graph.js", timeout=2) as response:
javascript = response.read().decode("utf-8")
self.assertEqual(
"text/javascript; charset=utf-8",
response.headers["Content-Type"],
)
self.assertIn("DocForge graph", html)
self.assertIn('href="assets/graph.css"', html)
self.assertIn('src="assets/graph.js"', html)
self.assertIn('id="zoom-in"', html)
self.assertIn('id="zoom-out"', html)
self.assertIn('id="reset-view"', html)
self.assertIn('id="node-dialog"', html)
self.assertIn('id="node-card"', html)
self.assertIn('id="explore-node"', html)
self.assertIn('id="explore-card-node"', html)
self.assertIn("positionNodeCard(dialog, event)", javascript)
self.assertIn(
'renderDetails($("node-card-details"), data.node, data, true, false)',
javascript,
)
self.assertIn("dialog.show();", javascript)
self.assertIn("position: fixed; inset: auto; margin: 0", css)
self.assertIn("background: rgba(7, 18, 29, .82)", css)
self.assertIn("width: fit-content; height: fit-content", css)
self.assertIn("max-width: min(760px, calc(100vw - 32px))", css)
self.assertIn('id="left-resizer"', html)
self.assertIn('id="right-resizer"', html)
self.assertIn('id="view-nodes"', html)
self.assertIn('id="view-flow"', html)
self.assertIn('id="view-web"', html)
self.assertIn('id="view-logic"', html)
self.assertIn('id="neighborhood-sections"', html)
self.assertIn('id="relationship-key"', html)
self.assertIn('id="relationship-key-list"', html)
self.assertNotIn('id="details"', html)
self.assertIn(".empty[hidden] { display: none; }", css)
self.assertIn("html, body { height: 100%; overflow: hidden; }", css)
self.assertIn("Space centers selection", html)
self.assertIn("resize: both", css)
self.assertNotIn("backdrop-filter", css)
self.assertIn('addEventListener("wheel"', javascript)
self.assertIn('addEventListener("pointermove"', javascript)
self.assertIn("inspectNode(node.node_id)", javascript)
self.assertIn("showNodeCard(node.node_id)", javascript)
self.assertIn('addEventListener("contextmenu"', javascript)
self.assertIn("dialog.showModal()", javascript)
self.assertIn("await loadNode(nodeId)", javascript)
self.assertIn("beginDialogDrag", javascript)
self.assertIn('setupPanelResizer("left")', javascript)
self.assertIn('setupPanelResizer("right")', javascript)
self.assertIn("Structure & containment", javascript)
self.assertIn("Inherited & implemented behavior", javascript)
self.assertIn("Required dependencies", javascript)
self.assertIn('"logic-comment"', javascript)
self.assertNotIn(">Children<", html)
self.assertIn("distanceShade", javascript)
self.assertIn("nodeDisplayName", javascript)
self.assertIn("nodeKindLabel", javascript)
self.assertIn("nodeContributionCategory", javascript)
self.assertIn("graphNodeContent", javascript)
self.assertIn('"foreignObject"', javascript)
self.assertNotIn("short(node.title, 26)", javascript)
self.assertIn('id="node-legend"', html)
self.assertIn(".graph-node-content", css)
self.assertIn("overflow-wrap: anywhere", css)
self.assertIn("viewportForPositions", javascript)
self.assertIn("centerSelectedNode", javascript)
self.assertIn("relationStyles", javascript)
self.assertIn("appendRelationMarker", javascript)
self.assertIn("renderRelationshipKey", javascript)
self.assertIn("buildFlowGraph", javascript)
self.assertIn("buildWebGraph", javascript)
self.assertIn("pruneConvergenceGraph", javascript)
self.assertIn("layoutFlow", javascript)
self.assertIn('const endpoint = showingFlow ? "lineage"', javascript)
self.assertIn("filterByDescriptor", javascript)
self.assertIn("api(`filter?${params}`)", javascript)
self.assertIn('setViewMode("flow")', javascript)
self.assertIn('event.code !== "Space"', javascript)
self.assertIn('class: "selection-ring"', javascript)
self.assertIn("renewViewerLease", javascript)
pointerdown = javascript.split(
'$("graph").addEventListener("pointerdown"',
1,
)[1].split('$("graph").addEventListener("pointermove"', 1)[0]
pointermove = javascript.split(
'$("graph").addEventListener("pointermove"',
1,
)[1].split("function endPan", 1)[0]
self.assertNotIn("setPointerCapture", pointerdown)
self.assertIn("setPointerCapture", pointermove)
self.assertIn("right-click full inspector", html)
self.assertIn("default-src 'none'", headers["Content-Security-Policy"])
self.assertIn("script-src 'self'", headers["Content-Security-Policy"])
self.assertIn("style-src 'self'", headers["Content-Security-Policy"])
self.assertNotIn("unsafe-inline", headers["Content-Security-Policy"])
self.assertEqual("no-store", headers["Cache-Control"])
self.assertEqual("DENY", headers["X-Frame-Options"])
with self.assertRaises(urllib.error.HTTPError) as missing_asset:
urllib.request.urlopen(f"{base}assets/missing.js", timeout=2)
self.assertEqual(404, missing_asset.exception.code)
missing_asset.exception.close()
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"])
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"]}
)
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)
filter_query = urllib.parse.urlencode(
{"category": "tag", "value": "canonical", "limit": overview["max_results"]}
)
with urllib.request.urlopen(
f"{base}api/filter?{filter_query}", timeout=2
) as response:
filtered = json.load(response)
self.assertEqual("alpha-docs", filtered["project_id"])
self.assertEqual("tag", filtered["category"])
self.assertEqual("canonical", filtered["value"])
self.assertEqual(1, filtered["total"])
self.assertEqual("guide.foundation", filtered["results"][0]["node_id"])
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"])
lineage_query = urllib.parse.urlencode({"id": "guide.workflow", "limit": 1000})
with urllib.request.urlopen(
f"{base}api/lineage?{lineage_query}", timeout=2
) as response:
lineage = json.load(response)
self.assertTrue(lineage["lineage"])
self.assertEqual("guide.workflow", lineage["root"])
self.assertTrue(
any(
edge["source_id"] == "proof.validation"
and edge["relation"] == "proves"
and edge["target_id"] == "guide.workflow"
and edge["reversed"] is False
for edge in lineage["edges"]
)
)
web_query = urllib.parse.urlencode(
{"id": "guide.workflow", "depth": 2, "limit": 1000}
)
with urllib.request.urlopen(f"{base}api/web?{web_query}", timeout=2) as response:
web = json.load(response)
self.assertTrue(web["web"])
self.assertEqual("guide.workflow", web["root"])
self.assertEqual(0, web["hops"]["guide.workflow"])
logic_query = urllib.parse.urlencode({"id": "guide.workflow"})
with urllib.request.urlopen(
f"{base}api/logic?{logic_query}", timeout=2
) as response:
logic = json.load(response)
self.assertTrue(logic["logic"])
self.assertFalse(logic["available"])
self.assertEqual("guide.workflow", logic["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_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_manager_reuses_workers_and_applies_explicit_or_idle_shutdown(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
index.build()
state_path = Path(directory) / "viewer-manager.json"
with self.running_manager(
state_path, idle_timeout_seconds=0.25, check_interval_seconds=0.02
):
client = ViewerManagerClient(index, state_path=state_path)
first = client.start()
url = str(first["url"])
self.assertEqual("managed_idle", first["lifetime"]["policy"])
self.assertTrue(url.startswith("http://127.0.0.1:"))
with urllib.request.urlopen(url, timeout=2) as response:
self.assertEqual(200, response.status)
reused = client.start(depth=2)
self.assertTrue(reused["reused"])
self.assertEqual(url.split("?", 1)[0], str(reused["url"]).split("?", 1)[0])
self.assertEqual("running", client.status()["state"])
time.sleep(0.1)
heartbeat = url.split("?", 1)[0] + "api/heartbeat"
with urllib.request.urlopen(heartbeat, timeout=2) as response:
self.assertEqual("alive", json.load(response)["viewer"])
time.sleep(0.1)
self.assertEqual("running", client.status()["state"])
time.sleep(0.35)
deadline = time.monotonic() + 2
while client.status()["state"] == "running" and time.monotonic() < deadline:
time.sleep(0.02)
self.assertEqual("not_running", client.status()["state"])
restarted = client.start()
stopped = client.stop()
self.assertEqual("stopped", stopped["state"])
with self.assertRaises(OSError):
urllib.request.urlopen(str(restarted["url"]), timeout=0.2)
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()