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

761 lines
34 KiB
Python
Raw Normal View History

2026-07-24 16:01:03 -04:00
from __future__ import annotations
import json
import shutil
2026-07-24 21:01:53 -04:00
import subprocess
2026-07-24 16:01:03 -04:00
import tempfile
import threading
2026-07-24 21:43:11 -04:00
import time
2026-07-24 16:01:03 -04:00
import unittest
import urllib.error
import urllib.parse
import urllib.request
from contextlib import contextmanager
2026-07-24 16:01:03 -04:00
from pathlib import Path
from docforge.errors import DocForgeError
from docforge.index import ProjectIndex
from docforge.project import Project
from docforge.viewer_manager import ViewerManager, ViewerManagerClient
2026-07-24 16:01:03 -04:00
from docforge.visualization import (
_GRAPH_BROWSER_CSS,
2026-07-24 21:01:53 -04:00
_GRAPH_BROWSER_HTML,
_GRAPH_BROWSER_JAVASCRIPT,
2026-07-24 16:01:03 -04:00
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)
2026-07-24 16:01:03 -04:00
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)
2026-07-24 23:15:57 -04:00
filtered = snapshot.filter_nodes(category="tag", value="canonical", limit=2)
source = snapshot.source("guide.workflow")
flow = snapshot.lineage("guide.workflow", limit=20)
web = snapshot.web("guide.workflow", depth=2, limit=20)
2026-07-24 16:01:03 -04:00
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"])
2026-07-24 23:15:57 -04:00
self.assertEqual(1, filtered["count"])
self.assertEqual(1, filtered["total"])
self.assertFalse(filtered["truncated"])
self.assertEqual("guide.foundation", filtered["results"][0]["node_id"])
2026-07-24 16:01:03 -04:00
self.assertLessEqual(len(first["edges"]), 2)
self.assertEqual("docs/content/workflow.md", source["source_path"])
self.assertIn("Editors change canonical nodes", source["content"])
2026-07-24 16:01:03 -04:00
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"],
)
2026-07-24 16:01:03 -04:00
with self.assertRaisesRegex(DocForgeError, "safety boundary"):
snapshot.node("guide.workflow", depth=1, limit=401)
2026-07-24 23:15:57 -04:00
with self.assertRaisesRegex(DocForgeError, "category is unsupported"):
snapshot.filter_nodes(category="relation", value="depends_on", limit=2)
2026-07-24 16:01:03 -04:00
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:
2026-07-24 21:01:53 -04:00
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "graph-browser.js"
path.write_text(_GRAPH_BROWSER_JAVASCRIPT, encoding="utf-8")
2026-07-24 21:01:53 -04:00
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="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(
"grid-template-rows: auto minmax(0, 1fr) auto",
_GRAPH_BROWSER_CSS,
)
2026-07-24 21:43:11 -04:00
@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
2026-07-24 22:54:19 -04:00
viewport_logic = script.split("function viewportForPositions", 1)[1].split(
"function selectNode", 1
)[0]
2026-07-24 23:40:47 -04:00
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
]
2026-07-24 23:40:47 -04:00
relation_logic = script.split("function relationHash", 1)[1].split(
"function applyViewport", 1
)[0]
2026-07-24 21:43:11 -04:00
topology_logic = script.split("function analyzeTopology", 1)[1].split(
"function renderNeighborhood", 1
)[0]
harness = (
2026-07-24 22:54:19 -04:00
"const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});\n"
2026-07-24 23:40:47 -04:00
"const relationStyles"
+ relation_constants
+ "const escapeText"
+ node_label_logic
2026-07-24 23:40:47 -04:00
+ "function relationHash"
+ relation_logic
+ "function viewportForPositions"
2026-07-24 22:54:19 -04:00
+ viewport_logic
+ "function analyzeTopology"
2026-07-24 21:43:11 -04:00
+ 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");
2026-07-24 22:54:19 -04:00
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");
2026-07-24 23:40:47 -04:00
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"},
2026-07-24 23:40:47 -04:00
],
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"},
2026-07-24 23:40:47 -04:00
],
};
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");
2026-07-24 23:40:47 -04:00
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) {
2026-07-24 23:40:47 -04:00
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");
2026-07-24 21:43:11 -04:00
"""
)
result = subprocess.run(
["node", "-e", harness],
check=False,
capture_output=True,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
2026-07-24 16:01:03 -04:00
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"])
2026-07-24 21:43:11 -04:00
self.assertEqual("browser_lease", first["lifetime"]["policy"])
self.assertFalse(runner._thread.daemon)
2026-07-24 16:01:03 -04:00
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"],
)
2026-07-24 16:01:03 -04:00
self.assertIn("DocForge graph", html)
self.assertIn('href="assets/graph.css"', html)
self.assertIn('src="assets/graph.js"', html)
2026-07-24 16:09:56 -04:00
self.assertIn('id="zoom-in"', html)
self.assertIn('id="zoom-out"', html)
self.assertIn('id="reset-view"', html)
2026-07-24 21:01:53 -04:00
self.assertIn('id="node-dialog"', html)
2026-07-24 23:15:57 -04:00
self.assertIn('id="node-card"', html)
2026-07-24 21:01:53 -04:00
self.assertIn('id="explore-node"', html)
2026-07-24 23:15:57 -04:00
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)
2026-07-24 21:43:11 -04:00
self.assertIn('id="left-resizer"', html)
self.assertIn('id="right-resizer"', html)
2026-07-24 23:15:57 -04:00
self.assertIn('id="view-nodes"', html)
self.assertIn('id="view-flow"', html)
self.assertIn('id="view-web"', html)
self.assertIn('id="view-logic"', html)
2026-07-24 21:43:11 -04:00
self.assertIn('id="neighborhood-sections"', html)
2026-07-24 23:40:47 -04:00
self.assertIn('id="relationship-key"', html)
self.assertIn('id="relationship-key-list"', html)
2026-07-24 23:15:57 -04:00
self.assertNotIn('id="details"', html)
self.assertIn(".empty[hidden] { display: none; }", css)
self.assertIn("html, body { height: 100%; overflow: hidden; }", css)
2026-07-24 22:54:19 -04:00
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)
2026-07-24 23:40:47 -04:00
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]
2026-07-24 21:14:30 -04:00
self.assertNotIn("setPointerCapture", pointerdown)
self.assertIn("setPointerCapture", pointermove)
2026-07-24 23:15:57 -04:00
self.assertIn("right-click full inspector", html)
2026-07-24 16:01:03 -04:00
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"])
2026-07-24 16:01:03 -04:00
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()
2026-07-24 16:01:03 -04:00
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"])
2026-07-24 21:43:11 -04:00
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"])
2026-07-24 16:01:03 -04:00
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)
2026-07-24 23:15:57 -04:00
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"])
2026-07-24 16:01:03 -04:00
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"])
2026-07-24 16:01:03 -04:00
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()
2026-07-24 21:43:11 -04:00
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:
2026-07-24 22:07:33 -04:00
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)
2026-07-24 22:07:33 -04:00
2026-07-24 16:01:03 -04:00
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()