personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

link: add live integration test for pair+dial roundtrip

+345 -7
+15 -5
Makefile
··· 275 275 276 276 # Test environment - use fixtures journal for all tests 277 277 TEST_ENV = _SOLSTONE_JOURNAL_OVERRIDE=tests/fixtures/journal 278 + LINK_LIVE_TESTS = --ignore=tests/link/test_integration.py --ignore=tests/link/test_privacy_scan.py 278 279 279 280 # Venv tool shortcuts 280 281 PYTEST := $(VENV_BIN)/pytest ··· 288 289 # Run core tests (excluding integration and app tests) 289 290 test: .installed format-check 290 291 @echo "Running core tests..." 291 - $(TEST_ENV) $(PYTEST) tests/ -q --cov=. --ignore=tests/integration 292 + $(TEST_ENV) $(PYTEST) tests/ -q --cov=. --ignore=tests/integration $(LINK_LIVE_TESTS) 292 293 293 294 # Run app tests 294 295 test-apps: .installed ··· 317 318 # Run integration tests 318 319 test-integration: .installed 319 320 @echo "Running integration tests..." 320 - $(TEST_ENV) $(PYTEST) tests/integration/ -v --tb=short --timeout=20 321 + @STATUS=0; \ 322 + $(TEST_ENV) $(PYTEST) tests/integration/ tests/link/test_integration.py tests/link/test_privacy_scan.py -v --tb=short --timeout=20 || STATUS=$$?; \ 323 + if [ "$$STATUS" -ne 0 ] && [ "$$STATUS" -ne 5 ]; then exit $$STATUS; fi 321 324 322 325 # Run specific integration test 323 326 test-integration-only: .installed ··· 326 329 echo "Example: make test-integration-only TEST=test_api.py"; \ 327 330 exit 1; \ 328 331 fi 329 - $(TEST_ENV) $(PYTEST) tests/integration/$(TEST) --timeout=20 332 + @TARGET="$(TEST)"; \ 333 + case "$$TARGET" in \ 334 + tests/*|-*) ;; \ 335 + *) TARGET="tests/integration/$$TARGET" ;; \ 336 + esac; \ 337 + STATUS=0; \ 338 + $(TEST_ENV) $(PYTEST) "$$TARGET" --timeout=20 || STATUS=$$?; \ 339 + if [ "$$STATUS" -ne 0 ] && [ "$$STATUS" -ne 5 ]; then exit $$STATUS; fi 330 340 331 341 # Run all tests (core + apps + integration) 332 342 test-all: .installed 333 343 @echo "Running all tests (core + apps + integration)..." 334 - $(TEST_ENV) $(PYTEST) tests/ -v --cov=. && $(TEST_ENV) $(PYTEST) apps/ -v --cov=. --cov-append 344 + $(TEST_ENV) $(PYTEST) tests/ -v --cov=. --ignore=tests/integration $(LINK_LIVE_TESTS) && $(TEST_ENV) $(PYTEST) apps/ -v --cov=. --cov-append 335 345 336 346 # Auto-format and fix code, then report any remaining issues 337 347 format: .installed ··· 491 501 492 502 # Generate coverage report (core + apps, excluding core integration tests) 493 503 coverage: .installed 494 - $(TEST_ENV) $(PYTEST) tests/ --cov=. --cov-report=html --cov-report=term --ignore=tests/integration 504 + $(TEST_ENV) $(PYTEST) tests/ --cov=. --cov-report=html --cov-report=term --ignore=tests/integration $(LINK_LIVE_TESTS) 495 505 $(TEST_ENV) $(PYTEST) apps/ --cov=. --cov-report=html --cov-report=term --cov-append 496 506 @echo "Coverage report generated in htmlcov/index.html" 497 507
+3 -2
tests/link/client.py
··· 231 231 return 232 232 self._closed = True 233 233 for state in list(self._streams.values()): 234 + if state.reset_reason is None: 235 + state.reset_reason = RESET_INTERNAL_ERROR 234 236 self._close_stream(state, forget=True) 235 237 236 238 async def _dispatch(self, frame: Frame) -> None: ··· 361 363 if self._closed.is_set(): 362 364 return 363 365 self._mux.close() 364 - if not self._ws.closed: 365 - await self._ws.close() 366 + await self._ws.close() 366 367 await self._reader_task 367 368 self._closed.set() 368 369
+226
tests/link/live_helpers.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import contextlib 7 + import json 8 + import os 9 + import queue 10 + import subprocess 11 + import sys 12 + import threading 13 + import time 14 + from collections.abc import Iterator 15 + from pathlib import Path 16 + from typing import Any 17 + 18 + import pytest 19 + import requests 20 + from werkzeug.security import generate_password_hash 21 + from werkzeug.serving import make_server 22 + 23 + RELAY_URL = "https://spl-relay-staging.jer-3f2.workers.dev" 24 + CONVEY_PASSWORD = "pytest-link-pass" 25 + _READY_LINE = "listen WS open" 26 + 27 + 28 + def skip_unless_live_relay() -> None: 29 + if os.environ.get("SPL_RELAY_LIVE_TESTS", "1") == "0": 30 + pytest.skip( 31 + "SPL_RELAY_LIVE_TESTS=0; skipping live relay tests", 32 + allow_module_level=True, 33 + ) 34 + try: 35 + response = requests.get(f"{RELAY_URL}/", timeout=5) 36 + if response.status_code != 200: 37 + pytest.skip( 38 + f"relay unreachable: status {response.status_code}", 39 + allow_module_level=True, 40 + ) 41 + except Exception as exc: # noqa: BLE001 42 + pytest.skip(f"relay unreachable: {exc}", allow_module_level=True) 43 + 44 + 45 + class LinkProcessCapture: 46 + def __init__(self, proc: subprocess.Popen[str]) -> None: 47 + self.proc = proc 48 + self.stdout_lines: list[str] = [] 49 + self.stderr_lines: list[str] = [] 50 + self._queue: queue.Queue[tuple[str, str]] = queue.Queue() 51 + self._threads = [ 52 + threading.Thread( 53 + target=self._drain, 54 + args=(proc.stdout, self.stdout_lines, "stdout", self._queue), 55 + daemon=True, 56 + ), 57 + threading.Thread( 58 + target=self._drain, 59 + args=(proc.stderr, self.stderr_lines, "stderr", self._queue), 60 + daemon=True, 61 + ), 62 + ] 63 + for thread in self._threads: 64 + thread.start() 65 + 66 + def wait_for_line(self, needle: str, timeout: float) -> None: 67 + deadline = time.monotonic() + timeout 68 + while time.monotonic() < deadline: 69 + if any(needle in line for line in self.stderr_lines): 70 + return 71 + if self.proc.poll() is not None: 72 + break 73 + remaining = max(0.0, deadline - time.monotonic()) 74 + try: 75 + stream, line = self._queue.get(timeout=min(0.25, remaining)) 76 + except queue.Empty: 77 + continue 78 + if stream == "stderr" and needle in line: 79 + return 80 + raise RuntimeError( 81 + f"link service never emitted {needle!r}; stderr tail:\n" 82 + + "".join(self.stderr_lines[-50:]) 83 + ) 84 + 85 + @property 86 + def stdout_text(self) -> str: 87 + return "".join(self.stdout_lines) 88 + 89 + @property 90 + def stderr_text(self) -> str: 91 + return "".join(self.stderr_lines) 92 + 93 + def stop(self) -> None: 94 + if self.proc.poll() is None: 95 + self.proc.terminate() 96 + try: 97 + self.proc.wait(timeout=5) 98 + except subprocess.TimeoutExpired: 99 + self.proc.kill() 100 + self.proc.wait(timeout=5) 101 + for pipe in (self.proc.stdout, self.proc.stderr): 102 + if pipe is not None: 103 + pipe.close() 104 + for thread in self._threads: 105 + thread.join(timeout=1) 106 + 107 + @staticmethod 108 + def _drain( 109 + pipe: Any, 110 + target: list[str], 111 + stream: str, 112 + line_queue: queue.Queue[tuple[str, str]], 113 + ) -> None: 114 + if pipe is None: 115 + return 116 + try: 117 + for line in pipe: 118 + target.append(line) 119 + line_queue.put((stream, line)) 120 + finally: 121 + return 122 + 123 + 124 + @contextlib.contextmanager 125 + def running_convey_server(journal_path: Path) -> Iterator[str]: 126 + from convey import create_app 127 + 128 + _prepare_journal(journal_path) 129 + app = create_app(str(journal_path)) 130 + server = make_server("127.0.0.1", 0, app) 131 + thread = threading.Thread(target=server.serve_forever, daemon=True) 132 + thread.start() 133 + try: 134 + yield f"http://127.0.0.1:{server.server_port}" 135 + finally: 136 + server.shutdown() 137 + thread.join(timeout=5) 138 + 139 + 140 + @contextlib.contextmanager 141 + def running_link_service( 142 + journal_path: Path, 143 + *, 144 + relay_url: str = RELAY_URL, 145 + ) -> Iterator[LinkProcessCapture]: 146 + _prepare_journal(journal_path) 147 + repo_root = Path(__file__).resolve().parents[2] 148 + sol_bin = Path(sys.executable).with_name("sol") 149 + env = os.environ.copy() 150 + env["_SOLSTONE_JOURNAL_OVERRIDE"] = str(journal_path) 151 + env["SOL_LINK_RELAY_URL"] = relay_url 152 + env["SOL_SKIP_SUPERVISOR_CHECK"] = "1" 153 + env["PYTHONUNBUFFERED"] = "1" 154 + env["PATH"] = f"{repo_root / '.venv' / 'bin'}:{env.get('PATH', '')}" 155 + proc = subprocess.Popen( 156 + [str(sol_bin), "link", "-v"], 157 + cwd=repo_root, 158 + env=env, 159 + stdout=subprocess.PIPE, 160 + stderr=subprocess.PIPE, 161 + text=True, 162 + bufsize=1, 163 + ) 164 + capture = LinkProcessCapture(proc) 165 + try: 166 + capture.wait_for_line(_READY_LINE, timeout=15) 167 + yield capture 168 + finally: 169 + capture.stop() 170 + 171 + 172 + def list_devices(base_url: str) -> list[dict[str, Any]]: 173 + response = requests.get(f"{base_url}/app/link/api/devices", timeout=10) 174 + response.raise_for_status() 175 + payload = response.json() 176 + assert isinstance(payload, dict) 177 + devices = payload.get("devices") 178 + assert isinstance(devices, list) 179 + return devices 180 + 181 + 182 + def unpair_device(base_url: str, fingerprint: str) -> dict[str, Any]: 183 + response = requests.post( 184 + f"{base_url}/app/link/unpair", 185 + json={"fingerprint": fingerprint}, 186 + timeout=10, 187 + ) 188 + response.raise_for_status() 189 + payload = response.json() 190 + assert isinstance(payload, dict) 191 + return payload 192 + 193 + 194 + def runtime_texts( 195 + journal_path: Path, 196 + capture: LinkProcessCapture, 197 + ) -> dict[str, str]: 198 + out = { 199 + "stdout": capture.stdout_text, 200 + "stderr": capture.stderr_text, 201 + } 202 + extra_paths = [journal_path / "health" / "supervisor.log"] 203 + extra_paths.extend(sorted((journal_path / "link").glob("*.log"))) 204 + for path in extra_paths: 205 + if path.exists(): 206 + out[str(path.relative_to(journal_path))] = path.read_text("utf-8") 207 + return out 208 + 209 + 210 + def _prepare_journal(journal_path: Path) -> None: 211 + config_path = journal_path / "config" / "journal.json" 212 + config_path.parent.mkdir(parents=True, exist_ok=True) 213 + config_path.write_text( 214 + json.dumps( 215 + { 216 + "convey": { 217 + "password_hash": generate_password_hash(CONVEY_PASSWORD), 218 + "trust_localhost": True, 219 + }, 220 + "setup": {"completed_at": 1}, 221 + }, 222 + indent=2, 223 + ) 224 + + "\n", 225 + encoding="utf-8", 226 + )
+101
tests/link/test_integration.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import asyncio 7 + import base64 8 + import json 9 + from pathlib import Path 10 + 11 + import pytest 12 + 13 + from tests.link.client import Client, StreamResetError 14 + from tests.link.live_helpers import ( 15 + CONVEY_PASSWORD, 16 + RELAY_URL, 17 + list_devices, 18 + running_convey_server, 19 + running_link_service, 20 + skip_unless_live_relay, 21 + unpair_device, 22 + ) 23 + 24 + pytestmark = pytest.mark.integration 25 + skip_unless_live_relay() 26 + 27 + 28 + @pytest.mark.asyncio 29 + @pytest.mark.timeout(60) 30 + async def test_pair_enroll_dial_roundtrip( 31 + tmp_path: Path, 32 + monkeypatch: pytest.MonkeyPatch, 33 + ) -> None: 34 + tmp_journal = tmp_path / "journal" 35 + tmp_journal.mkdir() 36 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_journal)) 37 + 38 + with ( 39 + running_convey_server(tmp_journal) as base_url, 40 + running_link_service(tmp_journal), 41 + ): 42 + identity = Client.pair(base_url, device_label="pytest-device") 43 + assert identity.home_instance_id 44 + assert identity.client_cert_pem.startswith("-----BEGIN CERTIFICATE-----") 45 + assert len(identity.home_attestation.split(".")) == 3 46 + 47 + enrolled = Client.enroll_device(RELAY_URL, identity) 48 + assert enrolled.device_token 49 + 50 + before = next( 51 + device 52 + for device in list_devices(base_url) 53 + if device["fingerprint"] == identity.fingerprint 54 + ) 55 + assert before["last_seen_at"] is None 56 + 57 + session = await Client.dial(RELAY_URL, enrolled) 58 + async with session: 59 + auth = base64.b64encode(f":{CONVEY_PASSWORD}".encode("utf-8")).decode( 60 + "ascii" 61 + ) 62 + status, headers, body = await session.request( 63 + "GET", 64 + "/", 65 + headers={"authorization": f"Basic {auth}"}, 66 + ) 67 + assert status == 302 68 + assert headers["location"] == "/app/home/" 69 + 70 + status, headers, body = await session.request( 71 + "GET", 72 + "/app/link/api/status", 73 + headers={"authorization": f"Basic {auth}"}, 74 + ) 75 + assert status == 200 76 + assert headers["content-type"] == "application/json" 77 + status_payload = json.loads(body) 78 + assert status_payload["instance_id"] == identity.home_instance_id 79 + 80 + after = next( 81 + device 82 + for device in list_devices(base_url) 83 + if device["fingerprint"] == identity.fingerprint 84 + ) 85 + assert after["last_seen_at"] is not None 86 + 87 + unpaired = unpair_device(base_url, identity.fingerprint) 88 + assert unpaired["unpaired"] == identity.fingerprint 89 + 90 + await asyncio.sleep(1) 91 + failed = await Client.dial(RELAY_URL, enrolled) 92 + async with failed: 93 + with pytest.raises(StreamResetError): 94 + auth = base64.b64encode(f":{CONVEY_PASSWORD}".encode("utf-8")).decode( 95 + "ascii" 96 + ) 97 + await failed.request( 98 + "GET", 99 + "/app/link/api/status", 100 + headers={"authorization": f"Basic {auth}"}, 101 + )