Navegador

feat: graph explorer — HTTP server with browser-based force-directed visualization Self-contained HTML page with canvas-based physics layout, search, and node detail panel. No external dependencies. CLI: navegador explore [--port 8080]. Closes #8

lmata 2026-03-23 05:50 trunk
Commit 26020931064a63106f065634dc2812bdd8a827730e2930d0416ecc0b2da342fe
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -753,10 +753,62 @@
753753
f"({len(applied)} migration{'s' if len(applied) != 1 else ''})"
754754
)
755755
else:
756756
console.print(f"[green]Schema is up to date[/green] (v{current})")
757757
758
+
759
+# ── Graph explorer ────────────────────────────────────────────────────────────
760
+
761
+
762
+@main.command()
763
+@DB_OPTION
764
+@click.option("--host", default="127.0.0.1", show_default=True, help="Bind address.")
765
+@click.option("--port", default=8080, show_default=True, help="TCP port.")
766
+@click.option(
767
+ "--no-browser",
768
+ is_flag=True,
769
+ default=False,
770
+ help="Don't open a browser tab automatically.",
771
+)
772
+def explore(db: str, host: str, port: int, no_browser: bool):
773
+ """Launch the browser-based graph explorer.
774
+
775
+ Starts an HTTP server and opens the interactive force-directed
776
+ visualisation in your default browser.
777
+
778
+ \b
779
+ Examples:
780
+ navegador explore
781
+ navegador explore --port 9000
782
+ navegador explore --no-browser
783
+ """
784
+ import time
785
+ import webbrowser
786
+
787
+ from navegador.explorer import ExplorerServer
788
+
789
+ store = _get_store(db)
790
+ server = ExplorerServer(store, host=host, port=port)
791
+ server.start()
792
+ url = server.url
793
+
794
+ console.print(f"[green]Graph explorer running[/green] → {url}")
795
+ console.print("Press [bold]Ctrl-C[/bold] to stop.")
796
+
797
+ if not no_browser:
798
+ # Small delay so the server is accepting connections before the browser hits it
799
+ time.sleep(0.3)
800
+ webbrowser.open(url)
801
+
802
+ try:
803
+ while True:
804
+ time.sleep(1)
805
+ except KeyboardInterrupt:
806
+ console.print("\n[yellow]Stopping explorer…[/yellow]")
807
+ finally:
808
+ server.stop()
809
+
758810
759811
# ── Enrichment ───────────────────────────────────────────────────────────────
760812
761813
762814
@main.command()
763815
764816
ADDED navegador/explorer/__init__.py
765817
ADDED navegador/explorer/server.py
766818
ADDED navegador/explorer/templates.py
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -753,10 +753,62 @@
753 f"({len(applied)} migration{'s' if len(applied) != 1 else ''})"
754 )
755 else:
756 console.print(f"[green]Schema is up to date[/green] (v{current})")
757
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
758
759 # ── Enrichment ───────────────────────────────────────────────────────────────
760
761
762 @main.command()
763
764 DDED navegador/explorer/__init__.py
765 DDED navegador/explorer/server.py
766 DDED navegador/explorer/templates.py
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -753,10 +753,62 @@
753 f"({len(applied)} migration{'s' if len(applied) != 1 else ''})"
754 )
755 else:
756 console.print(f"[green]Schema is up to date[/green] (v{current})")
757
758
759 # ── Graph explorer ────────────────────────────────────────────────────────────
760
761
762 @main.command()
763 @DB_OPTION
764 @click.option("--host", default="127.0.0.1", show_default=True, help="Bind address.")
765 @click.option("--port", default=8080, show_default=True, help="TCP port.")
766 @click.option(
767 "--no-browser",
768 is_flag=True,
769 default=False,
770 help="Don't open a browser tab automatically.",
771 )
772 def explore(db: str, host: str, port: int, no_browser: bool):
773 """Launch the browser-based graph explorer.
774
775 Starts an HTTP server and opens the interactive force-directed
776 visualisation in your default browser.
777
778 \b
779 Examples:
780 navegador explore
781 navegador explore --port 9000
782 navegador explore --no-browser
783 """
784 import time
785 import webbrowser
786
787 from navegador.explorer import ExplorerServer
788
789 store = _get_store(db)
790 server = ExplorerServer(store, host=host, port=port)
791 server.start()
792 url = server.url
793
794 console.print(f"[green]Graph explorer running[/green] → {url}")
795 console.print("Press [bold]Ctrl-C[/bold] to stop.")
796
797 if not no_browser:
798 # Small delay so the server is accepting connections before the browser hits it
799 time.sleep(0.3)
800 webbrowser.open(url)
801
802 try:
803 while True:
804 time.sleep(1)
805 except KeyboardInterrupt:
806 console.print("\n[yellow]Stopping explorer…[/yellow]")
807 finally:
808 server.stop()
809
810
811 # ── Enrichment ───────────────────────────────────────────────────────────────
812
813
814 @main.command()
815
816 DDED navegador/explorer/__init__.py
817 DDED navegador/explorer/server.py
818 DDED navegador/explorer/templates.py
--- a/navegador/explorer/__init__.py
+++ b/navegador/explorer/__init__.py
@@ -0,0 +1,19 @@
1
+# Copyright CONFLICT LLC 2026 (weareconflict.com)
2
+"""
3
+Navegador Graph Explorer — HTTP server + browser-based visualisation.
4
+
5
+Usage::
6
+
7
+ from navegador.graph import GraphStore
8
+ from navegador.explorer import ExplorerServer
9
+
10
+ store = GraphStore.sqlite(".navegador/graph.db")
11
+ server = ExplorerServer(store, host="127.0.0.1", port=8080)
12
+ server.start() # opens http://127.0.0.1:8080 in a thread
13
+ ...
14
+ server.stop()
15
+"""
16
+
17
+from .server import ExplorerServer
18
+
19
+__all__ = ["ExplorerServer"]
--- a/navegador/explorer/__init__.py
+++ b/navegador/explorer/__init__.py
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/navegador/explorer/__init__.py
+++ b/navegador/explorer/__init__.py
@@ -0,0 +1,19 @@
1 # Copyright CONFLICT LLC 2026 (weareconflict.com)
2 """
3 Navegador Graph Explorer — HTTP server + browser-based visualisation.
4
5 Usage::
6
7 from navegador.graph import GraphStore
8 from navegador.explorer import ExplorerServer
9
10 store = GraphStore.sqlite(".navegador/graph.db")
11 server = ExplorerServer(store, host="127.0.0.1", port=8080)
12 server.start() # opens http://127.0.0.1:8080 in a thread
13 ...
14 server.stop()
15 """
16
17 from .server import ExplorerServer
18
19 __all__ = ["ExplorerServer"]
--- a/navegador/explorer/server.py
+++ b/navegador/explorer/server.py
@@ -0,0 +1,243 @@
1
+# Copyright CONFLICT LLC 2026 (weareconflict.com)
2
+"""
3
+ExplorerServer — lightweight HTTP server for graph visualisation.
4
+
5
+Runs in a daemon thread so it does not block the caller. Uses only
6
+Python's built-in http.server and json modules (no Flask/FastAPI).
7
+
8
+Routes
9
+------
10
+GET / — self-contained HTML visualisation page
11
+GET /api/graph — full graph as {nodes: [...], edges: [...]}
12
+GET /api/search?q= — search nodes by name (case-insensitive substring)
13
+GET /api/node/<name>— node details + immediate neighbours
14
+GET /api/stats — {nodes: N, edges: M, node_types: {...}, edge_types: {...}}
15
+"""
16
+
17
+from __future__ import annotations
18
+
19
+import json
20
+import logging
21
+import threading
22
+from http.server import BaseHTTPRequestHandler, HTTPServer
23
+from typing import TYPE_CHECKING, Any
24
+from urllib.parse import parse_qs, unquote, urlparse
25
+
26
+from .templates import HTML_TEMPLATE
27
+
28
+if TYPE_CHECKING:
29
+ from navegador.graph.store import GraphStore
30
+
31
+logger = logging.getLogger(__name__)
32
+
33
+# ── Cypher helpers ─────────────────────────────────────────────────────────
34
+
35
+
36
+def _query(store: "GraphStore", cypher: str, params: dict[str, Any] | None = None) -> list:
37
+ """Run a Cypher query and return the result_set list (or [])."""
38
+ try:
39
+ result = store.query(cypher, params or {})
40
+ return result.result_set or []
41
+ except Exception as exc:
42
+ logger.warning("Graph query failed: %s", exc)
43
+ return []
44
+
45
+
46
+def _get_all_nodes(store: "GraphStore") -> list[dict]:
47
+ rows = _query(
48
+ store,
49
+ "MATCH (n) RETURN id(n) AS id, labels(n)[0] AS label, "
50
+ "n.name AS name, properties(n) AS props",
51
+ )
52
+ result = []
53
+ for row in rows:
54
+ nid, label, name, props = row[0], row[1], row[2], row[3]
55
+ node_props = dict(props) if isinstance(props, dict) els{
56
+.append(
57
+ str(nid),
58
+ "name": nam"props": node_props,
59
+ })
60
+ return result )
61
+ return result
62
+
63
+
64
+def _get_all_edges(store: "GraphStore") -> list[dict]:
65
+ rows = _query(
66
+ store,
67
+ "MATCH (a)-[r]->(b) RETURN id(a) AS src, id(b) AS tgt, type(r) AS rel",
68
+ )
69
+ result = []
70
+ for row in rows:
71
+ src, tgt, rel = row[0], row[1], row[2]
72
+ result.append({"source": str(src), "target": str(tgt), "type": rel or ""})
73
+ return result
74
+
75
+
76
+def _search_nodes(store: "GraphStore", query: str, limit: int = 50) -> list[dict]:
77
+ q = query.lower()
78
+ rows = _query(
79
+ store,
80
+ "MATCH (n) WHERE toLower(n.name) CONTAINS $q "
81
+ "RETURN labels(n)[0] AS label, n.name AS name, "
82
+ "coalesce(n.file_path, '') AS file_path, "
83
+ "coalesce(n.domain, '') AS domain "
84
+ "LIMIT $limit",
85
+ {"q": q, "limit": limit},
86
+ )
87
+ result = []
88
+ for row in r{
89
+ "label": row[0ult = store.query(cyp [])."""
90
+ try:
91
+ result = try:
92
+ rn result.result_set or []
93
+ e})
94
+ return resultogger.warning("Graph query failed: %s", exc)
95
+ return []
96
+
97
+
98
+def _get_all_nodes(store: "GraphStore") -> list[dict]:
99
+ rows = _query(
100
+ store,
101
+ "MATCH (n) RETURN id(n) AS id, labels(n)[0] AS label, "
102
+ "n.name AS name, properties(n) AS props",
103
+ )
104
+ result = []
105
+ for row in rows:
106
+ nid, label, name, props = row[0], row[1], row[2], row[3]
107
+ node_props = dict(props) if isinstance(props, dict) else {}
108
+ result.append(
109
+ {
110
+ "id": str(nid),
111
+ "label": label or "default",
112
+ "name": name or str(nid),
113
+ "props": node_props,
114
+ }
115
+ )
116
+ return result
117
+
118
+
119
+def _get_all_edges(store: "GraphStore") -> list[dict]:
120
+ rows = _query(
121
+ store,
122
+ "MATCH (a)-[r]->(b) RETURN id(a) AS src, id(b) AS tgt, type(r) AS rel",
123
+ )
124
+ result = []
125
+ for row in rows:
126
+ src, tgt, rel = row[0], row[1], row[2]
127
+ result.append({"source": str(src), "target": str(tgt), "type": rel or ""})
128
+ return result
129
+
130
+
131
+def _search_nodes(store: "GraphStore", query: str, limit: int = 50) -> list[dict]:
132
+ q = query.lower()
133
+ rows = _query(
134
+ store,
135
+ "MATCH (n) WHERE toLower(n.name) CONTAINS $q "
136
+ "RETURN labels(n)[0] AS label, n.name AS name, "
137
+ "coalesce(n.file_path, '') AS file_path, "
138
+ "coalesce(n.domain, '') AS domain "
139
+ "LIMIT $limit",
140
+ {"q": q, "limit": limit},
141
+ )
142
+ result = []
143
+ for row in rows:
144
+ result.append(
145
+ {
146
+ "label": row[0] or "",
147
+ "name": row[1] or "",
148
+ "file_path": row[2] or "",
149
+ "domain": row[3] or "",
150
+ }
151
+ )
152
+ return result
153
+
154
+
155
+def _get_node_detail(store: "GraphStore", name: str) -> dict:
156
+ # Node properties
157
+ rows = _query(
158
+ store,
159
+ "MATCH (n) WHERE n.name = $name "
160
+ "RETURN labels(n)[0] AS label, properties(n) AS props "
161
+ "LIMIT 1",
162
+ {"name": name},
163
+ )
164
+ if not rows:
165
+ return {"name": name, "label": "", "props": {}, "neighbors": []}
166
+
167
+ label = rows[0][0] or ""
168
+ props = dict(rows[0][1]) if isinstance(rows[0][1], dict) else {}
169
+
170
+ # Outbound neighbours
171
+ out_rows = _query(
172
+ store,
173
+ "MATCH (n)-[r]->(nb) WHERE n.name = $name "
174
+ "RETURN labels(nb)[0] AS nb_label, nb.name AS nb_name, type(r) AS rel "
175
+ "LIMIT 100",
176
+ {"name": name},
177
+ )
178
+ # Inbound neighbours
179
+ in_rows = _query(
180
+ store,
181
+ "MATCH (nb)-[r]->(n) WHERE n.name = $name "
182
+ "RETURN labels(nb)[0] AS nb_label, nb.name AS nb_name, type(r) AS rel "
183
+ "LIMIT 100",
184
+ {"name": name},
185
+ )
186
+
187
+ seen: set[str] = set()
188
+ neighbors = []
189
+ for row in list(out_rows) + list(in_rows):
190
+ nb_label, nb_name, rel = row[0] or "", row[1] or "", row[2] or ""
191
+ key = f"{nb_name}|{rel}"
192
+ if key not in seen:
193
+ seen.add(key)
194
+ neighbors.append({"label": nb_label, "name": nb_name, "rel": rel})
195
+
196
+ return {"name": name, "label": label, "props": props, "neighbors": neighbors}
197
+
198
+
199
+def _get_stats(store: "GraphStore") -> dict:
200
+ node_count = store.node_count()
201
+ edge_count = store.edge_count()
202
+
203
+ node_type_rows = _query(
204
+ store, "MATCH (n) RETURN labels(n)[0] AS type, count(n) AS c ORDER BY c DESC"
205
+ )
206
+ edge_type_rows = _query(
207
+ store, "MATCH ()-[r]->() RETURN type(r) AS type, count(r) AS c ORDER BY c DESC"
208
+ )
209
+
210
+ return {
211
+ "nodes": node_count,
212
+ "edges": edge_count,
213
+ "node_types": {r[0]: r[1] for r in node_type_rows if r[0]},
214
+ "edge_types": {r[0]: r[1] for r in edge_type_rows if r[0]},
215
+ }
216
+
217
+
218
+# ── Request handler ────────────────────────────────────────────────────────
219
+
220
+
221
+def _make_handler(store: "GraphStore"):
222
+ """Return a BaseHTTPRequestHandler subclass bound to *store*."""
223
+
224
+ class _Handler(BaseHTTPRequestHandler):
225
+ _store = store
226
+
227
+ # silence default access log to keep CLI output cle:] self.seessage(self, fmt, *args):
228
+ logger.debug(fmt, *args)
229
+
230
+ def _send_json(self, data: Any, status: int = 200) -> None:
231
+ body = json.dumps(data, default=str).encode()
232
+ self.send_response(status)
233
+ self.send_header("Content-Type", "application/json")
234
+ self.send_header("Content-Length", str(len(body)))
235
+ self.send_header("Access-Control-Allow-Origin", "*")
236
+ self.end_headers()
237
+ self.wfile.write(body)
238
+
239
+ def _send_html(self, html: str) -> None:
240
+ body = html.encode()
241
+ self.send_response(200)
242
+ self.send_header("Content-Type", "text/html; charset=utf-8")
243
+ self.send_header("Content-Length", s
--- a/navegador/explorer/server.py
+++ b/navegador/explorer/server.py
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/navegador/explorer/server.py
+++ b/navegador/explorer/server.py
@@ -0,0 +1,243 @@
1 # Copyright CONFLICT LLC 2026 (weareconflict.com)
2 """
3 ExplorerServer — lightweight HTTP server for graph visualisation.
4
5 Runs in a daemon thread so it does not block the caller. Uses only
6 Python's built-in http.server and json modules (no Flask/FastAPI).
7
8 Routes
9 ------
10 GET / — self-contained HTML visualisation page
11 GET /api/graph — full graph as {nodes: [...], edges: [...]}
12 GET /api/search?q= — search nodes by name (case-insensitive substring)
13 GET /api/node/<name>— node details + immediate neighbours
14 GET /api/stats — {nodes: N, edges: M, node_types: {...}, edge_types: {...}}
15 """
16
17 from __future__ import annotations
18
19 import json
20 import logging
21 import threading
22 from http.server import BaseHTTPRequestHandler, HTTPServer
23 from typing import TYPE_CHECKING, Any
24 from urllib.parse import parse_qs, unquote, urlparse
25
26 from .templates import HTML_TEMPLATE
27
28 if TYPE_CHECKING:
29 from navegador.graph.store import GraphStore
30
31 logger = logging.getLogger(__name__)
32
33 # ── Cypher helpers ─────────────────────────────────────────────────────────
34
35
36 def _query(store: "GraphStore", cypher: str, params: dict[str, Any] | None = None) -> list:
37 """Run a Cypher query and return the result_set list (or [])."""
38 try:
39 result = store.query(cypher, params or {})
40 return result.result_set or []
41 except Exception as exc:
42 logger.warning("Graph query failed: %s", exc)
43 return []
44
45
46 def _get_all_nodes(store: "GraphStore") -> list[dict]:
47 rows = _query(
48 store,
49 "MATCH (n) RETURN id(n) AS id, labels(n)[0] AS label, "
50 "n.name AS name, properties(n) AS props",
51 )
52 result = []
53 for row in rows:
54 nid, label, name, props = row[0], row[1], row[2], row[3]
55 node_props = dict(props) if isinstance(props, dict) els{
56 .append(
57 str(nid),
58 "name": nam"props": node_props,
59 })
60 return result )
61 return result
62
63
64 def _get_all_edges(store: "GraphStore") -> list[dict]:
65 rows = _query(
66 store,
67 "MATCH (a)-[r]->(b) RETURN id(a) AS src, id(b) AS tgt, type(r) AS rel",
68 )
69 result = []
70 for row in rows:
71 src, tgt, rel = row[0], row[1], row[2]
72 result.append({"source": str(src), "target": str(tgt), "type": rel or ""})
73 return result
74
75
76 def _search_nodes(store: "GraphStore", query: str, limit: int = 50) -> list[dict]:
77 q = query.lower()
78 rows = _query(
79 store,
80 "MATCH (n) WHERE toLower(n.name) CONTAINS $q "
81 "RETURN labels(n)[0] AS label, n.name AS name, "
82 "coalesce(n.file_path, '') AS file_path, "
83 "coalesce(n.domain, '') AS domain "
84 "LIMIT $limit",
85 {"q": q, "limit": limit},
86 )
87 result = []
88 for row in r{
89 "label": row[0ult = store.query(cyp [])."""
90 try:
91 result = try:
92 rn result.result_set or []
93 e})
94 return resultogger.warning("Graph query failed: %s", exc)
95 return []
96
97
98 def _get_all_nodes(store: "GraphStore") -> list[dict]:
99 rows = _query(
100 store,
101 "MATCH (n) RETURN id(n) AS id, labels(n)[0] AS label, "
102 "n.name AS name, properties(n) AS props",
103 )
104 result = []
105 for row in rows:
106 nid, label, name, props = row[0], row[1], row[2], row[3]
107 node_props = dict(props) if isinstance(props, dict) else {}
108 result.append(
109 {
110 "id": str(nid),
111 "label": label or "default",
112 "name": name or str(nid),
113 "props": node_props,
114 }
115 )
116 return result
117
118
119 def _get_all_edges(store: "GraphStore") -> list[dict]:
120 rows = _query(
121 store,
122 "MATCH (a)-[r]->(b) RETURN id(a) AS src, id(b) AS tgt, type(r) AS rel",
123 )
124 result = []
125 for row in rows:
126 src, tgt, rel = row[0], row[1], row[2]
127 result.append({"source": str(src), "target": str(tgt), "type": rel or ""})
128 return result
129
130
131 def _search_nodes(store: "GraphStore", query: str, limit: int = 50) -> list[dict]:
132 q = query.lower()
133 rows = _query(
134 store,
135 "MATCH (n) WHERE toLower(n.name) CONTAINS $q "
136 "RETURN labels(n)[0] AS label, n.name AS name, "
137 "coalesce(n.file_path, '') AS file_path, "
138 "coalesce(n.domain, '') AS domain "
139 "LIMIT $limit",
140 {"q": q, "limit": limit},
141 )
142 result = []
143 for row in rows:
144 result.append(
145 {
146 "label": row[0] or "",
147 "name": row[1] or "",
148 "file_path": row[2] or "",
149 "domain": row[3] or "",
150 }
151 )
152 return result
153
154
155 def _get_node_detail(store: "GraphStore", name: str) -> dict:
156 # Node properties
157 rows = _query(
158 store,
159 "MATCH (n) WHERE n.name = $name "
160 "RETURN labels(n)[0] AS label, properties(n) AS props "
161 "LIMIT 1",
162 {"name": name},
163 )
164 if not rows:
165 return {"name": name, "label": "", "props": {}, "neighbors": []}
166
167 label = rows[0][0] or ""
168 props = dict(rows[0][1]) if isinstance(rows[0][1], dict) else {}
169
170 # Outbound neighbours
171 out_rows = _query(
172 store,
173 "MATCH (n)-[r]->(nb) WHERE n.name = $name "
174 "RETURN labels(nb)[0] AS nb_label, nb.name AS nb_name, type(r) AS rel "
175 "LIMIT 100",
176 {"name": name},
177 )
178 # Inbound neighbours
179 in_rows = _query(
180 store,
181 "MATCH (nb)-[r]->(n) WHERE n.name = $name "
182 "RETURN labels(nb)[0] AS nb_label, nb.name AS nb_name, type(r) AS rel "
183 "LIMIT 100",
184 {"name": name},
185 )
186
187 seen: set[str] = set()
188 neighbors = []
189 for row in list(out_rows) + list(in_rows):
190 nb_label, nb_name, rel = row[0] or "", row[1] or "", row[2] or ""
191 key = f"{nb_name}|{rel}"
192 if key not in seen:
193 seen.add(key)
194 neighbors.append({"label": nb_label, "name": nb_name, "rel": rel})
195
196 return {"name": name, "label": label, "props": props, "neighbors": neighbors}
197
198
199 def _get_stats(store: "GraphStore") -> dict:
200 node_count = store.node_count()
201 edge_count = store.edge_count()
202
203 node_type_rows = _query(
204 store, "MATCH (n) RETURN labels(n)[0] AS type, count(n) AS c ORDER BY c DESC"
205 )
206 edge_type_rows = _query(
207 store, "MATCH ()-[r]->() RETURN type(r) AS type, count(r) AS c ORDER BY c DESC"
208 )
209
210 return {
211 "nodes": node_count,
212 "edges": edge_count,
213 "node_types": {r[0]: r[1] for r in node_type_rows if r[0]},
214 "edge_types": {r[0]: r[1] for r in edge_type_rows if r[0]},
215 }
216
217
218 # ── Request handler ────────────────────────────────────────────────────────
219
220
221 def _make_handler(store: "GraphStore"):
222 """Return a BaseHTTPRequestHandler subclass bound to *store*."""
223
224 class _Handler(BaseHTTPRequestHandler):
225 _store = store
226
227 # silence default access log to keep CLI output cle:] self.seessage(self, fmt, *args):
228 logger.debug(fmt, *args)
229
230 def _send_json(self, data: Any, status: int = 200) -> None:
231 body = json.dumps(data, default=str).encode()
232 self.send_response(status)
233 self.send_header("Content-Type", "application/json")
234 self.send_header("Content-Length", str(len(body)))
235 self.send_header("Access-Control-Allow-Origin", "*")
236 self.end_headers()
237 self.wfile.write(body)
238
239 def _send_html(self, html: str) -> None:
240 body = html.encode()
241 self.send_response(200)
242 self.send_header("Content-Type", "text/html; charset=utf-8")
243 self.send_header("Content-Length", s
--- a/navegador/explorer/templates.py
+++ b/navegador/explorer/templates.py
@@ -0,0 +1,660 @@
1
+# Copyright CONFLICT LLC 2026 (weareconflict.com)
2
+"""
3
+HTML template for the Navegador Graph Explorer.
4
+
5
+A fully self-contained, single-file HTML page with:
6
+- Force-directed graph visualisation via inline canvas + JS physics
7
+- Search box that queries /api/search
8
+- Node click → detail panel via /api/node/<name>
9
+- Stats bar via /api/stats
10
+- Zero external dependencies (no CDN, no frameworks)
11
+"""
12
+
13
+# Colour palette per node label ─────────────────────────────────────────────
14
+NODE_COLORS = {
15
+ "Function": "#4e9af1",
16
+ "Method": "#6cb4f5",
17
+ "Class": "#f4a93b",
18
+ "File": "#a8d9a7",
19
+ "Module": "#82c9a0",
20
+ "Repository": "#e67e22",
21
+ "Variable": "#c39bd3",
22
+ "Import": "#a9cce3",
23
+ "Decorator": "#f1948a",
24
+ "Domain": "#f7dc6f",
25
+ "Concept": "#f9e79f",
26
+ "Rule": "#f0b27a",
27
+ "Decision": "#f8c471",
28
+ "WikiPage": "#d2b4de",
29
+ "Person": "#fadbd8",
30
+ "default": "#aaaaaa",
31
+
32
+}
33
+
34
+_COLORS_JS = "\n".join(f" '{label}': '{color}'," for label, c
35
+LICT LLC 2026 (weareconflict.com)
36
+"""
37
+HTML template for the Navegador Graph Explorer.
38
+
39
+A fully self-contained, single-file HTML page with:
40
+- Force-directed graph visualisation via inline canvas + JS physics
41
+- Search box that queries /api/search
42
+- Node click → detail panel via /api/node/<name>
43
+- Stats bar via /api/stats
44
+- Zero external dependencies (no CDN, no frameworks)
45
+"""
46
+
47
+# Colour palette per node label ─────────────────────────────────────────────
48
+NODE_COLORS = {
49
+ "Function": "#4e9af1",
50
+ "Method": "#6cb4f5",
51
+ "Class": "#f4a93b",
52
+ "File": "#a8d9a7",
53
+ "Module": "#82c9a0",
54
+ "Repository": "#e67e22",
55
+ "Variable": "#c39bd3",
56
+ "Import": "#a9cce3",
57
+ "Decorator": "#f1948a",
58
+ "Domain": "#f7dc6f",
59
+ "Concept": "#f9e79f",
60
+ "Rule": "#f0b27a",
61
+ "Decision": "#f8c471",
62
+ "WikiPage": "#d2b4de",
63
+ "Person": "#fadbd8",
64
+ "default": "#aaaaaa",
65
+}
66
+
67
+_COLORS_JS = "\n".join(f" '{label}': '{color}'," for label, color in NODE_COLORS.items())
68
+
69
+HTML_TEMPLATE = """<!DOCTYPE html>
70
+<html lang="en">
71
+<head>
72
+<meta charset="UTF-8">
73
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
74
+<title>Navegador Graph Explorer</title>
75
+<style>
76
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
77
+ body {{
78
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
79
+ background: #1a1a2e;
80
+ color: #e0e0e0;
81
+ height: 100vh;
82
+ display: flex;
83
+ flex-direction: column;
84
+ overflow: hidden;
85
+ }}
86
+ #header {{
87
+ background: #16213e;
88
+ border-bottom: 1px solid #0f3460;
89
+ padding: 10px 16px;
90
+ display: flex;
91
+ align-items: center;
92
+ gap: 12px;
93
+ flex-shrink: 0;
94
+ }}
95
+ #header h1 {{
96
+ font-size: 1.1rem;
97
+ color: #4e9af1;
98
+ letter-spacing: 0.05em;
99
+ white-space: nowrap;
100
+ }}
101
+ #search-box {{
102
+ flex: 1;
103
+ max-width: 400px;
104
+ background: #0f3460;
105
+ border: 1px solid #4e9af1;
106
+ border-radius: 6px;
107
+ padding: 6px 12px;
108
+ color: #e0e0e0;
109
+ font-size: 0.9rem;
110
+ outline: none;
111
+ }}
112
+ #search-box::placeholder {{ color: #5a6a8a; }}
113
+ #search-box:focus {{ border-color: #6cb4f5; box-shadow: 0 0 0 2px rgba(78,154,241,0.2); }}
114
+ #stats-bar {{
115
+ font-size: 0.75rem;
116
+ color: #7a8aaa;
117
+ white-space: nowrap;
118
+ }}
119
+ #main {{
120
+ display: flex;
121
+ flex: 1;
122
+ overflow: hidden;
123
+ }}
124
+ #canvas-wrap {{
125
+ flex: 1;
126
+ position: relative;
127
+ overflow: hidden;
128
+ }}
129
+ #graph-canvas {{
130
+ display: block;
131
+ width: 100%;
132
+ height: 100%;
133
+ cursor: grab;
134
+ }}
135
+ #graph-canvas:active {{ cursor: grabbing; }}
136
+ #sidebar {{
137
+ width: 300px;
138
+ background: #16213e;
139
+ border-left: 1px solid #0f3460;
140
+ display: flex;
141
+ flex-direction: column;
142
+ flex-shrink: 0;
143
+ overflow: hidden;
144
+ }}
145
+ #sidebar-title {{
146
+ padding: 10px 14px;
147
+ font-size: 0.8rem;
148
+ color: #7a8aaa;
149
+ border-bottom: 1px solid #0f3460;
150
+ text-transform: uppercase;
151
+ letter-spacing: 0.08em;
152
+ }}
153
+ #search-results {{
154
+ border-bottom: 1px solid #0f3460;
155
+ max-height: 220px;
156
+ overflow-y: auto;
157
+ }}
158
+ .search-result {{
159
+ padding: 8px 14px;
160
+ cursor: pointer;
161
+ border-bottom: 1px solid #0d1b35;
162
+ transition: background 0.15s;
163
+ }}
164
+ .search-result:hover {{ background: #0f3460; }}
165
+ .search-result .sr-name {{ font-size: 0.85rem; font-weight: 600; color: #c8d8f0; }}
166
+ .search-result .sr-meta {{ font-size: 0.72rem; color: #5a6a8a; margin-top: 2px; }}
167
+ #detail-panel {{
168
+ flex: 1;
169
+ padding: 14px;
170
+ overflow-y: auto;
171
+ font-size: 0.82rem;
172
+ }}
173
+ #detail-panel h2 {{
174
+ font-size: 1rem;
175
+ color: #4e9af1;
176
+ margin-bottom: 8px;
177
+ word-break: break-all;
178
+ }}
179
+ .detail-label {{
180
+ color: #5a6a8a;
181
+ font-size: 0.72rem;
182
+ text-transform: uppercase;
183
+ letter-spacing: 0.06em;
184
+ margin-top: 10px;
185
+ margin-bottom: 3px;
186
+ }}
187
+ .detail-value {{
188
+ color: #c8d8f0;
189
+ word-break: break-word;
190
+ }}
191
+ .badge {{
192
+ display: inline-block;
193
+ padding: 2px 7px;
194
+ border-radius: 10px;
195
+ font-size: 0.7rem;
196
+ font-weight: 600;
197
+ margin-right: 4px;
198
+ margin-bottom: 4px;
199
+ }}
200
+ .neighbor-item {{
201
+ background: #0f3460;
202
+ border-radius: 4px;
203
+ padding: 4px 8px;
204
+ margin: 3px 0;
205
+ cursor: pointer;
206
+ transition: background 0.15s;
207
+ display: flex;
208
+ justify-content: space-between;
209
+ align-items: center;
210
+ }}
211
+ .neighbor-item:hover {{ background: #1a4a80; }}
212
+ .neighbor-name {{ color: #c8d8f0; font-size: 0.8rem; }}
213
+ .neighbor-type {{ color: #5a6a8a; font-size: 0.7rem; }}
214
+ #empty-hint {{
215
+ color: #3a4a6a;
216
+ text-align: center;
217
+ margin-top: 40px;
218
+ font-size: 0.85rem;
219
+ line-height: 1.6;
220
+ }}
221
+ #loading {{
222
+ position: absolute;
223
+ top: 50%;
224
+ left: 50%;
225
+ transform: translate(-50%, -50%);
226
+ color: #4e9af1;
227
+ font-size: 0.9rem;
228
+ pointer-events: none;
229
+ }}
230
+ ::-webkit-scrollbar {{ width: 5px; }}
231
+ ::-webkit-scrollbar-track {{ background: #0d1b35; }}
232
+ ::-webkit-scrollbar-thumb {{ background: #0f3460; border-radius: 3px; }}
233
+</style>
234
+</head>
235
+<body>
236
+<div id="header">
237
+ <h1>navegador</h1>
238
+ <input id="search-box" type="text" placeholder="Search nodes..." autocomplete="off">
239
+ <div id="stats-bar">Loading…</div>
240
+</div>
241
+<div id="main">
242
+ <div id="canvas-wrap">
243
+ <canvas id="graph-canvas"></canvas>
244
+ <div id="loading">Loading graph…</div>
245
+ </div>
246
+ <div id="sidebar">
247
+ <div id="sidebar-title">Explorer</div>
248
+ <div id="search-results"></div>
249
+ <div id="detail-panel">
250
+ <div id="empty-hint">Click a node<br>or search above<br>to see details.</div>
251
+ </div>
252
+ </div>
253
+</div>
254
+<script>
255
+(function() {
256
+'use strict';
257
+
258
+// ── Colour palette ─────────────────────────────────────────────────────────
259
+const NODE_COLORS = {{
260
+{colors}
261
+}};
262
+function nodeColor(label) {{
263
+ return NODE_COLORS[label] || NODE_COLORS['default'];
264
+}}
265
+
266
+// ── State ──────────────────────────────────────────────────────────────────
267
+let nodes = []; // {{id, label, name, x, y, vx, vy, ...props}}
268
+let edges = []; // {{source_id, target_id, type}}
269
+let nodeById = {{}}; // id → node
270
+
271
+let selectedNode = null;
272
+let hoveredNode = null;
273
+
274
+// Camera
275
+let camX = 0, camY = 0, camScale = 1;
276
+let isDragging = false, dragStartX = 0, dragStartY = 0, camStartX = 0, camStartY = 0;
277
+let isDraggingNode = false, dragNode = null, dragNodeOffX = 0, dragNodeOffY = 0;
278
+
279
+// Physics
280
+let physicsRunning = true;
281
+const REPEL = 8000;
282
+const ATTRACT = 0.04;
283
+const EDGE_LEN = 120;
284
+const DAMPING = 0.85;
285
+const MAX_VEL = 12;
286
+
287
+// ── Canvas setup ────────────────────────────────────────────────────────────
288
+const canvas = document.getElementById('graph-canvas');
289
+const ctx = canvas.getContext('2d');
290
+const wrap = document.getElementById('canvas-wrap');
291
+const loading = document.getElementById('loading');
292
+
293
+function resize() {{
294
+ canvas.width = wrap.clientWidth;
295
+ canvas.height = wrap.clientHeight;
296
+}}
297
+window.addEventListener('resize', resize);
298
+resize();
299
+
300
+// ── Fetch graph data ────────────────────────────────────────────────────────
301
+async function loadGraph() {{
302
+ try {{
303
+ const data = await fetch('/api/graph').then(r => r.json());
304
+ initGraph(data.nodes || [], data.edges || []);
305
+ loading.style.display = 'none';
306
+ loadStats();
307
+ }} catch(e) {{
308
+ loading.textContent = 'Error loading graph.';
309
+ }}
310
+}}
311
+
312
+function initGraph(rawNodes, rawEdges) {{
313
+ const cx = canvas.width / 2, cy = canvas.height / 2;
314
+ nodeById = {{}};
315
+ nodes = rawNodes.map((n, i) => {{
316
+ const angle = (i / Math.max(rawNodes.length, 1)) * 2 * Math.PI;
317
+ const r = Math.min(cx, cy) * 0.6;
318
+ const node = {{
319
+ id: n.id,
320
+ label: n.label || 'default',
321
+ name: n.name || n.id,
322
+ x: cx + r * Math.cos(angle),
323
+ y: cy + r * Math.sin(angle),
324
+ vx: 0, vy: 0,
325
+ props: n.props || {{}},
326
+ }};
327
+ nodeById[n.id] = node;
328
+ return node;
329
+ }});
330
+ edges = rawEdges.map(e => ({{
331
+ source_id: e.source,
332
+ target_id: e.target,
333
+ type: e.type || '',
334
+ }}));
335
+}}
336
+
337
+async function loadStats() {{
338
+ try {{
339
+ const s = await fetch('/api/stats').then(r => r.json());
340
+ const bar = document.getElementById('stats-bar');
341
+ bar.textContent = `${{s.nodes}} nodes · ${{s.edges}} edges`;
342
+ }} catch(_) {{}}
343
+}}
344
+
345
+// ── Physics simulation ──────────────────────────────────────────────────────
346
+function tick() {{
347
+ if (!physicsRunning || nodes.length === 0) return;
348
+
349
+ const cx = canvas.width / 2, cy = canvas.height / 2;
350
+
351
+ // Repulsion between all pairs (Barnes-Hut approximation skipped for simplicity)
352
+ for (let i = 0; i < nodes.length; i++) {{
353
+ const a = nodes[i];
354
+ for (let j = i + 1; j < nodes.length; j++) {{
355
+ const b = nodes[j];
356
+ let dx = b.x - a.x, dy = b.y - a.y;
357
+ const dist2 = dx*dx + dy*dy + 0.1;
358
+ const dist = Math.sqrt(dist2);
359
+ const force = REPEL / dist2;
360
+ const fx = (dx / dist) * force;
361
+ const fy = (dy / dist) * force;
362
+ a.vx -= fx; a.vy -= fy;
363
+ b.vx += fx; b.vy += fy;
364
+ }}
365
+ }}
366
+
367
+ // Edge spring attraction
368
+ for (const e of edges) {{
369
+ const a = nodeById[e.source_id], b = nodeById[e.target_id];
370
+ if (!a || !b) continue;
371
+ const dx = b.x - a.x, dy = b.y - a.y;
372
+ const dist = Math.sqrt(dx*dx + dy*dy) || 1;
373
+ const force = (dist - EDGE_LEN) * ATTRACT;
374
+ const fx = (dx / dist) * force;
375
+ const fy = (dy / dist) * force;
376
+ a.vx += fx; a.vy += fy;
377
+ b.vx -= fx; b.vy -= fy;
378
+ }}
379
+
380
+ // Weak centering pull
381
+ for (const n of nodes) {{
382
+ n.vx += (cx - n.x) * 0.0005;
383
+ n.vy += (cy - n.y) * 0.0005;
384
+ }}
385
+
386
+ // Integrate
387
+ for (const n of nodes) {{
388
+ if (n === dragNode) continue;
389
+ n.vx = Math.max(-MAX_VEL, Math.min(MAX_VEL, n.vx * DAMPING));
390
+ n.vy = Math.max(-MAX_VEL, Math.min(MAX_VEL, n.vy * DAMPING));
391
+ n.x += n.vx;
392
+ n.y += n.vy;
393
+ }}
394
+}}
395
+
396
+// ── Render ──────────────────────────────────────────────────────────────────
397
+function draw() {{
398
+ const w = canvas.width, h = canvas.height;
399
+ ctx.clearRect(0, 0, w, h);
400
+ ctx.save();
401
+ ctx.translate(camX, camY);
402
+ ctx.scale(camScale, camScale);
403
+
404
+ // Edges
405
+ ctx.lineWidth = 1 / camScale;
406
+ for (const e of edges) {{
407
+ const a = nodeById[e.source_id], b = nodeById[e.target_id];
408
+ if (!a || !b) continue;
409
+ const isHighlighted = selectedNode && (a === selectedNode || b === selectedNode);
410
+ ctx.globalAlpha = isHighlighted ? 0.9 : 0.25;
411
+ ctx.strokeStyle = isHighlighted ? '#4e9af1' : '#3a5070';
412
+ ctx.beginPath();
413
+ ctx.moveTo(a.x, a.y);
414
+ ctx.lineTo(b.x, b.y);
415
+ ctx.stroke();
416
+
417
+ // Arrowhead
418
+ if (isHighlighted) {{
419
+ const dx = b.x - a.x, dy = b.y - a.y;
420
+ const dist = Math.sqrt(dx*dx+dy*dy) || 1;
421
+ const r = 8;
422
+ const tx = b.x - (dx/dist)*r, ty = b.y - (dy/dist)*r;
423
+ const angle = Math.atan2(dy, dx);
424
+ ctx.globalAlpha = 0.7;
425
+ ctx.fillStyle = '#4e9af1';
426
+ ctx.beginPath();
427
+ ctx.moveTo(tx, ty);
428
+ ctx.lineTo(tx - 8*Math.cos(angle-0.4), ty - 8*Math.sin(angle-0.4));
429
+ ctx.lineTo(tx - 8*Math.cos(angle+0.4), ty - 8*Math.sin(angle+0.4));
430
+ ctx.closePath();
431
+ ctx.fill();
432
+ }}
433
+ }}
434
+ ctx.globalAlpha = 1;
435
+
436
+ // Edge labels on highlighted edges
437
+ if (selectedNode) {{
438
+ ctx.font = `${{Math.max(9, 10/camScale)}}px sans-serif`;
439
+ ctx.fillStyle = '#5a8ab8';
440
+ for (const e of edges) {{
441
+ const a = nodeById[e.source_id], b = nodeById[e.target_id];
442
+ if (!a || !b) continue;
443
+ if (a !== selectedNode && b !== selectedNode) continue;
444
+ if (!e.type) continue;
445
+ const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
446
+ ctx.fillText(e.type, mx, my);
447
+ }}
448
+ }}
449
+
450
+ // Nodes
451
+ const nodeR = 8;
452
+ for (const n of nodes) {{
453
+ const isSelected = n === selectedNode;
454
+ const isHovered = n === hoveredNode;
455
+ const color = nodeColor(n.label);
456
+
457
+ ctx.beginPath();
458
+ ctx.arc(n.x, n.y, nodeR + (isSelected ? 3 : isHovered ? 1 : 0), 0, 2*Math.PI);
459
+ ctx.fillStyle = color;
460
+ ctx.globalAlpha = 0.9;
461
+ ctx.fill();
462
+ if (isSelected || isHovered) {{
463
+ ctx.strokeStyle = '#ffffff';
464
+ ctx.lineWidth = 2 / camScale;
465
+ ctx.stroke();
466
+ }}
467
+ ctx.globalAlpha = 1;
468
+
469
+ // Label
470
+ const labelThreshold = 0.4;
471
+ if (camScale > labelThreshold || isSelected || isHovered) {{
472
+ const fontSize = Math.max(8, 11 / camScale);
473
+ ctx.font = `${{isSelected ? 'bold ' : ''}}${{fontSize}}px sans-serif`;
474
+ ctx.fillStyle = '#e0e8ff';
475
+ ctx.globalAlpha = Math.min(1, (camScale - labelThreshold + 0.1) * 3);
476
+ if (isSelected || isHovered) ctx.globalAlpha = 1;
477
+ ctx.fillText(n.name, n.x + nodeR + 2, n.y + 4);
478
+ ctx.globalAlpha = 1;
479
+ }}
480
+ }}
481
+
482
+ ctx.restore();
483
+}}
484
+
485
+function loop() {{
486
+ tick();
487
+ draw();
488
+ requestAnimationFrame(loop);
489
+}}
490
+
491
+// ── Hit testing ─────────────────────────────────────────────────────────────
492
+function screenToWorld(sx, sy) {{
493
+ return {{ x: (sx - camX) / camScale, y: (sy - camY) / camScale }};
494
+}}
495
+
496
+function nodeAtScreen(sx, sy) {{
497
+ const w = screenToWorld(sx, sy);
498
+ const nodeR = 11;
499
+ for (let i = nodes.length - 1; i >= 0; i--) {{
500
+ const n = nodes[i];
501
+ const dx = n.x - w.x, dy = n.y - w.y;
502
+ if (dx*dx + dy*dy <= nodeR*nodeR) return n;
503
+ }}
504
+ return null;
505
+}}
506
+
507
+// ── Mouse / touch events ────────────────────────────────────────────────────
508
+canvas.addEventListener('mousedown', e => {{
509
+ const hit = nodeAtScreen(e.offsetX, e.offsetY);
510
+ if (hit) {{
511
+ isDraggingNode = true;
512
+ dragNode = hit;
513
+ physicsRunning = true;
514
+ const w = screenToWorld(e.offsetX, e.offsetY);
515
+ dragNodeOffX = hit.x - w.x;
516
+ dragNodeOffY = hit.y - w.y;
517
+ }} else {{
518
+ isDragging = true;
519
+ dragStartX = e.offsetX; dragStartY = e.offsetY;
520
+ camStartX = camX; camStartY = camY;
521
+ }}
522
+}});
523
+
524
+canvas.addEventListener('mousemove', e => {{
525
+ if (isDraggingNode && dragNode) {{
526
+ const w = screenToWorld(e.offsetX, e.offsetY);
527
+ dragNode.x = w.x + dragNodeOffX;
528
+ dragNode.y = w.y + dragNodeOffY;
529
+ dragNode.vx = 0; dragNode.vy = 0;
530
+ }} else if (isDragging) {{
531
+ camX = camStartX + (e.offsetX - dragStartX);
532
+ camY = camStartY + (e.offsetY - dragStartY);
533
+ }} else {{
534
+ hoveredNode = nodeAtScreen(e.offsetX, e.offsetY);
535
+ canvas.style.cursor = hoveredNode ? 'pointer' : 'grab';
536
+ }}
537
+}});
538
+
539
+canvas.addEventListener('mouseup', e => {{
540
+ if (isDraggingNode && dragNode) {{
541
+ const wasDragged = Math.abs(dragNode.vx) < 0.5 && Math.abs(dragNode.vy) < 0.5;
542
+ if (wasDragged) selectNode(dragNode);
543
+ }} else if (!isDragging || (Math.abs(e.offsetX - dragStartX) < 4 && Math.abs(e.offsetY - dragStartY) < 4)) {{
544
+ const hit = nodeAtScreen(e.offsetX, e.offsetY);
545
+ if (hit) selectNode(hit);
546
+ }}
547
+ isDragging = false;
548
+ isDraggingNode = false;
549
+ dragNode = null;
550
+}});
551
+
552
+canvas.addEventListener('wheel', e => {{
553
+ e.preventDefault();
554
+ const factor = e.deltaY < 0 ? 1.1 : 0.9;
555
+ const mx = e.offsetX, my = e.offsetY;
556
+ camX = mx - (mx - camX) * factor;
557
+ camY = my - (my - camY) * factor;
558
+ camScale = Math.max(0.05, Math.min(10, camScale * factor));
559
+}}, {{ passive: false }});
560
+
561
+// ── Node selection ──────────────────────────────────────────────────────────
562
+async function selectNode(node) {{
563
+ selectedNode = node;
564
+ try {{
565
+ const data = await fetch('/api/node/' + encodeURIComponent(node.name)).then(r => r.json());
566
+ renderDetail(data);
567
+ }} catch(e) {{
568
+ renderDetail({{ name: node.name, label: node.label, props: node.props, neighbors: [] }});
569
+ }}
570
+}}
571
+
572
+function renderDetail(data) {{
573
+ const panel = document.getElementById('detail-panel');
574
+ const label = data.label || '';
575
+ const color = nodeColor(label);
576
+ let html = `<h2>${{data.name}}</h2>`;
577
+ html += `<div class="detail-label">Type</div>`;
578
+ html += `<span class="badge" style="background:${{color}}22;color:${{color}};border:1px solid ${{color}}44">${{label}}</span>`;
579
+
580
+ const props = data.props || {{}};
581
+ const skip = new Set(['name']);
582
+ const order = ['file_path', 'line_start', 'line_end', 'signature', 'docstring',
583
+ 'description', 'status', 'domain', 'rationale', 'url'];
584
+ const shown = new Set();
585
+
586
+ for (const key of order) {{
587
+ if (props[key] !== undefined && props[key] !== null && props[key] !== '') {{
588
+ html += `<div class="detail-label">${{key.replace(/_/g,' ')}}</div>`;
589
+ html += `<div class="detail-value">${{escHtml(String(props[key]))}}</div>`;
590
+ shown.add(key);
591
+ }}
592
+ }}
593
+ for (const [key, val] of Object.entries(props)) {{
594
+ if (skip.has(key) || shown.has(key) || val === null || val === undefined || val === '') continue;
595
+ html += `<div class="detail-label">${{key.replace(/_/g,' ')}}</div>`;
596
+ html += `<div class="detail-value">${{escHtml(String(val))}}</div>`;
597
+ }}
598
+
599
+ const neighbors = data.neighbors || [];
600
+ if (neighbors.length > 0) {{
601
+ html += `<div class="detail-label">Neighbors (${{neighbors.length}})</div>`;
602
+ for (con# Copyright CONFLICT LLC 2026 (weareconflict.com)
603
+"""
604
+HTML template for the Navegador Graph Explorer.
605
+
606
+A fully self-contained, single-file HTML page with:
607
+- Force-directed graph visualisation via inline canvas + JS physics
608
+- Search box that queries /api/search
609
+- Node click → detail panel via /api/node/<name>
610
+- Stats bar via /api/stats
611
+- Zero external dependencies (no CDN, no frameworks)
612
+"""
613
+
614
+# Colour palette per node label ─────────────────────────────────────────────
615
+NODE_COLORS = {
616
+ "Function": "#4e9af1",
617
+ "Method": "#6cb4f5",
618
+ "Class": "#f4a93b",
619
+ "File": "#a8d9a7",
620
+ "Module": "#82c9a0",
621
+ "Repository": "#e67e22",
622
+ "Variable": "#c39bd3",
623
+ "Import": "#a9cce3",
624
+ "Decorator": "#f1948a",
625
+ "Domain": "#f7dc6f",
626
+ "Concept": "#f9e79f",
627
+ "Rule": "#f0b27a",
628
+ "Decision": "#f8c471",
629
+ "WikiPage": "#d2b4de",
630
+ "Person": "#fadbd8",
631
+ "default": "#aaaaaa",
632
+}
633
+
634
+_COLORS_JS = "\n".join(f" '{label}': '{color}'," for label, color in NODE_COLORS.items())
635
+
636
+HTML_TEMPLATE = """<!DOCTYPE html>
637
+<html lang="en">
638
+<head>
639
+<meta charset="UTF-8">
640
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
641
+<title>Navegador Graph Explorer</title>
642
+<style>
643
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
644
+ body {{
645
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
646
+ background: #1a1a2e;
647
+ color: #e0e0e0;
648
+ height: 100vh;
649
+ display: flex;
650
+ flex-direction: column;
651
+ overflow: hidden;
652
+ }}
653
+ #header {{
654
+ background: #16213e;
655
+ border-bottom: 1px solid #0f3460;
656
+ padding: 10px 16px;
657
+ display: flex;
658
+ align-items: center;
659
+ gap: 12px;
660
+ flex-shrink:
--- a/navegador/explorer/templates.py
+++ b/navegador/explorer/templates.py
@@ -0,0 +1,660 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/navegador/explorer/templates.py
+++ b/navegador/explorer/templates.py
@@ -0,0 +1,660 @@
1 # Copyright CONFLICT LLC 2026 (weareconflict.com)
2 """
3 HTML template for the Navegador Graph Explorer.
4
5 A fully self-contained, single-file HTML page with:
6 - Force-directed graph visualisation via inline canvas + JS physics
7 - Search box that queries /api/search
8 - Node click → detail panel via /api/node/<name>
9 - Stats bar via /api/stats
10 - Zero external dependencies (no CDN, no frameworks)
11 """
12
13 # Colour palette per node label ─────────────────────────────────────────────
14 NODE_COLORS = {
15 "Function": "#4e9af1",
16 "Method": "#6cb4f5",
17 "Class": "#f4a93b",
18 "File": "#a8d9a7",
19 "Module": "#82c9a0",
20 "Repository": "#e67e22",
21 "Variable": "#c39bd3",
22 "Import": "#a9cce3",
23 "Decorator": "#f1948a",
24 "Domain": "#f7dc6f",
25 "Concept": "#f9e79f",
26 "Rule": "#f0b27a",
27 "Decision": "#f8c471",
28 "WikiPage": "#d2b4de",
29 "Person": "#fadbd8",
30 "default": "#aaaaaa",
31
32 }
33
34 _COLORS_JS = "\n".join(f" '{label}': '{color}'," for label, c
35 LICT LLC 2026 (weareconflict.com)
36 """
37 HTML template for the Navegador Graph Explorer.
38
39 A fully self-contained, single-file HTML page with:
40 - Force-directed graph visualisation via inline canvas + JS physics
41 - Search box that queries /api/search
42 - Node click → detail panel via /api/node/<name>
43 - Stats bar via /api/stats
44 - Zero external dependencies (no CDN, no frameworks)
45 """
46
47 # Colour palette per node label ─────────────────────────────────────────────
48 NODE_COLORS = {
49 "Function": "#4e9af1",
50 "Method": "#6cb4f5",
51 "Class": "#f4a93b",
52 "File": "#a8d9a7",
53 "Module": "#82c9a0",
54 "Repository": "#e67e22",
55 "Variable": "#c39bd3",
56 "Import": "#a9cce3",
57 "Decorator": "#f1948a",
58 "Domain": "#f7dc6f",
59 "Concept": "#f9e79f",
60 "Rule": "#f0b27a",
61 "Decision": "#f8c471",
62 "WikiPage": "#d2b4de",
63 "Person": "#fadbd8",
64 "default": "#aaaaaa",
65 }
66
67 _COLORS_JS = "\n".join(f" '{label}': '{color}'," for label, color in NODE_COLORS.items())
68
69 HTML_TEMPLATE = """<!DOCTYPE html>
70 <html lang="en">
71 <head>
72 <meta charset="UTF-8">
73 <meta name="viewport" content="width=device-width, initial-scale=1.0">
74 <title>Navegador Graph Explorer</title>
75 <style>
76 * {{ box-sizing: border-box; margin: 0; padding: 0; }}
77 body {{
78 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
79 background: #1a1a2e;
80 color: #e0e0e0;
81 height: 100vh;
82 display: flex;
83 flex-direction: column;
84 overflow: hidden;
85 }}
86 #header {{
87 background: #16213e;
88 border-bottom: 1px solid #0f3460;
89 padding: 10px 16px;
90 display: flex;
91 align-items: center;
92 gap: 12px;
93 flex-shrink: 0;
94 }}
95 #header h1 {{
96 font-size: 1.1rem;
97 color: #4e9af1;
98 letter-spacing: 0.05em;
99 white-space: nowrap;
100 }}
101 #search-box {{
102 flex: 1;
103 max-width: 400px;
104 background: #0f3460;
105 border: 1px solid #4e9af1;
106 border-radius: 6px;
107 padding: 6px 12px;
108 color: #e0e0e0;
109 font-size: 0.9rem;
110 outline: none;
111 }}
112 #search-box::placeholder {{ color: #5a6a8a; }}
113 #search-box:focus {{ border-color: #6cb4f5; box-shadow: 0 0 0 2px rgba(78,154,241,0.2); }}
114 #stats-bar {{
115 font-size: 0.75rem;
116 color: #7a8aaa;
117 white-space: nowrap;
118 }}
119 #main {{
120 display: flex;
121 flex: 1;
122 overflow: hidden;
123 }}
124 #canvas-wrap {{
125 flex: 1;
126 position: relative;
127 overflow: hidden;
128 }}
129 #graph-canvas {{
130 display: block;
131 width: 100%;
132 height: 100%;
133 cursor: grab;
134 }}
135 #graph-canvas:active {{ cursor: grabbing; }}
136 #sidebar {{
137 width: 300px;
138 background: #16213e;
139 border-left: 1px solid #0f3460;
140 display: flex;
141 flex-direction: column;
142 flex-shrink: 0;
143 overflow: hidden;
144 }}
145 #sidebar-title {{
146 padding: 10px 14px;
147 font-size: 0.8rem;
148 color: #7a8aaa;
149 border-bottom: 1px solid #0f3460;
150 text-transform: uppercase;
151 letter-spacing: 0.08em;
152 }}
153 #search-results {{
154 border-bottom: 1px solid #0f3460;
155 max-height: 220px;
156 overflow-y: auto;
157 }}
158 .search-result {{
159 padding: 8px 14px;
160 cursor: pointer;
161 border-bottom: 1px solid #0d1b35;
162 transition: background 0.15s;
163 }}
164 .search-result:hover {{ background: #0f3460; }}
165 .search-result .sr-name {{ font-size: 0.85rem; font-weight: 600; color: #c8d8f0; }}
166 .search-result .sr-meta {{ font-size: 0.72rem; color: #5a6a8a; margin-top: 2px; }}
167 #detail-panel {{
168 flex: 1;
169 padding: 14px;
170 overflow-y: auto;
171 font-size: 0.82rem;
172 }}
173 #detail-panel h2 {{
174 font-size: 1rem;
175 color: #4e9af1;
176 margin-bottom: 8px;
177 word-break: break-all;
178 }}
179 .detail-label {{
180 color: #5a6a8a;
181 font-size: 0.72rem;
182 text-transform: uppercase;
183 letter-spacing: 0.06em;
184 margin-top: 10px;
185 margin-bottom: 3px;
186 }}
187 .detail-value {{
188 color: #c8d8f0;
189 word-break: break-word;
190 }}
191 .badge {{
192 display: inline-block;
193 padding: 2px 7px;
194 border-radius: 10px;
195 font-size: 0.7rem;
196 font-weight: 600;
197 margin-right: 4px;
198 margin-bottom: 4px;
199 }}
200 .neighbor-item {{
201 background: #0f3460;
202 border-radius: 4px;
203 padding: 4px 8px;
204 margin: 3px 0;
205 cursor: pointer;
206 transition: background 0.15s;
207 display: flex;
208 justify-content: space-between;
209 align-items: center;
210 }}
211 .neighbor-item:hover {{ background: #1a4a80; }}
212 .neighbor-name {{ color: #c8d8f0; font-size: 0.8rem; }}
213 .neighbor-type {{ color: #5a6a8a; font-size: 0.7rem; }}
214 #empty-hint {{
215 color: #3a4a6a;
216 text-align: center;
217 margin-top: 40px;
218 font-size: 0.85rem;
219 line-height: 1.6;
220 }}
221 #loading {{
222 position: absolute;
223 top: 50%;
224 left: 50%;
225 transform: translate(-50%, -50%);
226 color: #4e9af1;
227 font-size: 0.9rem;
228 pointer-events: none;
229 }}
230 ::-webkit-scrollbar {{ width: 5px; }}
231 ::-webkit-scrollbar-track {{ background: #0d1b35; }}
232 ::-webkit-scrollbar-thumb {{ background: #0f3460; border-radius: 3px; }}
233 </style>
234 </head>
235 <body>
236 <div id="header">
237 <h1>navegador</h1>
238 <input id="search-box" type="text" placeholder="Search nodes..." autocomplete="off">
239 <div id="stats-bar">Loading…</div>
240 </div>
241 <div id="main">
242 <div id="canvas-wrap">
243 <canvas id="graph-canvas"></canvas>
244 <div id="loading">Loading graph…</div>
245 </div>
246 <div id="sidebar">
247 <div id="sidebar-title">Explorer</div>
248 <div id="search-results"></div>
249 <div id="detail-panel">
250 <div id="empty-hint">Click a node<br>or search above<br>to see details.</div>
251 </div>
252 </div>
253 </div>
254 <script>
255 (function() {
256 'use strict';
257
258 // ── Colour palette ─────────────────────────────────────────────────────────
259 const NODE_COLORS = {{
260 {colors}
261 }};
262 function nodeColor(label) {{
263 return NODE_COLORS[label] || NODE_COLORS['default'];
264 }}
265
266 // ── State ──────────────────────────────────────────────────────────────────
267 let nodes = []; // {{id, label, name, x, y, vx, vy, ...props}}
268 let edges = []; // {{source_id, target_id, type}}
269 let nodeById = {{}}; // id → node
270
271 let selectedNode = null;
272 let hoveredNode = null;
273
274 // Camera
275 let camX = 0, camY = 0, camScale = 1;
276 let isDragging = false, dragStartX = 0, dragStartY = 0, camStartX = 0, camStartY = 0;
277 let isDraggingNode = false, dragNode = null, dragNodeOffX = 0, dragNodeOffY = 0;
278
279 // Physics
280 let physicsRunning = true;
281 const REPEL = 8000;
282 const ATTRACT = 0.04;
283 const EDGE_LEN = 120;
284 const DAMPING = 0.85;
285 const MAX_VEL = 12;
286
287 // ── Canvas setup ────────────────────────────────────────────────────────────
288 const canvas = document.getElementById('graph-canvas');
289 const ctx = canvas.getContext('2d');
290 const wrap = document.getElementById('canvas-wrap');
291 const loading = document.getElementById('loading');
292
293 function resize() {{
294 canvas.width = wrap.clientWidth;
295 canvas.height = wrap.clientHeight;
296 }}
297 window.addEventListener('resize', resize);
298 resize();
299
300 // ── Fetch graph data ────────────────────────────────────────────────────────
301 async function loadGraph() {{
302 try {{
303 const data = await fetch('/api/graph').then(r => r.json());
304 initGraph(data.nodes || [], data.edges || []);
305 loading.style.display = 'none';
306 loadStats();
307 }} catch(e) {{
308 loading.textContent = 'Error loading graph.';
309 }}
310 }}
311
312 function initGraph(rawNodes, rawEdges) {{
313 const cx = canvas.width / 2, cy = canvas.height / 2;
314 nodeById = {{}};
315 nodes = rawNodes.map((n, i) => {{
316 const angle = (i / Math.max(rawNodes.length, 1)) * 2 * Math.PI;
317 const r = Math.min(cx, cy) * 0.6;
318 const node = {{
319 id: n.id,
320 label: n.label || 'default',
321 name: n.name || n.id,
322 x: cx + r * Math.cos(angle),
323 y: cy + r * Math.sin(angle),
324 vx: 0, vy: 0,
325 props: n.props || {{}},
326 }};
327 nodeById[n.id] = node;
328 return node;
329 }});
330 edges = rawEdges.map(e => ({{
331 source_id: e.source,
332 target_id: e.target,
333 type: e.type || '',
334 }}));
335 }}
336
337 async function loadStats() {{
338 try {{
339 const s = await fetch('/api/stats').then(r => r.json());
340 const bar = document.getElementById('stats-bar');
341 bar.textContent = `${{s.nodes}} nodes · ${{s.edges}} edges`;
342 }} catch(_) {{}}
343 }}
344
345 // ── Physics simulation ──────────────────────────────────────────────────────
346 function tick() {{
347 if (!physicsRunning || nodes.length === 0) return;
348
349 const cx = canvas.width / 2, cy = canvas.height / 2;
350
351 // Repulsion between all pairs (Barnes-Hut approximation skipped for simplicity)
352 for (let i = 0; i < nodes.length; i++) {{
353 const a = nodes[i];
354 for (let j = i + 1; j < nodes.length; j++) {{
355 const b = nodes[j];
356 let dx = b.x - a.x, dy = b.y - a.y;
357 const dist2 = dx*dx + dy*dy + 0.1;
358 const dist = Math.sqrt(dist2);
359 const force = REPEL / dist2;
360 const fx = (dx / dist) * force;
361 const fy = (dy / dist) * force;
362 a.vx -= fx; a.vy -= fy;
363 b.vx += fx; b.vy += fy;
364 }}
365 }}
366
367 // Edge spring attraction
368 for (const e of edges) {{
369 const a = nodeById[e.source_id], b = nodeById[e.target_id];
370 if (!a || !b) continue;
371 const dx = b.x - a.x, dy = b.y - a.y;
372 const dist = Math.sqrt(dx*dx + dy*dy) || 1;
373 const force = (dist - EDGE_LEN) * ATTRACT;
374 const fx = (dx / dist) * force;
375 const fy = (dy / dist) * force;
376 a.vx += fx; a.vy += fy;
377 b.vx -= fx; b.vy -= fy;
378 }}
379
380 // Weak centering pull
381 for (const n of nodes) {{
382 n.vx += (cx - n.x) * 0.0005;
383 n.vy += (cy - n.y) * 0.0005;
384 }}
385
386 // Integrate
387 for (const n of nodes) {{
388 if (n === dragNode) continue;
389 n.vx = Math.max(-MAX_VEL, Math.min(MAX_VEL, n.vx * DAMPING));
390 n.vy = Math.max(-MAX_VEL, Math.min(MAX_VEL, n.vy * DAMPING));
391 n.x += n.vx;
392 n.y += n.vy;
393 }}
394 }}
395
396 // ── Render ──────────────────────────────────────────────────────────────────
397 function draw() {{
398 const w = canvas.width, h = canvas.height;
399 ctx.clearRect(0, 0, w, h);
400 ctx.save();
401 ctx.translate(camX, camY);
402 ctx.scale(camScale, camScale);
403
404 // Edges
405 ctx.lineWidth = 1 / camScale;
406 for (const e of edges) {{
407 const a = nodeById[e.source_id], b = nodeById[e.target_id];
408 if (!a || !b) continue;
409 const isHighlighted = selectedNode && (a === selectedNode || b === selectedNode);
410 ctx.globalAlpha = isHighlighted ? 0.9 : 0.25;
411 ctx.strokeStyle = isHighlighted ? '#4e9af1' : '#3a5070';
412 ctx.beginPath();
413 ctx.moveTo(a.x, a.y);
414 ctx.lineTo(b.x, b.y);
415 ctx.stroke();
416
417 // Arrowhead
418 if (isHighlighted) {{
419 const dx = b.x - a.x, dy = b.y - a.y;
420 const dist = Math.sqrt(dx*dx+dy*dy) || 1;
421 const r = 8;
422 const tx = b.x - (dx/dist)*r, ty = b.y - (dy/dist)*r;
423 const angle = Math.atan2(dy, dx);
424 ctx.globalAlpha = 0.7;
425 ctx.fillStyle = '#4e9af1';
426 ctx.beginPath();
427 ctx.moveTo(tx, ty);
428 ctx.lineTo(tx - 8*Math.cos(angle-0.4), ty - 8*Math.sin(angle-0.4));
429 ctx.lineTo(tx - 8*Math.cos(angle+0.4), ty - 8*Math.sin(angle+0.4));
430 ctx.closePath();
431 ctx.fill();
432 }}
433 }}
434 ctx.globalAlpha = 1;
435
436 // Edge labels on highlighted edges
437 if (selectedNode) {{
438 ctx.font = `${{Math.max(9, 10/camScale)}}px sans-serif`;
439 ctx.fillStyle = '#5a8ab8';
440 for (const e of edges) {{
441 const a = nodeById[e.source_id], b = nodeById[e.target_id];
442 if (!a || !b) continue;
443 if (a !== selectedNode && b !== selectedNode) continue;
444 if (!e.type) continue;
445 const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
446 ctx.fillText(e.type, mx, my);
447 }}
448 }}
449
450 // Nodes
451 const nodeR = 8;
452 for (const n of nodes) {{
453 const isSelected = n === selectedNode;
454 const isHovered = n === hoveredNode;
455 const color = nodeColor(n.label);
456
457 ctx.beginPath();
458 ctx.arc(n.x, n.y, nodeR + (isSelected ? 3 : isHovered ? 1 : 0), 0, 2*Math.PI);
459 ctx.fillStyle = color;
460 ctx.globalAlpha = 0.9;
461 ctx.fill();
462 if (isSelected || isHovered) {{
463 ctx.strokeStyle = '#ffffff';
464 ctx.lineWidth = 2 / camScale;
465 ctx.stroke();
466 }}
467 ctx.globalAlpha = 1;
468
469 // Label
470 const labelThreshold = 0.4;
471 if (camScale > labelThreshold || isSelected || isHovered) {{
472 const fontSize = Math.max(8, 11 / camScale);
473 ctx.font = `${{isSelected ? 'bold ' : ''}}${{fontSize}}px sans-serif`;
474 ctx.fillStyle = '#e0e8ff';
475 ctx.globalAlpha = Math.min(1, (camScale - labelThreshold + 0.1) * 3);
476 if (isSelected || isHovered) ctx.globalAlpha = 1;
477 ctx.fillText(n.name, n.x + nodeR + 2, n.y + 4);
478 ctx.globalAlpha = 1;
479 }}
480 }}
481
482 ctx.restore();
483 }}
484
485 function loop() {{
486 tick();
487 draw();
488 requestAnimationFrame(loop);
489 }}
490
491 // ── Hit testing ─────────────────────────────────────────────────────────────
492 function screenToWorld(sx, sy) {{
493 return {{ x: (sx - camX) / camScale, y: (sy - camY) / camScale }};
494 }}
495
496 function nodeAtScreen(sx, sy) {{
497 const w = screenToWorld(sx, sy);
498 const nodeR = 11;
499 for (let i = nodes.length - 1; i >= 0; i--) {{
500 const n = nodes[i];
501 const dx = n.x - w.x, dy = n.y - w.y;
502 if (dx*dx + dy*dy <= nodeR*nodeR) return n;
503 }}
504 return null;
505 }}
506
507 // ── Mouse / touch events ────────────────────────────────────────────────────
508 canvas.addEventListener('mousedown', e => {{
509 const hit = nodeAtScreen(e.offsetX, e.offsetY);
510 if (hit) {{
511 isDraggingNode = true;
512 dragNode = hit;
513 physicsRunning = true;
514 const w = screenToWorld(e.offsetX, e.offsetY);
515 dragNodeOffX = hit.x - w.x;
516 dragNodeOffY = hit.y - w.y;
517 }} else {{
518 isDragging = true;
519 dragStartX = e.offsetX; dragStartY = e.offsetY;
520 camStartX = camX; camStartY = camY;
521 }}
522 }});
523
524 canvas.addEventListener('mousemove', e => {{
525 if (isDraggingNode && dragNode) {{
526 const w = screenToWorld(e.offsetX, e.offsetY);
527 dragNode.x = w.x + dragNodeOffX;
528 dragNode.y = w.y + dragNodeOffY;
529 dragNode.vx = 0; dragNode.vy = 0;
530 }} else if (isDragging) {{
531 camX = camStartX + (e.offsetX - dragStartX);
532 camY = camStartY + (e.offsetY - dragStartY);
533 }} else {{
534 hoveredNode = nodeAtScreen(e.offsetX, e.offsetY);
535 canvas.style.cursor = hoveredNode ? 'pointer' : 'grab';
536 }}
537 }});
538
539 canvas.addEventListener('mouseup', e => {{
540 if (isDraggingNode && dragNode) {{
541 const wasDragged = Math.abs(dragNode.vx) < 0.5 && Math.abs(dragNode.vy) < 0.5;
542 if (wasDragged) selectNode(dragNode);
543 }} else if (!isDragging || (Math.abs(e.offsetX - dragStartX) < 4 && Math.abs(e.offsetY - dragStartY) < 4)) {{
544 const hit = nodeAtScreen(e.offsetX, e.offsetY);
545 if (hit) selectNode(hit);
546 }}
547 isDragging = false;
548 isDraggingNode = false;
549 dragNode = null;
550 }});
551
552 canvas.addEventListener('wheel', e => {{
553 e.preventDefault();
554 const factor = e.deltaY < 0 ? 1.1 : 0.9;
555 const mx = e.offsetX, my = e.offsetY;
556 camX = mx - (mx - camX) * factor;
557 camY = my - (my - camY) * factor;
558 camScale = Math.max(0.05, Math.min(10, camScale * factor));
559 }}, {{ passive: false }});
560
561 // ── Node selection ──────────────────────────────────────────────────────────
562 async function selectNode(node) {{
563 selectedNode = node;
564 try {{
565 const data = await fetch('/api/node/' + encodeURIComponent(node.name)).then(r => r.json());
566 renderDetail(data);
567 }} catch(e) {{
568 renderDetail({{ name: node.name, label: node.label, props: node.props, neighbors: [] }});
569 }}
570 }}
571
572 function renderDetail(data) {{
573 const panel = document.getElementById('detail-panel');
574 const label = data.label || '';
575 const color = nodeColor(label);
576 let html = `<h2>${{data.name}}</h2>`;
577 html += `<div class="detail-label">Type</div>`;
578 html += `<span class="badge" style="background:${{color}}22;color:${{color}};border:1px solid ${{color}}44">${{label}}</span>`;
579
580 const props = data.props || {{}};
581 const skip = new Set(['name']);
582 const order = ['file_path', 'line_start', 'line_end', 'signature', 'docstring',
583 'description', 'status', 'domain', 'rationale', 'url'];
584 const shown = new Set();
585
586 for (const key of order) {{
587 if (props[key] !== undefined && props[key] !== null && props[key] !== '') {{
588 html += `<div class="detail-label">${{key.replace(/_/g,' ')}}</div>`;
589 html += `<div class="detail-value">${{escHtml(String(props[key]))}}</div>`;
590 shown.add(key);
591 }}
592 }}
593 for (const [key, val] of Object.entries(props)) {{
594 if (skip.has(key) || shown.has(key) || val === null || val === undefined || val === '') continue;
595 html += `<div class="detail-label">${{key.replace(/_/g,' ')}}</div>`;
596 html += `<div class="detail-value">${{escHtml(String(val))}}</div>`;
597 }}
598
599 const neighbors = data.neighbors || [];
600 if (neighbors.length > 0) {{
601 html += `<div class="detail-label">Neighbors (${{neighbors.length}})</div>`;
602 for (con# Copyright CONFLICT LLC 2026 (weareconflict.com)
603 """
604 HTML template for the Navegador Graph Explorer.
605
606 A fully self-contained, single-file HTML page with:
607 - Force-directed graph visualisation via inline canvas + JS physics
608 - Search box that queries /api/search
609 - Node click → detail panel via /api/node/<name>
610 - Stats bar via /api/stats
611 - Zero external dependencies (no CDN, no frameworks)
612 """
613
614 # Colour palette per node label ─────────────────────────────────────────────
615 NODE_COLORS = {
616 "Function": "#4e9af1",
617 "Method": "#6cb4f5",
618 "Class": "#f4a93b",
619 "File": "#a8d9a7",
620 "Module": "#82c9a0",
621 "Repository": "#e67e22",
622 "Variable": "#c39bd3",
623 "Import": "#a9cce3",
624 "Decorator": "#f1948a",
625 "Domain": "#f7dc6f",
626 "Concept": "#f9e79f",
627 "Rule": "#f0b27a",
628 "Decision": "#f8c471",
629 "WikiPage": "#d2b4de",
630 "Person": "#fadbd8",
631 "default": "#aaaaaa",
632 }
633
634 _COLORS_JS = "\n".join(f" '{label}': '{color}'," for label, color in NODE_COLORS.items())
635
636 HTML_TEMPLATE = """<!DOCTYPE html>
637 <html lang="en">
638 <head>
639 <meta charset="UTF-8">
640 <meta name="viewport" content="width=device-width, initial-scale=1.0">
641 <title>Navegador Graph Explorer</title>
642 <style>
643 * {{ box-sizing: border-box; margin: 0; padding: 0; }}
644 body {{
645 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
646 background: #1a1a2e;
647 color: #e0e0e0;
648 height: 100vh;
649 display: flex;
650 flex-direction: column;
651 overflow: hidden;
652 }}
653 #header {{
654 background: #16213e;
655 border-bottom: 1px solid #0f3460;
656 padding: 10px 16px;
657 display: flex;
658 align-items: center;
659 gap: 12px;
660 flex-shrink:
--- pyproject.toml
+++ pyproject.toml
@@ -87,10 +87,14 @@
8787
target-version = "py312"
8888
8989
[tool.ruff.lint]
9090
select = ["E", "F", "W", "I"]
9191
92
+[tool.ruff.lint.per-file-ignores]
93
+# HTML/JS template strings inevitably contain long lines
94
+"navegador/explorer/templates.py" = ["E501"]
95
+
9296
[tool.mypy]
9397
python_version = "3.12"
9498
warn_return_any = true
9599
warn_unused_configs = true
96100
97101
98102
ADDED tests/test_explorer.py
--- pyproject.toml
+++ pyproject.toml
@@ -87,10 +87,14 @@
87 target-version = "py312"
88
89 [tool.ruff.lint]
90 select = ["E", "F", "W", "I"]
91
 
 
 
 
92 [tool.mypy]
93 python_version = "3.12"
94 warn_return_any = true
95 warn_unused_configs = true
96
97
98 DDED tests/test_explorer.py
--- pyproject.toml
+++ pyproject.toml
@@ -87,10 +87,14 @@
87 target-version = "py312"
88
89 [tool.ruff.lint]
90 select = ["E", "F", "W", "I"]
91
92 [tool.ruff.lint.per-file-ignores]
93 # HTML/JS template strings inevitably contain long lines
94 "navegador/explorer/templates.py" = ["E501"]
95
96 [tool.mypy]
97 python_version = "3.12"
98 warn_return_any = true
99 warn_unused_configs = true
100
101
102 DDED tests/test_explorer.py
--- a/tests/test_explorer.py
+++ b/tests/test_explorer.py
@@ -0,0 +1,551 @@
1
+# Copyright CONFLICT LLC 2026 (weareconflict.com)
2
+"""
3
+Tests for navegador.explorer — ExplorerServer, API endpoints, HTML template,
4
+and the CLI `explore` command.
5
+"""
6
+
7
+from __future__ import annotations
8
+
9
+import json
10
+import urllib.error
11
+import urllib.request
12
+from unittest.mock import MagicMock, patch
13
+
14
+import pytest
15
+from click.testing import CliRunner
16
+
17
+from navegador.cli.commands import main
18
+from navegador.explorer import ExplorerServer
19
+from navegador.explorer.templates import HTML_TEMPLATE
20
+
21
+# ── Helpers ────────────────────────────────────────────────────────────────
22
+
23
+
24
+def _mock_store(
25
+ *,
26
+ nodes: list | None = None,
27
+ edges: list | None = None,
28
+ node_count: int = 3,
29
+ edge_count: int = 2,
30
+):
31
+ """Return a minimal GraphStore mock suitable for explorer tests."""
32
+ store = MagicMock()
33
+ store.node_count.return_value = node_count
34
+ store.edge_count.return_value = edge_count
35
+
36
+ # Each query() call returns a result-set mock. We cycle through prebuilt
37
+ # responses so different Cypher patterns get appropriate data.
38
+ _node_rows = nodes or []
39
+ _edge_rows = edges or []
40
+
41
+ def _query_side_effect(cypher: str, params=None):
42
+ result = MagicMock()
43
+ cypher_lower = cypher.lower()
44
+ if "match (a)-[r]->(b)" in cypher_lower:
45
+ result.result_set = _edge_rows
46
+ elif "match (n)-[r]->(nb)" in cypher_lower or "match (nb)-[r]->(n)" in cypher_lower:
47
+ result.result_set = []
48
+ elif "match (n) where n.name" in cypher_lower and "properties" in cypher_lower:
49
+ # node detail: single node row
50
+ result.result_set = [["Function", {"name": "foo", "file_path": "app.py"}]]
51
+ elif "match (n)" in cypher_lower and "tolow" in cypher_lower:
52
+ result.result_set = [
53
+ ["Function", "foo", "app.py", ""],
54
+ ]
55
+ elif "labels(n)" in cypher_lower and "count" in cypher_lower:
56
+ result.result_set = [["Function", 2], ["Class", 1]]
57
+ elif "type(r)" in cypher_lower and "count" in cypher_lower:
58
+ result.result_set = [["CALLS", 2]]
59
+ else:
60
+ result.result_set = _node_rows
61
+ return result
62
+
63
+ store.query.side_effect = _query_side_effect
64
+ return store
65
+
66
+
67
+def _free_port() -> int:
68
+ """Return an available TCP port on localhost."""
69
+ import socket
70
+ with socket.socket() as s:
71
+ s.bind(("127.0.0.1", 0))
72
+ return s.getsockname()[1]
73
+
74
+
75
+def _fetch(url: str, timeout: float = 5.0) -> tuple[int, str]:
76
+ """GET *url* and return (status_code, response_body_str)."""
77
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
78
+ return resp.status, resp.read().decode()
79
+
80
+
81
+def _fetch_json(url: str, timeout: float = 5.0) -> tuple[int, dict | list]:
82
+ status, body = _fetch(url, timeout)
83
+ return status, json.loads(body)
84
+
85
+
86
+# ── ExplorerServer creation ────────────────────────────────────────────────
87
+
88
+
89
+class TestExplorerServerCreation:
90
+ def test_default_host_and_port(self):
91
+ store = _mock_store()
92
+ server = ExplorerServer(store)
93
+ assert server.host == "127.0.0.1"
94
+ assert server.port == 8080
95
+ assert server.store is store
96
+
97
+ def test_custom_host_and_port(self):
98
+ store = _mock_store()
99
+ server = ExplorerServer(store, host="0.0.0.0", port=9999)
100
+ assert server.host == "0.0.0.0"
101
+ assert server.port == 9999
102
+
103
+ def test_url_property(self):
104
+ server = ExplorerServer(_mock_store(), host="127.0.0.1", port=8080)
105
+ assert server.url == "http://127.0.0.1:8080"
106
+
107
+ def test_not_running_by_default(self):
108
+ server = ExplorerServer(_mock_store(), port=_free_port())
109
+ assert server._server is None
110
+ assert server._thread is None
111
+
112
+ def test_double_start_raises(self):
113
+ port = _free_port()
114
+ server = ExplorerServer(_mock_store(), port=port)
115
+ server.start()
116
+ try:
117
+ with pytest.raises(RuntimeError, match="already running"):
118
+ server.start()
119
+ finally:
120
+ server.stop()
121
+
122
+ def test_stop_when_not_started_is_noop(self):
123
+ server = ExplorerServer(_mock_store(), port=_free_port())
124
+ server.stop() # should not raise
125
+
126
+ def test_context_manager(self):
127
+ port = _free_port()
128
+ store = _mock_store()
129
+ with ExplorerServer(store, port=port) as srv:
130
+ assert srv._server is not None
131
+ assert srv._server is None
132
+
133
+
134
+# ── Start / stop lifecycle ─────────────────────────────────────────────────
135
+
136
+
137
+class TestExplorerServerLifecycle:
138
+ def test_start_makes_server_accessible(self):
139
+ port = _free_port()
140
+ server = ExplorerServer(_mock_store(), port=port)
141
+ server.start()
142
+ try:
143
+ status, _ = _fetch(f"http://127.0.0.1:{port}/")
144
+ assert status == 200
145
+ finally:
146
+ server.stop()
147
+
148
+ def test_stop_takes_server_offline(self):
149
+ port = _free_port()
150
+ server = ExplorerServer(_mock_store(), port=port)
151
+ server.start()
152
+ server.stop()
153
+ with pytest.raises(Exception):
154
+ _fetch(f"http://127.0.0.1:{port}/", timeout=1.0)
155
+
156
+ def test_thread_is_daemon(self):
157
+ port = _free_port()
158
+ server = ExplorerServer(_mock_store(), port=port)
159
+ server.start()
160
+ try:
161
+ assert server._thread is not None
162
+ assert server._thread.daemon is True
163
+ finally:
164
+ server.stop()
165
+
166
+
167
+# ── API endpoint: GET / ────────────────────────────────────────────────────
168
+
169
+
170
+class TestRootEndpoint:
171
+ def test_returns_html(self):
172
+ port = _free_port()
173
+ with ExplorerServer(_mock_store(), port=port):
174
+ status, body = _fetch(f"http://127.0.0.1:{port}/")
175
+ assert status == 200
176
+ assert "<!DOCTYPE html>" in body or "<!doctype html>" in body.lower()
177
+
178
+ def test_html_contains_canvas(self):
179
+ port = _free_port()
180
+ with ExplorerServer(_mock_store(), port=port):
181
+ _, body = _fetch(f"http://127.0.0.1:{port}/")
182
+ assert "graph-canvas" in body
183
+
184
+ def test_html_contains_search_box(self):
185
+ port = _free_port()
186
+ with ExplorerServer(_mock_store(), port=port):
187
+ _, body = _fetch(f"http://127.0.0.1:{port}/")
188
+ assert "search-box" in body
189
+
190
+ def test_html_contains_api_calls(self):
191
+ port = _free_port()
192
+ with ExplorerServer(_mock_store(), port=port):
193
+ _, body = _fetch(f"http://127.0.0.1:{port}/")
194
+ assert "/api/graph" in body
195
+
196
+
197
+# ── API endpoint: GET /api/graph ──────────────────────────────────────────
198
+
199
+
200
+class TestGraphEndpoint:
201
+ def _make_node_rows(self):
202
+ # Rows returned for the full-node Cypher query
203
+ return [
204
+ [1, "Function", "foo", {"name": "foo", "file_path": "app.py"}],
205
+ [2, "Class", "Bar", {"name": "Bar", "file_path": "app.py"}],
206
+ ]
207
+
208
+ def test_returns_nodes_and_edges_keys(self):
209
+ port = _free_port()
210
+ store = _mock_store(nodes=self._make_node_rows(), edges=[])
211
+ with ExplorerServer(store, port=port):
212
+ status, data = _fetch_json(f"http://127.0.0.1:{port}/api/graph")
213
+ assert status == 200
214
+ assert "nodes" in data
215
+ assert "edges" in data
216
+
217
+ def test_nodes_have_required_fields(self):
218
+ port = _free_port()
219
+ store = _mock_store(nodes=self._make_node_rows())
220
+ with ExplorerServer(store, port=port):
221
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/graph")
222
+ for node in data["nodes"]:
223
+ assert "id" in node
224
+ assert "label" in node
225
+ assert "name" in node
226
+
227
+ def test_empty_graph(self):
228
+ port = _free_port()
229
+ store = _mock_store(nodes=[], edges=[], node_count=0, edge_count=0)
230
+ with ExplorerServer(store, port=port):
231
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/graph")
232
+ assert data["nodes"] == []
233
+ assert data["edges"] == []
234
+
235
+ def test_edges_have_required_fields(self):
236
+ port = _free_port()
237
+ edge_rows = [[1, 2, "CALLS"]]
238
+ store = _mock_store(nodes=self._make_node_rows(), edges=edge_rows)
239
+ with ExplorerServer(store, port=port):
240
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/graph")
241
+ for edge in data["edges"]:
242
+ assert "source" in edge
243
+ assert "target" in edge
244
+ assert "type" in edge
245
+
246
+
247
+# ── API endpoint: GET /api/search ─────────────────────────────────────────
248
+
249
+
250
+class TestSearchEndpoint:
251
+ def test_returns_nodes_key(self):
252
+ port = _free_port()
253
+ with ExplorerServer(_mock_store(), port=port):
254
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/search?q=foo")
255
+ assert "nodes" in data
256
+
257
+ def test_empty_query_returns_empty(self):
258
+ port = _free_port()
259
+ with ExplorerServer(_mock_store(), port=port):
260
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/search?q=")
261
+ assert data["nodes"] == []
262
+
263
+ def test_missing_q_returns_empty(self):
264
+ port = _free_port()
265
+ with ExplorerServer(_mock_store(), port=port):
266
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/search")
267
+ assert data["nodes"] == []
268
+
269
+ def test_result_nodes_have_name_and_label(self):
270
+ port = _free_port()
271
+ with ExplorerServer(_mock_store(), port=port):
272
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/search?q=foo")
273
+ for node in data["nodes"]:
274
+ assert "name" in node
275
+ assert "label" in node
276
+
277
+
278
+# ── API endpoint: GET /api/node/<name> ────────────────────────────────────
279
+
280
+
281
+class TestNodeDetailEndpoint:
282
+ def test_returns_name_label_props_neighbors(self):
283
+ port = _free_port()
284
+ with ExplorerServer(_mock_store(), port=port):
285
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/node/foo")
286
+ assert "name" in data
287
+ assert "label" in data
288
+ assert "props" in data
289
+ assert "neighbors" in data
290
+
291
+ def test_name_matches_request(self):
292
+ port = _free_port()
293
+ with ExplorerServer(_mock_store(), port=port):
294
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/node/foo")
295
+ assert data["name"] == "foo"
296
+
297
+ def test_url_encoded_name(self):
298
+ port = _free_port()
299
+ with ExplorerServer(_mock_store(), port=port):
300
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/node/my%20node")
301
+ assert data["name"] == "my node"
302
+
303
+ def test_unknown_node_returns_empty_detail(self):
304
+ port = _free_port()
305
+ store = _mock_store()
306
+ # Override query to return empty for the node-detail lookup
307
+ original_side_effect = store.query.side_effect
308
+
309
+ def _empty_node(cypher, params=None):
310
+ if "where n.name" in cypher.lower() and "properties" in cypher.lower():
311
+ r = MagicMock()
312
+ r.result_set = []
313
+ return r
314
+ return original_side_effect(cypher, params)
315
+
316
+ store.query.side_effect = _empty_node
317
+ with ExplorerServer(store, port=port):
318
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/node/nonexistent")
319
+ assert data["neighbors"] == []
320
+
321
+
322
+# ── API endpoint: GET /api/stats ──────────────────────────────────────────
323
+
324
+
325
+class TestStatsEndpoint:
326
+ def test_returns_nodes_and_edges_counts(self):
327
+ port = _free_port()
328
+ with ExplorerServer(_mock_store(node_count=5, edge_count=3), port=port):
329
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/stats")
330
+ assert data["nodes"] == 5
331
+ assert data["edges"] == 3
332
+
333
+ def test_returns_node_types_and_edge_types(self):
334
+ port = _free_port()
335
+ with ExplorerServer(_mock_store(), port=port):
336
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/stats")
337
+ assert "node_types" in data
338
+ assert "edge_types" in data
339
+ assert isinstance(data["node_types"], dict)
340
+ assert isinstance(data["edge_types"], dict)
341
+
342
+ def test_node_type_counts_sum(self):
343
+ port = _free_port()
344
+ with ExplorerServer(_mock_store(node_count=3), port=port):
345
+ _, data = _fetch_json(f"http://127.0.0.1:{port}/api/stats")
346
+ total = sum(data["node_types"].values())
347
+ # The mock returns Function:2, Class:1 → total 3
348
+ assert total == 3
349
+
350
+
351
+# ── 404 for unknown routes ─────────────────────────────────────────────────
352
+
353
+
354
+class TestNotFound:
355
+ def test_unknown_path_returns_404(self):
356
+ port = _free_port()
357
+ with ExplorerServer(_mock_store(), port=port):
358
+ with pytest.raises(urllib.error.HTTPError) as exc_info:
359
+ urllib.request.urlopen(f"http://127.0.0.1:{port}/api/nonexistent")
360
+ assert exc_info.value.code == 404
361
+
362
+
363
+# ── HTML template ─────────────────────────────────────────────────────────
364
+
365
+
366
+class TestHtmlTemplate:
367
+ def test_is_string(self):
368
+ assert isinstance(HTML_TEMPLATE, str)
369
+
370
+ def test_contains_doctype(self):
371
+ assert "<!DOCTYPE html>" in HTML_TEMPLATE
372
+
373
+ def test_contains_canvas(self):
374
+ assert "graph-canvas" in HTML_TEMPLATE
375
+
376
+ def test_contains_search_box(self):
377
+ assert "search-box" in HTML_TEMPLATE
378
+
379
+ def test_contains_detail_panel(self):
380
+ assert "detail-panel" in HTML_TEMPLATE
381
+
382
+ def test_contains_api_graph_fetch(self):
383
+ assert "/api/graph" in HTML_TEMPLATE
384
+
385
+ def test_contains_api_search_fetch(self):
386
+ assert "/api/search" in HTML_TEMPLATE
387
+
388
+ def test_contains_api_node_fetch(self):
389
+ assert "/api/node/" in HTML_TEMPLATE
390
+
391
+ def test_contains_api_stats_fetch(self):
392
+ assert "/api/stats" in HTML_TEMPLATE
393
+
394
+ def test_no_external_deps(self):
395
+ """No CDN or external URLs should appear in the template."""
396
+ import re
397
+ # Look for any http(s):// URLs — internal /api/ paths are fine
398
+ external = re.findall(r'https?://\S+', HTML_TEMPLATE)
399
+ assert external == [], f"External URLs found: {external}"
400
+
401
+ def test_contains_force_directed_physics(self):
402
+ lower = HTML_TEMPLATE.lower()
403
+ assert "REPEL" in HTML_TEMPLATE or "repulsion" in lower or "force" in lower
404
+
405
+ def test_colors_injected(self):
406
+ assert "Function" in HTML_TEMPLATE
407
+ assert "Class" in HTML_TEMPLATE
408
+
409
+ def test_self_contained_script_tag(self):
410
+ assert "<script>" in HTML_TEMPLATE
411
+
412
+ def test_self_contained_style_tag(self):
413
+ assert "<style>" in HTML_TEMPLATE
414
+
415
+
416
+# ── CLI command: navegador explore ────────────────────────────────────────
417
+
418
+
419
+class TestExploreCLI:
420
+ def test_help_text(self):
421
+ runner = CliRunner()
422
+ result = runner.invoke(main, ["explore", "--help"])
423
+ assert result.exit_code == 0
424
+ assert "explore" in result.output.lower() or "graph" in result.output.lower()
425
+
426
+ def test_explore_command_registered(self):
427
+ """Verify the explore command is registered under the main group."""
428
+ from navegador.cli.commands import main as cli_main
429
+ assert "explore" in cli_main.commands
430
+
431
+ def test_explore_starts_and_stops(self):
432
+ """CLI explore should start ExplorerServer and stop cleanly on KeyboardInterrupt."""
433
+ runner = CliRunner()
434
+ port = _free_port()
435
+
436
+ mock_srv = MagicMock()
437
+ mock_srv.url = f"http://127.0.0.1:{port}"
438
+
439
+ call_count = [0]
440
+
441
+ def _fake_sleep(seconds):
442
+ # Let the first call (browser delay) pass, raise on second (main loop)
443
+ call_count[0] += 1
444
+ if call_count[0] >= 2:
445
+ raise KeyboardInterrupt
446
+
447
+ # The explore command does local imports, so patch at the source modules.
448
+ with (
449
+ patch("navegador.explorer.ExplorerServer", return_value=mock_srv),
450
+ patch("navegador.cli.commands._get_store", return_value=MagicMock()),
451
+ patch("time.sleep", side_effect=_fake_sleep),
452
+ patch("webbrowser.open"),
453
+ ):
454
+ result = runner.invoke(main, ["explore", "--port", str(port)])
455
+
456
+ mock_srv.start.assert_called_once()
457
+ mock_srv.stop.assert_called_once()
458
+ assert result.exit_code == 0
459
+
460
+ def test_explore_no_browser_flag(self):
461
+ """--no-browser should skip webbrowser.open."""
462
+ runner = CliRunner()
463
+ port = _free_port()
464
+
465
+ mock_srv = MagicMock()
466
+ mock_srv.url = f"http://127.0.0.1:{port}"
467
+
468
+ def _fake_sleep(seconds):
469
+ raise KeyboardInterrupt
470
+
471
+ with (
472
+ patch("navegador.explorer.ExplorerServer", return_value=mock_srv),
473
+ patch("navegador.cli.commands._get_store", return_value=MagicMock()),
474
+ patch("time.sleep", side_effect=_fake_sleep),
475
+ patch("webbrowser.open") as mock_open,
476
+ ):
477
+ result = runner.invoke(main, ["explore", "--no-browser", "--port", str(port)])
478
+
479
+ mock_open.assert_not_called()
480
+ assert result.exit_code == 0
481
+
482
+ def test_explore_custom_port(self):
483
+ """--port option should be forwarded to ExplorerServer."""
484
+ runner = CliRunner()
485
+ port = _free_port()
486
+
487
+ captured = {}
488
+
489
+ def _fake_server(store, host, port): # noqa: A002
490
+ captured["port"] = port
491
+ srv = MagicMock()
492
+ srv.url = f"http://{host}:{port}"
493
+ return srv
494
+
495
+ def _fake_sleep(seconds):
496
+ raise KeyboardInterrupt
497
+
498
+ with (
499
+ patch("navegador.explorer.ExplorerServer", side_effect=_fake_server),
500
+ patch("navegador.cli.commands._get_store", return_value=MagicMock()),
501
+ patch("time.sleep", side_effect=_fake_sleep),
502
+ patch("webbrowser.open"),
503
+ ):
504
+ runner.invoke(main, ["explore", "--port", str(port)])
505
+
506
+ assert captured.get("port") == port
507
+
508
+ def test_explore_custom_host(self):
509
+ """--host option should be forwarded to ExplorerServer."""
510
+ runner = CliRunner()
511
+ captured = {}
512
+
513
+ def _fake_server(store, host, port): # noqa: A002
514
+ captured["host"] = host
515
+ srv = MagicMock()
516
+ srv.url = f"http://{host}:{port}"
517
+ return srv
518
+
519
+ def _fake_sleep(seconds):
520
+ raise KeyboardInterrupt
521
+
522
+ with (
523
+ patch("navegador.explorer.ExplorerServer", side_effect=_fake_server),
524
+ patch("navegador.cli.commands._get_store", return_value=MagicMock()),
525
+ patch("time.sleep", side_effect=_fake_sleep),
526
+ patch("webbrowser.open"),
527
+ ):
528
+ runner.invoke(main, ["explore", "--host", "0.0.0.0"])
529
+
530
+ assert captured.get("host") == "0.0.0.0"
531
+
532
+ def test_explore_output_shows_url(self):
533
+ """explore should print the server URL to stdout."""
534
+ runner = CliRunner()
535
+ port = _free_port()
536
+
537
+ mock_srv = MagicMock()
538
+ mock_srv.url = f"http://127.0.0.1:{port}"
539
+
540
+ def _fake_sleep(seconds):
541
+ raise KeyboardInterrupt
542
+
543
+ with (
544
+ patch("navegador.explorer.ExplorerServer", return_value=mock_srv),
545
+ patch("navegador.cli.commands._get_store", return_value=MagicMock()),
546
+ patch("time.sleep", side_effect=_fake_sleep),
547
+ patch("webbrowser.open"),
548
+ ):
549
+ result = runner.invoke(main, ["explore", "--port", str(port)])
550
+
551
+ assert str(port) in result.output or "127.0.0.1" in result.output
--- a/tests/test_explorer.py
+++ b/tests/test_explorer.py
@@ -0,0 +1,551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_explorer.py
+++ b/tests/test_explorer.py
@@ -0,0 +1,551 @@
1 # Copyright CONFLICT LLC 2026 (weareconflict.com)
2 """
3 Tests for navegador.explorer — ExplorerServer, API endpoints, HTML template,
4 and the CLI `explore` command.
5 """
6
7 from __future__ import annotations
8
9 import json
10 import urllib.error
11 import urllib.request
12 from unittest.mock import MagicMock, patch
13
14 import pytest
15 from click.testing import CliRunner
16
17 from navegador.cli.commands import main
18 from navegador.explorer import ExplorerServer
19 from navegador.explorer.templates import HTML_TEMPLATE
20
21 # ── Helpers ────────────────────────────────────────────────────────────────
22
23
24 def _mock_store(
25 *,
26 nodes: list | None = None,
27 edges: list | None = None,
28 node_count: int = 3,
29 edge_count: int = 2,
30 ):
31 """Return a minimal GraphStore mock suitable for explorer tests."""
32 store = MagicMock()
33 store.node_count.return_value = node_count
34 store.edge_count.return_value = edge_count
35
36 # Each query() call returns a result-set mock. We cycle through prebuilt
37 # responses so different Cypher patterns get appropriate data.
38 _node_rows = nodes or []
39 _edge_rows = edges or []
40
41 def _query_side_effect(cypher: str, params=None):
42 result = MagicMock()
43 cypher_lower = cypher.lower()
44 if "match (a)-[r]->(b)" in cypher_lower:
45 result.result_set = _edge_rows
46 elif "match (n)-[r]->(nb)" in cypher_lower or "match (nb)-[r]->(n)" in cypher_lower:
47 result.result_set = []
48 elif "match (n) where n.name" in cypher_lower and "properties" in cypher_lower:
49 # node detail: single node row
50 result.result_set = [["Function", {"name": "foo", "file_path": "app.py"}]]
51 elif "match (n)" in cypher_lower and "tolow" in cypher_lower:
52 result.result_set = [
53 ["Function", "foo", "app.py", ""],
54 ]
55 elif "labels(n)" in cypher_lower and "count" in cypher_lower:
56 result.result_set = [["Function", 2], ["Class", 1]]
57 elif "type(r)" in cypher_lower and "count" in cypher_lower:
58 result.result_set = [["CALLS", 2]]
59 else:
60 result.result_set = _node_rows
61 return result
62
63 store.query.side_effect = _query_side_effect
64 return store
65
66
67 def _free_port() -> int:
68 """Return an available TCP port on localhost."""
69 import socket
70 with socket.socket() as s:
71 s.bind(("127.0.0.1", 0))
72 return s.getsockname()[1]
73
74
75 def _fetch(url: str, timeout: float = 5.0) -> tuple[int, str]:
76 """GET *url* and return (status_code, response_body_str)."""
77 with urllib.request.urlopen(url, timeout=timeout) as resp:
78 return resp.status, resp.read().decode()
79
80
81 def _fetch_json(url: str, timeout: float = 5.0) -> tuple[int, dict | list]:
82 status, body = _fetch(url, timeout)
83 return status, json.loads(body)
84
85
86 # ── ExplorerServer creation ────────────────────────────────────────────────
87
88
89 class TestExplorerServerCreation:
90 def test_default_host_and_port(self):
91 store = _mock_store()
92 server = ExplorerServer(store)
93 assert server.host == "127.0.0.1"
94 assert server.port == 8080
95 assert server.store is store
96
97 def test_custom_host_and_port(self):
98 store = _mock_store()
99 server = ExplorerServer(store, host="0.0.0.0", port=9999)
100 assert server.host == "0.0.0.0"
101 assert server.port == 9999
102
103 def test_url_property(self):
104 server = ExplorerServer(_mock_store(), host="127.0.0.1", port=8080)
105 assert server.url == "http://127.0.0.1:8080"
106
107 def test_not_running_by_default(self):
108 server = ExplorerServer(_mock_store(), port=_free_port())
109 assert server._server is None
110 assert server._thread is None
111
112 def test_double_start_raises(self):
113 port = _free_port()
114 server = ExplorerServer(_mock_store(), port=port)
115 server.start()
116 try:
117 with pytest.raises(RuntimeError, match="already running"):
118 server.start()
119 finally:
120 server.stop()
121
122 def test_stop_when_not_started_is_noop(self):
123 server = ExplorerServer(_mock_store(), port=_free_port())
124 server.stop() # should not raise
125
126 def test_context_manager(self):
127 port = _free_port()
128 store = _mock_store()
129 with ExplorerServer(store, port=port) as srv:
130 assert srv._server is not None
131 assert srv._server is None
132
133
134 # ── Start / stop lifecycle ─────────────────────────────────────────────────
135
136
137 class TestExplorerServerLifecycle:
138 def test_start_makes_server_accessible(self):
139 port = _free_port()
140 server = ExplorerServer(_mock_store(), port=port)
141 server.start()
142 try:
143 status, _ = _fetch(f"http://127.0.0.1:{port}/")
144 assert status == 200
145 finally:
146 server.stop()
147
148 def test_stop_takes_server_offline(self):
149 port = _free_port()
150 server = ExplorerServer(_mock_store(), port=port)
151 server.start()
152 server.stop()
153 with pytest.raises(Exception):
154 _fetch(f"http://127.0.0.1:{port}/", timeout=1.0)
155
156 def test_thread_is_daemon(self):
157 port = _free_port()
158 server = ExplorerServer(_mock_store(), port=port)
159 server.start()
160 try:
161 assert server._thread is not None
162 assert server._thread.daemon is True
163 finally:
164 server.stop()
165
166
167 # ── API endpoint: GET / ────────────────────────────────────────────────────
168
169
170 class TestRootEndpoint:
171 def test_returns_html(self):
172 port = _free_port()
173 with ExplorerServer(_mock_store(), port=port):
174 status, body = _fetch(f"http://127.0.0.1:{port}/")
175 assert status == 200
176 assert "<!DOCTYPE html>" in body or "<!doctype html>" in body.lower()
177
178 def test_html_contains_canvas(self):
179 port = _free_port()
180 with ExplorerServer(_mock_store(), port=port):
181 _, body = _fetch(f"http://127.0.0.1:{port}/")
182 assert "graph-canvas" in body
183
184 def test_html_contains_search_box(self):
185 port = _free_port()
186 with ExplorerServer(_mock_store(), port=port):
187 _, body = _fetch(f"http://127.0.0.1:{port}/")
188 assert "search-box" in body
189
190 def test_html_contains_api_calls(self):
191 port = _free_port()
192 with ExplorerServer(_mock_store(), port=port):
193 _, body = _fetch(f"http://127.0.0.1:{port}/")
194 assert "/api/graph" in body
195
196
197 # ── API endpoint: GET /api/graph ──────────────────────────────────────────
198
199
200 class TestGraphEndpoint:
201 def _make_node_rows(self):
202 # Rows returned for the full-node Cypher query
203 return [
204 [1, "Function", "foo", {"name": "foo", "file_path": "app.py"}],
205 [2, "Class", "Bar", {"name": "Bar", "file_path": "app.py"}],
206 ]
207
208 def test_returns_nodes_and_edges_keys(self):
209 port = _free_port()
210 store = _mock_store(nodes=self._make_node_rows(), edges=[])
211 with ExplorerServer(store, port=port):
212 status, data = _fetch_json(f"http://127.0.0.1:{port}/api/graph")
213 assert status == 200
214 assert "nodes" in data
215 assert "edges" in data
216
217 def test_nodes_have_required_fields(self):
218 port = _free_port()
219 store = _mock_store(nodes=self._make_node_rows())
220 with ExplorerServer(store, port=port):
221 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/graph")
222 for node in data["nodes"]:
223 assert "id" in node
224 assert "label" in node
225 assert "name" in node
226
227 def test_empty_graph(self):
228 port = _free_port()
229 store = _mock_store(nodes=[], edges=[], node_count=0, edge_count=0)
230 with ExplorerServer(store, port=port):
231 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/graph")
232 assert data["nodes"] == []
233 assert data["edges"] == []
234
235 def test_edges_have_required_fields(self):
236 port = _free_port()
237 edge_rows = [[1, 2, "CALLS"]]
238 store = _mock_store(nodes=self._make_node_rows(), edges=edge_rows)
239 with ExplorerServer(store, port=port):
240 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/graph")
241 for edge in data["edges"]:
242 assert "source" in edge
243 assert "target" in edge
244 assert "type" in edge
245
246
247 # ── API endpoint: GET /api/search ─────────────────────────────────────────
248
249
250 class TestSearchEndpoint:
251 def test_returns_nodes_key(self):
252 port = _free_port()
253 with ExplorerServer(_mock_store(), port=port):
254 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/search?q=foo")
255 assert "nodes" in data
256
257 def test_empty_query_returns_empty(self):
258 port = _free_port()
259 with ExplorerServer(_mock_store(), port=port):
260 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/search?q=")
261 assert data["nodes"] == []
262
263 def test_missing_q_returns_empty(self):
264 port = _free_port()
265 with ExplorerServer(_mock_store(), port=port):
266 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/search")
267 assert data["nodes"] == []
268
269 def test_result_nodes_have_name_and_label(self):
270 port = _free_port()
271 with ExplorerServer(_mock_store(), port=port):
272 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/search?q=foo")
273 for node in data["nodes"]:
274 assert "name" in node
275 assert "label" in node
276
277
278 # ── API endpoint: GET /api/node/<name> ────────────────────────────────────
279
280
281 class TestNodeDetailEndpoint:
282 def test_returns_name_label_props_neighbors(self):
283 port = _free_port()
284 with ExplorerServer(_mock_store(), port=port):
285 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/node/foo")
286 assert "name" in data
287 assert "label" in data
288 assert "props" in data
289 assert "neighbors" in data
290
291 def test_name_matches_request(self):
292 port = _free_port()
293 with ExplorerServer(_mock_store(), port=port):
294 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/node/foo")
295 assert data["name"] == "foo"
296
297 def test_url_encoded_name(self):
298 port = _free_port()
299 with ExplorerServer(_mock_store(), port=port):
300 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/node/my%20node")
301 assert data["name"] == "my node"
302
303 def test_unknown_node_returns_empty_detail(self):
304 port = _free_port()
305 store = _mock_store()
306 # Override query to return empty for the node-detail lookup
307 original_side_effect = store.query.side_effect
308
309 def _empty_node(cypher, params=None):
310 if "where n.name" in cypher.lower() and "properties" in cypher.lower():
311 r = MagicMock()
312 r.result_set = []
313 return r
314 return original_side_effect(cypher, params)
315
316 store.query.side_effect = _empty_node
317 with ExplorerServer(store, port=port):
318 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/node/nonexistent")
319 assert data["neighbors"] == []
320
321
322 # ── API endpoint: GET /api/stats ──────────────────────────────────────────
323
324
325 class TestStatsEndpoint:
326 def test_returns_nodes_and_edges_counts(self):
327 port = _free_port()
328 with ExplorerServer(_mock_store(node_count=5, edge_count=3), port=port):
329 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/stats")
330 assert data["nodes"] == 5
331 assert data["edges"] == 3
332
333 def test_returns_node_types_and_edge_types(self):
334 port = _free_port()
335 with ExplorerServer(_mock_store(), port=port):
336 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/stats")
337 assert "node_types" in data
338 assert "edge_types" in data
339 assert isinstance(data["node_types"], dict)
340 assert isinstance(data["edge_types"], dict)
341
342 def test_node_type_counts_sum(self):
343 port = _free_port()
344 with ExplorerServer(_mock_store(node_count=3), port=port):
345 _, data = _fetch_json(f"http://127.0.0.1:{port}/api/stats")
346 total = sum(data["node_types"].values())
347 # The mock returns Function:2, Class:1 → total 3
348 assert total == 3
349
350
351 # ── 404 for unknown routes ─────────────────────────────────────────────────
352
353
354 class TestNotFound:
355 def test_unknown_path_returns_404(self):
356 port = _free_port()
357 with ExplorerServer(_mock_store(), port=port):
358 with pytest.raises(urllib.error.HTTPError) as exc_info:
359 urllib.request.urlopen(f"http://127.0.0.1:{port}/api/nonexistent")
360 assert exc_info.value.code == 404
361
362
363 # ── HTML template ─────────────────────────────────────────────────────────
364
365
366 class TestHtmlTemplate:
367 def test_is_string(self):
368 assert isinstance(HTML_TEMPLATE, str)
369
370 def test_contains_doctype(self):
371 assert "<!DOCTYPE html>" in HTML_TEMPLATE
372
373 def test_contains_canvas(self):
374 assert "graph-canvas" in HTML_TEMPLATE
375
376 def test_contains_search_box(self):
377 assert "search-box" in HTML_TEMPLATE
378
379 def test_contains_detail_panel(self):
380 assert "detail-panel" in HTML_TEMPLATE
381
382 def test_contains_api_graph_fetch(self):
383 assert "/api/graph" in HTML_TEMPLATE
384
385 def test_contains_api_search_fetch(self):
386 assert "/api/search" in HTML_TEMPLATE
387
388 def test_contains_api_node_fetch(self):
389 assert "/api/node/" in HTML_TEMPLATE
390
391 def test_contains_api_stats_fetch(self):
392 assert "/api/stats" in HTML_TEMPLATE
393
394 def test_no_external_deps(self):
395 """No CDN or external URLs should appear in the template."""
396 import re
397 # Look for any http(s):// URLs — internal /api/ paths are fine
398 external = re.findall(r'https?://\S+', HTML_TEMPLATE)
399 assert external == [], f"External URLs found: {external}"
400
401 def test_contains_force_directed_physics(self):
402 lower = HTML_TEMPLATE.lower()
403 assert "REPEL" in HTML_TEMPLATE or "repulsion" in lower or "force" in lower
404
405 def test_colors_injected(self):
406 assert "Function" in HTML_TEMPLATE
407 assert "Class" in HTML_TEMPLATE
408
409 def test_self_contained_script_tag(self):
410 assert "<script>" in HTML_TEMPLATE
411
412 def test_self_contained_style_tag(self):
413 assert "<style>" in HTML_TEMPLATE
414
415
416 # ── CLI command: navegador explore ────────────────────────────────────────
417
418
419 class TestExploreCLI:
420 def test_help_text(self):
421 runner = CliRunner()
422 result = runner.invoke(main, ["explore", "--help"])
423 assert result.exit_code == 0
424 assert "explore" in result.output.lower() or "graph" in result.output.lower()
425
426 def test_explore_command_registered(self):
427 """Verify the explore command is registered under the main group."""
428 from navegador.cli.commands import main as cli_main
429 assert "explore" in cli_main.commands
430
431 def test_explore_starts_and_stops(self):
432 """CLI explore should start ExplorerServer and stop cleanly on KeyboardInterrupt."""
433 runner = CliRunner()
434 port = _free_port()
435
436 mock_srv = MagicMock()
437 mock_srv.url = f"http://127.0.0.1:{port}"
438
439 call_count = [0]
440
441 def _fake_sleep(seconds):
442 # Let the first call (browser delay) pass, raise on second (main loop)
443 call_count[0] += 1
444 if call_count[0] >= 2:
445 raise KeyboardInterrupt
446
447 # The explore command does local imports, so patch at the source modules.
448 with (
449 patch("navegador.explorer.ExplorerServer", return_value=mock_srv),
450 patch("navegador.cli.commands._get_store", return_value=MagicMock()),
451 patch("time.sleep", side_effect=_fake_sleep),
452 patch("webbrowser.open"),
453 ):
454 result = runner.invoke(main, ["explore", "--port", str(port)])
455
456 mock_srv.start.assert_called_once()
457 mock_srv.stop.assert_called_once()
458 assert result.exit_code == 0
459
460 def test_explore_no_browser_flag(self):
461 """--no-browser should skip webbrowser.open."""
462 runner = CliRunner()
463 port = _free_port()
464
465 mock_srv = MagicMock()
466 mock_srv.url = f"http://127.0.0.1:{port}"
467
468 def _fake_sleep(seconds):
469 raise KeyboardInterrupt
470
471 with (
472 patch("navegador.explorer.ExplorerServer", return_value=mock_srv),
473 patch("navegador.cli.commands._get_store", return_value=MagicMock()),
474 patch("time.sleep", side_effect=_fake_sleep),
475 patch("webbrowser.open") as mock_open,
476 ):
477 result = runner.invoke(main, ["explore", "--no-browser", "--port", str(port)])
478
479 mock_open.assert_not_called()
480 assert result.exit_code == 0
481
482 def test_explore_custom_port(self):
483 """--port option should be forwarded to ExplorerServer."""
484 runner = CliRunner()
485 port = _free_port()
486
487 captured = {}
488
489 def _fake_server(store, host, port): # noqa: A002
490 captured["port"] = port
491 srv = MagicMock()
492 srv.url = f"http://{host}:{port}"
493 return srv
494
495 def _fake_sleep(seconds):
496 raise KeyboardInterrupt
497
498 with (
499 patch("navegador.explorer.ExplorerServer", side_effect=_fake_server),
500 patch("navegador.cli.commands._get_store", return_value=MagicMock()),
501 patch("time.sleep", side_effect=_fake_sleep),
502 patch("webbrowser.open"),
503 ):
504 runner.invoke(main, ["explore", "--port", str(port)])
505
506 assert captured.get("port") == port
507
508 def test_explore_custom_host(self):
509 """--host option should be forwarded to ExplorerServer."""
510 runner = CliRunner()
511 captured = {}
512
513 def _fake_server(store, host, port): # noqa: A002
514 captured["host"] = host
515 srv = MagicMock()
516 srv.url = f"http://{host}:{port}"
517 return srv
518
519 def _fake_sleep(seconds):
520 raise KeyboardInterrupt
521
522 with (
523 patch("navegador.explorer.ExplorerServer", side_effect=_fake_server),
524 patch("navegador.cli.commands._get_store", return_value=MagicMock()),
525 patch("time.sleep", side_effect=_fake_sleep),
526 patch("webbrowser.open"),
527 ):
528 runner.invoke(main, ["explore", "--host", "0.0.0.0"])
529
530 assert captured.get("host") == "0.0.0.0"
531
532 def test_explore_output_shows_url(self):
533 """explore should print the server URL to stdout."""
534 runner = CliRunner()
535 port = _free_port()
536
537 mock_srv = MagicMock()
538 mock_srv.url = f"http://127.0.0.1:{port}"
539
540 def _fake_sleep(seconds):
541 raise KeyboardInterrupt
542
543 with (
544 patch("navegador.explorer.ExplorerServer", return_value=mock_srv),
545 patch("navegador.cli.commands._get_store", return_value=MagicMock()),
546 patch("time.sleep", side_effect=_fake_sleep),
547 patch("webbrowser.open"),
548 ):
549 result = runner.invoke(main, ["explore", "--port", str(port)])
550
551 assert str(port) in result.output or "127.0.0.1" in result.output

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button