hivescope 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: hivescope
3
+ Version: 0.1.0
4
+ Summary: Hivescope: E2E testing library for HiveMind protocol
5
+ Author-email: JarbasAi <jarbasai@mailfence.com>
6
+ License: AGPL-3.0
7
+ Keywords: hivemind,protocol,testing,e2e,pytest
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Topic :: Software Development :: Testing
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: hivemind-bus-client>=0.7.0a2
18
+ Requires-Dist: hivemind-core>=4.0
19
+ Requires-Dist: ovos-utils>=0.0.40
20
+ Requires-Dist: pytest>=7.4
21
+ Requires-Dist: websockets>=11
22
+ Provides-Extra: ovos
23
+ Requires-Dist: ovoscope>=0.13; extra == "ovos"
24
+ Requires-Dist: ovos-core>=0.0.30; extra == "ovos"
25
+
26
+ # Hivescope
27
+
28
+ A self-contained pytest-based E2E testing library for HiveMind protocol implementations.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install "hivescope @ git+https://github.com/JarbasHiveMind/hivescope@dev"
34
+ ```
35
+
36
+ With OVOS skill-level testing support:
37
+
38
+ ```bash
39
+ pip install "hivescope[ovos] @ git+https://github.com/JarbasHiveMind/hivescope@dev"
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ ```python
45
+ from hivescope.scenarios import single_satellite
46
+ from hivescope.assertions import assert_handshake_complete, assert_encryption_match
47
+
48
+ def test_handshake():
49
+ builder = single_satellite()
50
+ builder.start_all()
51
+ try:
52
+ master = builder.get_master("M0")
53
+ satellite = builder.get_satellite("S0")
54
+ assert_handshake_complete(master, satellite)
55
+ assert_encryption_match(master, satellite)
56
+ finally:
57
+ builder.stop_all()
58
+ ```
59
+
60
+ Using pytest fixtures (add to `tests/conftest.py`):
61
+
62
+ ```python
63
+ pytest_plugins = ['hivescope.pytest_fixtures']
64
+ ```
65
+
66
+ ```python
67
+ def test_message_forwarded(master_node, satellite_node):
68
+ from ovos_bus_client.message import Message
69
+ satellite_node.send(Message("test:ping", {}))
70
+ master_node.recorder.assert_received("BUS", count=1)
71
+ ```
72
+
73
+ ## Templates
74
+
75
+ Copy-paste test templates from `templates/` into your repo's `tests/e2e/`:
76
+
77
+ | Template | Covers |
78
+ |---|---|
79
+ | `test_template_handshake.py` | Cipher/encoding agreement, handshake completion |
80
+ | `test_template_routing.py` | Message routing through master |
81
+ | `test_template_acl.py` | ACL enforcement for restricted satellites |
82
+ | `test_template_binary.py` | Binary protocol message handling |
83
+
84
+ ## Configuration
85
+
86
+ | Key | Default | Purpose |
87
+ |---|---|---|
88
+ | `use_loopback` | `False` | Pass `True` to `add_master()` to use loopback network protocol instead of in-process |
89
+ | `[ovos]` extra | not installed | Enables `OvoscopeAgentProtocol` backed by a live MiniCroft |
90
+
91
+ ## API Reference
92
+
93
+ See [docs/index.md](docs/index.md) for the full public API.
94
+
95
+ ## License
96
+
97
+ AGPL-3.0
@@ -0,0 +1,72 @@
1
+ # Hivescope
2
+
3
+ A self-contained pytest-based E2E testing library for HiveMind protocol implementations.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install "hivescope @ git+https://github.com/JarbasHiveMind/hivescope@dev"
9
+ ```
10
+
11
+ With OVOS skill-level testing support:
12
+
13
+ ```bash
14
+ pip install "hivescope[ovos] @ git+https://github.com/JarbasHiveMind/hivescope@dev"
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```python
20
+ from hivescope.scenarios import single_satellite
21
+ from hivescope.assertions import assert_handshake_complete, assert_encryption_match
22
+
23
+ def test_handshake():
24
+ builder = single_satellite()
25
+ builder.start_all()
26
+ try:
27
+ master = builder.get_master("M0")
28
+ satellite = builder.get_satellite("S0")
29
+ assert_handshake_complete(master, satellite)
30
+ assert_encryption_match(master, satellite)
31
+ finally:
32
+ builder.stop_all()
33
+ ```
34
+
35
+ Using pytest fixtures (add to `tests/conftest.py`):
36
+
37
+ ```python
38
+ pytest_plugins = ['hivescope.pytest_fixtures']
39
+ ```
40
+
41
+ ```python
42
+ def test_message_forwarded(master_node, satellite_node):
43
+ from ovos_bus_client.message import Message
44
+ satellite_node.send(Message("test:ping", {}))
45
+ master_node.recorder.assert_received("BUS", count=1)
46
+ ```
47
+
48
+ ## Templates
49
+
50
+ Copy-paste test templates from `templates/` into your repo's `tests/e2e/`:
51
+
52
+ | Template | Covers |
53
+ |---|---|
54
+ | `test_template_handshake.py` | Cipher/encoding agreement, handshake completion |
55
+ | `test_template_routing.py` | Message routing through master |
56
+ | `test_template_acl.py` | ACL enforcement for restricted satellites |
57
+ | `test_template_binary.py` | Binary protocol message handling |
58
+
59
+ ## Configuration
60
+
61
+ | Key | Default | Purpose |
62
+ |---|---|---|
63
+ | `use_loopback` | `False` | Pass `True` to `add_master()` to use loopback network protocol instead of in-process |
64
+ | `[ovos]` extra | not installed | Enables `OvoscopeAgentProtocol` backed by a live MiniCroft |
65
+
66
+ ## API Reference
67
+
68
+ See [docs/index.md](docs/index.md) for the full public API.
69
+
70
+ ## License
71
+
72
+ AGPL-3.0
@@ -0,0 +1,67 @@
1
+ """
2
+ Hivescope: E2E Testing Library for HiveMind
3
+
4
+ A reusable pytest-based framework for writing end-to-end tests of HiveMind
5
+ protocol implementations. Provides stable APIs for topology simulation,
6
+ message routing verification, and protocol-level assertions.
7
+
8
+ Public API — stable across versions:
9
+ - TopologyBuilder: Builder for test network topologies
10
+ - MasterNode, SatelliteNode, RelayNode: Network node types
11
+ - TestAgentProtocol: Agent protocol backed by FakeBus (fast, deterministic)
12
+ - OvoscopeAgentProtocol: Agent protocol backed by MiniCroft (realistic, slow)
13
+ - TestBinaryProtocol: Binary data handler stub for testing
14
+ - TestNetworkProtocol: Network protocol stub for in-process testing
15
+ - MessageRecorder, RecordedMessage: Message recording and inspection
16
+ - InMemoryClientDatabase: In-memory credential store for testing
17
+
18
+ Fixtures & Helpers (use via conftest.py):
19
+ - pytest fixtures: topology, master_node, satellite_node, etc.
20
+ - Assertion helpers: assert_handshake_complete(), assert_message_routed(), etc.
21
+ - Preset topologies: single_satellite(), three_satellites(), with_relay(), etc.
22
+
23
+ Example:
24
+ from hivescope import TopologyBuilder
25
+ from hivescope.scenarios import single_satellite
26
+
27
+ # Build and run a simple test topology
28
+ b = single_satellite()
29
+ b.start_all()
30
+ # ... test assertions ...
31
+ b.stop_all()
32
+ """
33
+
34
+ from hivescope.topology import TopologyBuilder
35
+ from hivescope.node import MasterNode, SatelliteNode
36
+ from hivescope.topology import RelayNode
37
+ from hivescope.recorder import MessageRecorder, RecordedMessage
38
+ from hivescope.database import InMemoryClientDatabase
39
+ from hivescope.plugins.agent import TestAgentProtocol
40
+ from hivescope.plugins.binary import TestBinaryProtocol
41
+ from hivescope.plugins.network import TestNetworkProtocol
42
+
43
+ # Optional: OvoscopeAgentProtocol requires ovoscope+ovos-core
44
+ try:
45
+ from hivescope.plugins.ovoscope_agent import OvoscopeAgentProtocol
46
+ except ImportError:
47
+ OvoscopeAgentProtocol = None
48
+
49
+ from hivescope.version import __version__
50
+
51
+ __all__ = [
52
+ # Core topology classes (stable)
53
+ "TopologyBuilder",
54
+ "MasterNode",
55
+ "SatelliteNode",
56
+ "RelayNode",
57
+ # Protocol plugins (stable)
58
+ "TestAgentProtocol",
59
+ "TestBinaryProtocol",
60
+ "TestNetworkProtocol",
61
+ "OvoscopeAgentProtocol",
62
+ # Message recording (stable)
63
+ "MessageRecorder",
64
+ "RecordedMessage",
65
+ # Database (stable)
66
+ "InMemoryClientDatabase",
67
+ ]
@@ -0,0 +1,225 @@
1
+ """
2
+ Common assertion helpers for hivescope e2e tests.
3
+
4
+ Simplify protocol-level assertions with helpers like:
5
+ - assert_handshake_complete(master, satellite)
6
+ - assert_message_routed(master, msg_type, count)
7
+ - assert_acl_enforced(master, satellite, msg_type)
8
+
9
+ Each helper performs a specific protocol check and raises AssertionError
10
+ with detailed failure messages if the check fails.
11
+
12
+ Usage:
13
+
14
+ from hivescope.assertions import (
15
+ assert_handshake_complete,
16
+ assert_message_routed,
17
+ )
18
+
19
+ def test_handshake(master_node, satellite_node):
20
+ satellite_node.connect(master_node)
21
+ satellite_node.wait_for_handshake(timeout=5)
22
+
23
+ assert_handshake_complete(master_node, satellite_node)
24
+ assert_message_routed(master_node, "HELLO", count=1)
25
+ """
26
+
27
+ from typing import Optional, Any
28
+ from hivescope.node import MasterNode, SatelliteNode
29
+ from hivemind_bus_client.message import HiveMessageType
30
+
31
+
32
+ def assert_handshake_complete(
33
+ master: MasterNode,
34
+ satellite: SatelliteNode,
35
+ timeout: float = 5.0
36
+ ) -> None:
37
+ """
38
+ Assert that satellite has completed handshake with master.
39
+
40
+ Checks:
41
+ - satellite.crypto_key is not None
42
+ - satellite.handshake_event is set
43
+ - master has registered the satellite peer
44
+
45
+ Raises AssertionError with diagnostic details if any check fails.
46
+ """
47
+ errors = []
48
+
49
+ if satellite.shim.crypto_key is None:
50
+ errors.append("satellite.shim.crypto_key is None (no crypto negotiated)")
51
+
52
+ if not satellite.shim.handshake_event.is_set():
53
+ errors.append("satellite.shim.handshake_event not set (handshake not complete)")
54
+
55
+ connected_peers = master.connected_peers()
56
+ if satellite.peer not in connected_peers:
57
+ errors.append(
58
+ f"satellite peer '{satellite.peer}' not in master's connected_peers: {connected_peers}"
59
+ )
60
+
61
+ if errors:
62
+ raise AssertionError(
63
+ f"Handshake not complete:\n " + "\n ".join(errors)
64
+ )
65
+
66
+
67
+ def assert_message_routed(
68
+ node,
69
+ msg_type: str,
70
+ count: int = 1,
71
+ direction: Optional[str] = None,
72
+ timeout: float = 2.0
73
+ ) -> None:
74
+ """
75
+ Assert that a specific message type was routed through a node.
76
+
77
+ Checks:
78
+ - message was recorded by node.recorder
79
+ - count matches expected number
80
+
81
+ Args:
82
+ node: MasterNode or SatelliteNode with MessageRecorder
83
+ msg_type: HiveMessageType name (e.g., "HELLO", "BUS", "BROADCAST")
84
+ count: Expected number of messages
85
+ direction: Optional "inbound" or "outbound" filter
86
+ timeout: Wait time for message to appear (not implemented yet)
87
+
88
+ Raises AssertionError if count doesn't match.
89
+ """
90
+ messages = node.recorder.messages
91
+
92
+ if direction:
93
+ messages = [m for m in messages if m.direction == direction]
94
+
95
+ matching = [m for m in messages if m.msg_type == msg_type]
96
+ actual_count = len(matching)
97
+
98
+ if actual_count != count:
99
+ raise AssertionError(
100
+ f"Expected {count} '{msg_type}' messages, got {actual_count}.\n"
101
+ f"All messages: {[m.msg_type for m in node.recorder.messages]}"
102
+ )
103
+
104
+
105
+ def assert_acl_enforced(
106
+ master: MasterNode,
107
+ satellite: SatelliteNode,
108
+ msg_type: str,
109
+ allowed: bool = False
110
+ ) -> None:
111
+ """
112
+ Assert that ACL is enforced for a message type on a satellite.
113
+
114
+ If allowed=False, verify that sending msg_type to satellite is blocked.
115
+ If allowed=True, verify that msg_type is allowed through.
116
+
117
+ This is a placeholder for more complex ACL assertions.
118
+ """
119
+ # TODO: Implement after ACL enforcement logic is fully understood
120
+ pass
121
+
122
+
123
+ def assert_encryption_match(
124
+ master: MasterNode,
125
+ satellite: SatelliteNode
126
+ ) -> None:
127
+ """
128
+ Assert that the master-side connection and satellite shim have matching
129
+ encryption settings after handshake.
130
+
131
+ Checks:
132
+ - cipher type matches
133
+ - json_encoding matches
134
+
135
+ Raises AssertionError if settings don't match.
136
+ """
137
+ errors = []
138
+
139
+ master_conn = next(
140
+ (c for c in master.hm_protocol.clients.values()
141
+ if c.peer == satellite.peer),
142
+ None,
143
+ )
144
+ if master_conn is None:
145
+ raise AssertionError(
146
+ f"satellite peer '{satellite.peer}' not registered at master; "
147
+ "cannot compare encryption settings"
148
+ )
149
+
150
+ if master_conn.cipher != satellite.shim.cipher:
151
+ errors.append(
152
+ f"cipher mismatch: master={master_conn.cipher}, "
153
+ f"satellite={satellite.shim.cipher}"
154
+ )
155
+
156
+ if master_conn.json_encoding != satellite.shim.json_encoding:
157
+ errors.append(
158
+ f"json_encoding mismatch: master={master_conn.json_encoding}, "
159
+ f"satellite={satellite.shim.json_encoding}"
160
+ )
161
+
162
+ if errors:
163
+ raise AssertionError(
164
+ f"Encryption settings don't match:\n " + "\n ".join(errors)
165
+ )
166
+
167
+
168
+ def assert_client_registered(
169
+ master: MasterNode,
170
+ peer: str
171
+ ) -> None:
172
+ """
173
+ Assert that a client is registered in master's connected_peers.
174
+
175
+ Raises AssertionError if peer is not registered.
176
+ """
177
+ connected = master.connected_peers()
178
+ if peer not in connected:
179
+ raise AssertionError(
180
+ f"Peer '{peer}' not registered in master. "
181
+ f"Connected peers: {connected}"
182
+ )
183
+
184
+
185
+ def assert_client_not_registered(
186
+ master: MasterNode,
187
+ peer: str
188
+ ) -> None:
189
+ """
190
+ Assert that a client is NOT registered in master's connected_peers.
191
+
192
+ Raises AssertionError if peer is registered.
193
+ """
194
+ connected = master.connected_peers()
195
+ if peer in connected:
196
+ raise AssertionError(
197
+ f"Peer '{peer}' is registered in master. "
198
+ f"Connected peers: {connected}"
199
+ )
200
+
201
+
202
+ def assert_message_received_by(
203
+ node,
204
+ msg_type: str,
205
+ count: int = 1
206
+ ) -> None:
207
+ """
208
+ Assert that node's recorder has received a message type.
209
+
210
+ Convenience wrapper for assert_message_routed with direction='inbound'.
211
+ """
212
+ assert_message_routed(node, msg_type, count=count, direction="inbound")
213
+
214
+
215
+ def assert_message_sent_by(
216
+ node,
217
+ msg_type: str,
218
+ count: int = 1
219
+ ) -> None:
220
+ """
221
+ Assert that node's recorder has sent a message type.
222
+
223
+ Convenience wrapper for assert_message_routed with direction='outbound'.
224
+ """
225
+ assert_message_routed(node, msg_type, count=count, direction="outbound")
@@ -0,0 +1,113 @@
1
+ """
2
+ In-memory ClientDatabase that avoids any plugin loading or disk I/O.
3
+ Implements the same public interface as hivemind_core.database.ClientDatabase
4
+ so it can be passed directly to HiveMindListenerProtocol.
5
+ """
6
+ from typing import List, Optional, Iterable
7
+
8
+ from hivemind_plugin_manager.database import Client
9
+
10
+
11
+ class InMemoryClientDatabase:
12
+ """Drop-in replacement for ClientDatabase backed by a plain dict."""
13
+
14
+ def __init__(self):
15
+ self._clients: dict[str, Client] = {} # keyed by api_key
16
+
17
+ # --- write API ---
18
+
19
+ def add_client(self,
20
+ name: str,
21
+ key: str = "",
22
+ admin: bool = False,
23
+ intent_blacklist: Optional[List[str]] = None,
24
+ skill_blacklist: Optional[List[str]] = None,
25
+ message_blacklist: Optional[List[str]] = None,
26
+ allowed_types: Optional[List[str]] = None,
27
+ crypto_key: Optional[str] = None,
28
+ password: Optional[str] = None,
29
+ can_escalate: bool = True,
30
+ can_propagate: bool = True,
31
+ can_broadcast: bool = True) -> bool:
32
+ if crypto_key is not None:
33
+ crypto_key = crypto_key[:16]
34
+ existing = self.get_client_by_api_key(key)
35
+ if existing:
36
+ if name:
37
+ existing.name = name
38
+ if intent_blacklist is not None:
39
+ existing.intent_blacklist = intent_blacklist
40
+ if skill_blacklist is not None:
41
+ existing.skill_blacklist = skill_blacklist
42
+ if message_blacklist is not None:
43
+ existing.message_blacklist = message_blacklist
44
+ if allowed_types is not None:
45
+ existing.allowed_types = allowed_types
46
+ existing.is_admin = admin
47
+ if crypto_key:
48
+ existing.crypto_key = crypto_key
49
+ if password:
50
+ existing.password = password
51
+ existing.can_escalate = can_escalate
52
+ existing.can_propagate = can_propagate
53
+ existing.can_broadcast = can_broadcast
54
+ self._clients[key] = existing
55
+ return True
56
+
57
+ client = Client(
58
+ api_key=key,
59
+ name=name,
60
+ client_id=self.total_clients() + 1,
61
+ is_admin=admin,
62
+ intent_blacklist=intent_blacklist,
63
+ skill_blacklist=skill_blacklist,
64
+ message_blacklist=message_blacklist,
65
+ allowed_types=allowed_types,
66
+ crypto_key=crypto_key,
67
+ password=password,
68
+ can_escalate=can_escalate,
69
+ can_propagate=can_propagate,
70
+ can_broadcast=can_broadcast,
71
+ )
72
+ self._clients[key] = client
73
+ return True
74
+
75
+ def update_item(self, client: Client) -> bool:
76
+ self._clients[client.api_key] = client
77
+ return True
78
+
79
+ def delete_client(self, key: str) -> bool:
80
+ if key in self._clients:
81
+ # mark revoked (don't reuse client_id)
82
+ c = self._clients[key]
83
+ self._clients[key] = Client(client_id=c.client_id, api_key="revoked")
84
+ return True
85
+ return False
86
+
87
+ # --- read API ---
88
+
89
+ def get_client_by_api_key(self, api_key: str) -> Optional[Client]:
90
+ return self._clients.get(api_key)
91
+
92
+ def get_clients_by_name(self, name: str) -> List[Client]:
93
+ return [c for c in self._clients.values() if c.name == name]
94
+
95
+ def total_clients(self) -> int:
96
+ return len(self._clients)
97
+
98
+ # --- lifecycle ---
99
+
100
+ def sync(self):
101
+ pass # nothing to reload from disk
102
+
103
+ def __enter__(self):
104
+ return self
105
+
106
+ def __exit__(self, _type, value, traceback):
107
+ pass # nothing to commit
108
+
109
+ def __iter__(self) -> Iterable[Client]:
110
+ return iter(list(self._clients.values()))
111
+
112
+ def __len__(self) -> int:
113
+ return len(self._clients)