personal memory agent
0

Configure Feed

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

solstone / tests / test_doctor.py
56 kB 1556 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4from __future__ import annotations 5 6import json 7import os 8import subprocess 9import sys 10from pathlib import Path 11from types import SimpleNamespace 12 13import pytest 14 15from solstone.think import install_guard 16from solstone.think.probe import ProbeOutput 17 18ROOT = Path(__file__).resolve().parent.parent 19 20 21@pytest.fixture 22def doctor(): 23 from solstone.think import doctor as doctor_module 24 25 yield doctor_module 26 27 28@pytest.fixture 29def home_root(monkeypatch, tmp_path): 30 home = tmp_path / "home" 31 home.mkdir() 32 monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) 33 return home 34 35 36def args(doctor, *, port: int = 5015): 37 return doctor.Args(verbose=False, json=False, jsonl=False, port=port) 38 39 40def make_repo(tmp_path: Path, *, worktree: bool = False) -> Path: 41 repo = tmp_path / "repo" 42 repo.mkdir() 43 if worktree: 44 (repo / ".git").write_text("gitdir: /tmp/worktree\n", encoding="utf-8") 45 else: 46 (repo / ".git").mkdir() 47 return repo 48 49 50def ensure_expected_target(repo: Path) -> Path: 51 target = repo / ".venv" / "bin" / "sol" 52 target.parent.mkdir(parents=True, exist_ok=True) 53 target.write_text("", encoding="utf-8") 54 return target 55 56 57def make_alias(home_root: Path, target: Path | str, binary: str = "sol") -> Path: 58 alias = home_root / ".local" / "bin" / binary 59 alias.parent.mkdir(parents=True, exist_ok=True) 60 alias.symlink_to(target) 61 return alias 62 63 64def other_target(tmp_path: Path) -> Path: 65 target = tmp_path / "other" / ".venv" / "bin" / "sol" 66 target.parent.mkdir(parents=True, exist_ok=True) 67 target.write_text("", encoding="utf-8") 68 return target 69 70 71def test_install_guard_import_succeeds_when_frontmatter_is_shadowed(tmp_path): 72 shadow_dir = tmp_path / "shadow" 73 shadow_dir.mkdir() 74 (shadow_dir / "frontmatter.py").write_text( 75 'raise ImportError("blocked for test")\n', 76 encoding="utf-8", 77 ) 78 env = os.environ.copy() 79 pythonpath_parts = [str(shadow_dir), str(ROOT)] 80 existing_pythonpath = env.get("PYTHONPATH") 81 if existing_pythonpath: 82 pythonpath_parts.append(existing_pythonpath) 83 env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts) 84 85 result = subprocess.run( 86 [ 87 sys.executable, 88 "-c", 89 "from solstone.think.install_guard import parse_wrapper; print('ok')", 90 ], 91 cwd=ROOT, 92 capture_output=True, 93 text=True, 94 check=False, 95 env=env, 96 ) 97 98 assert result.returncode == 0 99 assert result.stdout.strip() == "ok" 100 101 102class TestPythonVersion: 103 def test_ok(self, doctor): 104 result = doctor.python_sanity_check(args(doctor)) 105 assert result.status == "ok" 106 107 def test_fail_when_too_old(self, doctor, monkeypatch): 108 monkeypatch.setattr(doctor.sys, "version_info", (3, 9, 18)) 109 result = doctor.python_sanity_check(args(doctor)) 110 assert result.status == "fail" 111 assert "does not satisfy" in result.detail 112 113 114class TestSolImportable: 115 def test_skip_when_absent(self, doctor, monkeypatch, tmp_path): 116 monkeypatch.setattr(doctor, "ROOT", tmp_path) 117 result = doctor.sol_importable_check(args(doctor)) 118 assert result.status == "skip" 119 120 def test_ok(self, doctor, monkeypatch, tmp_path): 121 monkeypatch.setattr(doctor, "ROOT", tmp_path) 122 python_bin = tmp_path / ".venv" / "bin" / "python" 123 python_bin.parent.mkdir(parents=True) 124 python_bin.write_text("", encoding="utf-8") 125 monkeypatch.setattr( 126 doctor, 127 "run_probe", 128 lambda *_args, **_kwargs: ProbeOutput("", "", 0), 129 ) 130 result = doctor.sol_importable_check(args(doctor)) 131 assert result.status == "ok" 132 assert ( 133 result.detail 134 == "from solstone.think.sol_compat_cli import main succeeded outside repo cwd" 135 ) 136 137 def test_fail_on_module_not_found(self, doctor, monkeypatch, tmp_path): 138 monkeypatch.setattr(doctor, "ROOT", tmp_path) 139 python_bin = tmp_path / ".venv" / "bin" / "python" 140 python_bin.parent.mkdir(parents=True) 141 python_bin.write_text("", encoding="utf-8") 142 monkeypatch.setattr( 143 doctor, 144 "run_probe", 145 lambda *_args, **_kwargs: ProbeOutput( 146 "", 147 "Traceback (most recent call last):\nModuleNotFoundError: No module named 'solstone'\n", 148 1, 149 ), 150 ) 151 result = doctor.sol_importable_check(args(doctor)) 152 assert result.status == "fail" 153 assert result.detail == "ModuleNotFoundError: No module named 'solstone'" 154 155 def test_fail_on_other_exception(self, doctor, monkeypatch, tmp_path): 156 monkeypatch.setattr(doctor, "ROOT", tmp_path) 157 python_bin = tmp_path / ".venv" / "bin" / "python" 158 python_bin.parent.mkdir(parents=True) 159 python_bin.write_text("", encoding="utf-8") 160 monkeypatch.setattr( 161 doctor, 162 "run_probe", 163 lambda *_args, **_kwargs: ProbeOutput( 164 "", "SyntaxError: broken import\n", 1 165 ), 166 ) 167 result = doctor.sol_importable_check(args(doctor)) 168 assert result.status == "fail" 169 assert result.detail == "SyntaxError: broken import" 170 171 172class TestPackagedInstall: 173 def setup_packaged(self, doctor, monkeypatch, tmp_path): 174 monkeypatch.setattr(doctor, "ROOT", tmp_path) 175 monkeypatch.setattr(doctor, "is_packaged_install", lambda: True) 176 177 def test_python_version_uses_metadata_when_pyproject_absent( 178 self, doctor, monkeypatch, tmp_path 179 ): 180 self.setup_packaged(doctor, monkeypatch, tmp_path) 181 monkeypatch.setattr( 182 doctor, 183 "distribution", 184 lambda name: SimpleNamespace(metadata={"Requires-Python": ">=3.11"}), 185 ) 186 187 result = doctor.python_sanity_check(args(doctor)) 188 189 assert result.status == "ok" 190 assert ">=3.11" in result.detail 191 192 def test_sol_importable_uses_in_process_import(self, doctor, monkeypatch, tmp_path): 193 self.setup_packaged(doctor, monkeypatch, tmp_path) 194 195 result = doctor.sol_importable_check(args(doctor)) 196 197 assert result.status == "ok" 198 assert result.detail == "import solstone succeeded in packaged install" 199 200 201class TestDefaultSttReady: 202 @staticmethod 203 def setup_linux_parakeet_default(doctor, monkeypatch, tmp_path): 204 journal = tmp_path / "journal" 205 monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) 206 monkeypatch.setattr( 207 doctor.parakeet_readiness, 208 "_platform_info", 209 lambda: ("linux", "x86_64"), 210 ) 211 return journal 212 213 @staticmethod 214 def make_ready_files(doctor, cache_root: Path, artifact_key: str) -> None: 215 for backend in doctor.parakeet_readiness.PARAKEET_CPP_BINARY_BACKENDS: 216 path = doctor.parakeet_readiness.parakeet_cpp_binary_path( 217 cache_root, artifact_key, backend 218 ) 219 path.parent.mkdir(parents=True, exist_ok=True) 220 path.write_text("#!/bin/sh\n", encoding="utf-8") 221 path.chmod(0o755) 222 model_path = doctor.parakeet_readiness.parakeet_cpp_model_path(cache_root) 223 model_path.parent.mkdir(parents=True, exist_ok=True) 224 model_path.write_text("model", encoding="utf-8") 225 226 def test_ok_when_linux_runtime_and_model_ready(self, doctor, monkeypatch, tmp_path): 227 from solstone.think.providers import parakeet_server 228 229 journal = self.setup_linux_parakeet_default(doctor, monkeypatch, tmp_path) 230 artifact_key = "x86_64-unknown-linux-gnu" 231 cache_root = doctor.parakeet_readiness.parakeet_cpp_cache_root(journal) 232 self.make_ready_files(doctor, cache_root, artifact_key) 233 monkeypatch.setattr( 234 parakeet_server, 235 "probe_state", 236 lambda: (parakeet_server.STATE_READY, None), 237 ) 238 239 result = doctor.default_stt_ready_check(args(doctor)) 240 241 assert result.status == "ok" 242 assert result.detail == ( 243 "parakeet-cpp ready (binaries + model installed, server reachable)" 244 ) 245 246 def test_warns_when_linux_cpp_artifacts_missing( 247 self, doctor, monkeypatch, tmp_path 248 ): 249 self.setup_linux_parakeet_default(doctor, monkeypatch, tmp_path) 250 251 result = doctor.default_stt_ready_check(args(doctor)) 252 253 assert result.status == "warn" 254 assert "missing" in result.detail 255 assert result.fix == doctor._PARAKEET_CPP_INSTALL_FIX 256 257 def test_warns_when_linux_cpp_server_not_started( 258 self, doctor, monkeypatch, tmp_path 259 ): 260 from solstone.think.providers import parakeet_server 261 262 journal = self.setup_linux_parakeet_default(doctor, monkeypatch, tmp_path) 263 artifact_key = "x86_64-unknown-linux-gnu" 264 cache_root = doctor.parakeet_readiness.parakeet_cpp_cache_root(journal) 265 self.make_ready_files(doctor, cache_root, artifact_key) 266 monkeypatch.setattr( 267 parakeet_server, 268 "probe_state", 269 lambda: (parakeet_server.STATE_FAILED, "no port"), 270 ) 271 272 result = doctor.default_stt_ready_check(args(doctor)) 273 274 assert result.status == "warn" 275 assert result.detail == "parakeet-server not reachable: no port" 276 assert result.fix == doctor._PARAKEET_CPP_START_FIX 277 278 def test_default_stt_fix_strings_are_distinct(self, doctor): 279 assert doctor._PARAKEET_CPP_INSTALL_FIX != doctor._PARAKEET_CPP_START_FIX 280 281 def test_skips_when_configured_backend_is_not_parakeet( 282 self, doctor, monkeypatch, tmp_path 283 ): 284 journal = tmp_path / "journal" 285 config_path = journal / "config" / "journal.json" 286 config_path.parent.mkdir(parents=True) 287 config_path.write_text( 288 json.dumps({"transcribe": {"backend": "confidential"}}), 289 encoding="utf-8", 290 ) 291 monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) 292 monkeypatch.setattr( 293 doctor.parakeet_readiness, 294 "_platform_info", 295 lambda: pytest.fail("platform should not be checked"), 296 ) 297 298 result = doctor.default_stt_ready_check(args(doctor)) 299 300 assert result.status == "skip" 301 assert "confidential" in result.detail 302 303 def test_journal_less_host_defaults_to_parakeet_without_creating_journal( 304 self, doctor, monkeypatch, tmp_path 305 ): 306 journal = tmp_path / "missing-journal" 307 monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) 308 monkeypatch.setattr( 309 doctor.parakeet_readiness, 310 "_platform_info", 311 lambda: ("linux", "x86_64"), 312 ) 313 314 result = doctor.default_stt_ready_check(args(doctor)) 315 316 assert result.status == "warn" 317 assert "missing" in result.detail 318 assert "configured backend" not in result.detail 319 assert not journal.exists() 320 321 def test_skips_arm64_linux_in_runner(self, doctor, monkeypatch, tmp_path): 322 self.setup_linux_parakeet_default(doctor, monkeypatch, tmp_path) 323 monkeypatch.setattr( 324 doctor.parakeet_readiness, 325 "_platform_info", 326 lambda: ("linux", "aarch64"), 327 ) 328 329 result = doctor.default_stt_ready_check(args(doctor)) 330 331 assert result.status == "skip" 332 assert result.detail == "parakeet not supported on this platform" 333 334 def test_registered_for_journal_and_journal_readiness_only(self, doctor): 335 journal_names = {check.name for check, _runner in doctor.JOURNAL_CHECKS} 336 readiness_names = {check.name for check, _runner in doctor.READINESS_CHECKS} 337 journal_readiness_names = { 338 check.name for check, _runner in doctor.JOURNAL_READINESS_CHECKS 339 } 340 universal_names = {check.name for check, _runner in doctor.UNIVERSAL_CHECKS} 341 342 assert "default_stt_ready" in journal_names 343 assert "default_stt_ready" not in readiness_names 344 assert "default_stt_ready" in journal_readiness_names 345 assert "default_stt_ready" not in universal_names 346 assert doctor.DEFAULT_STT_READY_CHECK in { 347 check for check, _runner in doctor.JOURNAL_CHECKS 348 } 349 assert doctor.DEFAULT_STT_READY_CHECK in { 350 check for check, _runner in doctor.JOURNAL_READINESS_CHECKS 351 } 352 assert doctor.DEFAULT_STT_READY_CHECK not in { 353 check for check, _runner in doctor.READINESS_CHECKS 354 } 355 assert doctor.CHECK_MAP["default_stt_ready"].severity == "advisory" 356 357 358class TestParakeetCppSttReady: 359 @staticmethod 360 def make_ready_files(doctor, cache_root: Path, artifact_key: str) -> None: 361 for backend in doctor.parakeet_readiness.PARAKEET_CPP_BINARY_BACKENDS: 362 path = doctor.parakeet_readiness.parakeet_cpp_binary_path( 363 cache_root, artifact_key, backend 364 ) 365 path.parent.mkdir(parents=True, exist_ok=True) 366 path.write_text("#!/bin/sh\n", encoding="utf-8") 367 path.chmod(0o755) 368 model_path = doctor.parakeet_readiness.parakeet_cpp_model_path(cache_root) 369 model_path.parent.mkdir(parents=True, exist_ok=True) 370 model_path.write_text("model", encoding="utf-8") 371 372 def test_registered_for_journal_and_journal_readiness_only(self, doctor): 373 pair = ( 374 doctor.PARAKEET_CPP_STT_READY_CHECK, 375 doctor.parakeet_cpp_stt_ready_check, 376 ) 377 378 assert doctor.PARAKEET_CPP_STT_READY_CHECK.name in doctor.CHECK_MAP 379 assert pair in doctor.JOURNAL_CHECKS 380 assert pair in doctor.JOURNAL_READINESS_CHECKS 381 assert pair not in doctor.UNIVERSAL_CHECKS 382 assert pair not in doctor.READINESS_CHECKS 383 384 def test_skips_when_backend_is_not_parakeet_cpp(self, doctor, monkeypatch): 385 monkeypatch.setattr(doctor, "_resolve_configured_backend", lambda: "parakeet") 386 387 result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) 388 389 assert result.status == "skip" 390 assert ( 391 result.detail 392 == "configured backend is not parakeet-cpp; check not applicable" 393 ) 394 395 def test_skips_off_linux(self, doctor, monkeypatch): 396 monkeypatch.setattr( 397 doctor, "_resolve_configured_backend", lambda: "parakeet-cpp" 398 ) 399 monkeypatch.setattr( 400 doctor.parakeet_readiness, 401 "_platform_info", 402 lambda: ("darwin", "arm64"), 403 ) 404 405 result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) 406 407 assert result.status == "skip" 408 assert result.detail == "parakeet-cpp is only supported on Linux" 409 410 def test_warns_with_install_fix_when_files_missing( 411 self, doctor, monkeypatch, tmp_path 412 ): 413 journal = tmp_path / "journal" 414 monkeypatch.setattr( 415 doctor, "_resolve_configured_backend", lambda: "parakeet-cpp" 416 ) 417 monkeypatch.setattr( 418 doctor.parakeet_readiness, 419 "_platform_info", 420 lambda: ("linux", "x86_64"), 421 ) 422 monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) 423 424 result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) 425 426 assert result.status == "warn" 427 assert "missing" in result.detail 428 assert result.fix == doctor._PARAKEET_CPP_INSTALL_FIX 429 430 def test_warns_with_start_fix_when_server_not_ready( 431 self, doctor, monkeypatch, tmp_path 432 ): 433 from solstone.think.providers import parakeet_server 434 435 journal = tmp_path / "journal" 436 artifact_key = "x86_64-unknown-linux-gnu" 437 cache_root = doctor.parakeet_readiness.parakeet_cpp_cache_root(journal) 438 self.make_ready_files(doctor, cache_root, artifact_key) 439 monkeypatch.setattr( 440 doctor, "_resolve_configured_backend", lambda: "parakeet-cpp" 441 ) 442 monkeypatch.setattr( 443 doctor.parakeet_readiness, 444 "_platform_info", 445 lambda: ("linux", "x86_64"), 446 ) 447 monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) 448 monkeypatch.setattr( 449 parakeet_server, 450 "probe_state", 451 lambda: (parakeet_server.STATE_FAILED, "no port"), 452 ) 453 454 result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) 455 456 assert result.status == "warn" 457 assert result.detail == "parakeet-server not reachable: no port" 458 assert result.fix == doctor._PARAKEET_CPP_START_FIX 459 460 def test_ok_when_files_present_and_server_ready( 461 self, doctor, monkeypatch, tmp_path 462 ): 463 from solstone.think.providers import parakeet_server 464 465 journal = tmp_path / "journal" 466 artifact_key = "x86_64-unknown-linux-gnu" 467 cache_root = doctor.parakeet_readiness.parakeet_cpp_cache_root(journal) 468 self.make_ready_files(doctor, cache_root, artifact_key) 469 monkeypatch.setattr( 470 doctor, "_resolve_configured_backend", lambda: "parakeet-cpp" 471 ) 472 monkeypatch.setattr( 473 doctor.parakeet_readiness, 474 "_platform_info", 475 lambda: ("linux", "x86_64"), 476 ) 477 monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) 478 monkeypatch.setattr( 479 parakeet_server, 480 "probe_state", 481 lambda: (parakeet_server.STATE_READY, None), 482 ) 483 484 result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) 485 486 assert result.status == "ok" 487 assert result.detail == ( 488 "parakeet-cpp ready (binaries + model installed, server reachable)" 489 ) 490 491 def test_does_not_create_absent_journal_when_not_configured( 492 self, doctor, monkeypatch, tmp_path 493 ): 494 journal = tmp_path / "missing-journal" 495 monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) 496 497 result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) 498 499 assert result.status == "skip" 500 assert not journal.exists() 501 502 503class TestHostDependencies: 504 def test_client_readiness_battery_is_host_free(self, doctor, monkeypatch): 505 expected = [ 506 "python_version", 507 "sol_importable", 508 "local_bin_sol_reachable", 509 "stale_alias_symlink", 510 "disk_space", 511 "journal_dir_writable", 512 ] 513 readiness_names = [check.name for check, _runner in doctor.READINESS_CHECKS] 514 515 assert readiness_names == expected 516 for name in [ 517 "host_dependencies", 518 "default_stt_ready", 519 "feature:pdf-import", 520 "feature:pdf-export", 521 ]: 522 assert name not in readiness_names 523 524 monkeypatch.setattr(doctor.sys, "argv", ["sol doctor"]) 525 assert "host_dependencies" not in { 526 check.name for check, _runner in doctor.select_battery(args(doctor)) 527 } 528 assert "host_dependencies" not in { 529 check.name 530 for check, _runner in doctor.select_battery( 531 doctor.parse_args(["--readiness"]) 532 ) 533 } 534 535 def test_journal_readiness_selects_host_stack_first(self, doctor, monkeypatch): 536 parsed = doctor.parse_args(["--readiness"]) 537 monkeypatch.setattr(doctor.sys, "argv", ["journal doctor"]) 538 539 selected = doctor.select_battery(parsed) 540 541 assert selected is doctor.JOURNAL_READINESS_CHECKS 542 assert selected[0][0] is doctor.HOST_DEPENDENCIES_CHECK 543 assert selected[0][0].name == "host_dependencies" 544 assert "default_stt_ready" in {check.name for check, _runner in selected} 545 assert "feature:pdf-import" in {check.name for check, _runner in selected} 546 assert "feature:pdf-export" in {check.name for check, _runner in selected} 547 548 def test_host_dependencies_registered_in_journal_and_check_map(self, doctor): 549 assert "host_dependencies" in { 550 check.name for check, _runner in doctor.JOURNAL_CHECKS 551 } 552 assert "host_dependencies" in { 553 check.name for check, _runner in doctor.JOURNAL_READINESS_CHECKS 554 } 555 assert "host_dependencies" not in { 556 check.name for check, _runner in doctor.READINESS_CHECKS 557 } 558 assert doctor.CHECK_MAP["host_dependencies"].severity == "blocker" 559 560 def test_maint_tasks_registered_in_journal_and_check_map(self, doctor): 561 pair = ( 562 doctor.JOURNAL_MAINT_TASKS_CHECK, 563 doctor.journal_maint_tasks_check, 564 ) 565 566 assert doctor.JOURNAL_MAINT_TASKS_CHECK.name == "journal_maint_tasks" 567 assert doctor.JOURNAL_MAINT_TASKS_CHECK.name in doctor.CHECK_MAP 568 assert doctor.CHECK_MAP["journal_maint_tasks"].severity == "blocker" 569 assert pair in doctor.JOURNAL_CHECKS 570 assert pair not in doctor.UNIVERSAL_CHECKS 571 assert pair not in doctor.READINESS_CHECKS 572 assert pair not in doctor.JOURNAL_READINESS_CHECKS 573 574 def test_host_dependencies_ok_when_present(self, doctor, monkeypatch): 575 monkeypatch.setattr(doctor, "_host_module_present", lambda _module: True) 576 577 result = doctor.host_dependencies_check(args(doctor)) 578 579 assert result.status == "ok" 580 assert result.detail == ( 581 "journal host dependencies present: python-frontmatter, Flask, ONNX runtime" 582 ) 583 584 @pytest.mark.parametrize( 585 ("missing_module", "label"), 586 [ 587 ("frontmatter", "python-frontmatter"), 588 ("flask", "Flask"), 589 ("onnxruntime", "ONNX runtime"), 590 ], 591 ) 592 def test_host_dependencies_fail_when_find_spec_returns_none( 593 self, doctor, monkeypatch, missing_module, label 594 ): 595 def fake_find_spec(module): 596 return None if module == missing_module else object() 597 598 monkeypatch.setattr(doctor.importlib.util, "find_spec", fake_find_spec) 599 600 result = doctor.host_dependencies_check(args(doctor)) 601 602 assert result.status == "fail" 603 assert result.severity == "blocker" 604 assert label in result.detail 605 assert "journal host stack incomplete" in result.detail 606 assert result.fix == doctor.HOST_DEPENDENCY_REINSTALL_GUIDANCE 607 608 def test_host_dependencies_fail_when_find_spec_raises(self, doctor, monkeypatch): 609 def fake_find_spec(module): 610 if module == "frontmatter": 611 raise ModuleNotFoundError("No module named 'frontmatter'") 612 return object() 613 614 monkeypatch.setattr(doctor.importlib.util, "find_spec", fake_find_spec) 615 616 result = doctor.host_dependencies_check(args(doctor)) 617 618 assert result.status == "fail" 619 assert "python-frontmatter" in result.detail 620 assert result.fix == doctor.HOST_DEPENDENCY_REINSTALL_GUIDANCE 621 622 623class TestPackagingChecks: 624 def set_versions(self, doctor, monkeypatch, **overrides): 625 versions = { 626 "solstone": None, 627 "solstone-journal": None, 628 "solstone-journal-cuda": None, 629 "solstone-journal-host": None, 630 } 631 versions.update(overrides) 632 monkeypatch.setattr(doctor, "_installed_packaging_versions", lambda: versions) 633 634 def test_leaf_exclusivity_fails_when_both_leaves_installed( 635 self, doctor, monkeypatch 636 ): 637 self.set_versions( 638 doctor, 639 monkeypatch, 640 **{ 641 "solstone-journal": "9.9.9", 642 "solstone-journal-cuda": "9.9.8", 643 }, 644 ) 645 646 result = doctor.journal_leaf_exclusivity_check(args(doctor)) 647 648 assert result.status == "fail" 649 assert result.severity == "blocker" 650 assert "solstone-journal 9.9.9" in result.detail 651 assert "solstone-journal-cuda 9.9.8" in result.detail 652 assert "CPU and CUDA ONNX runtimes own the same files" in result.detail 653 654 @pytest.mark.parametrize( 655 ("leaf_name", "other_leaf"), 656 [ 657 ("solstone-journal", "solstone-journal-cuda"), 658 ("solstone-journal-cuda", "solstone-journal"), 659 ], 660 ) 661 def test_package_version_fails_when_leaf_mismatches_solstone( 662 self, doctor, monkeypatch, leaf_name, other_leaf 663 ): 664 self.set_versions( 665 doctor, 666 monkeypatch, 667 solstone="9.9.9", 668 **{leaf_name: "9.9.8"}, 669 ) 670 671 result = doctor.journal_package_version_check(args(doctor)) 672 673 assert result.status == "fail" 674 assert result.severity == "blocker" 675 assert leaf_name in result.detail 676 if other_leaf == "solstone-journal": 677 assert "solstone-journal " not in result.detail 678 else: 679 assert other_leaf not in result.detail 680 assert "9.9.9" in result.detail 681 assert "9.9.8" in result.detail 682 assert leaf_name in (result.fix or "") 683 684 def test_retired_host_shim_warns_alongside_leaf(self, doctor, monkeypatch): 685 self.set_versions( 686 doctor, 687 monkeypatch, 688 **{ 689 "solstone-journal": "9.9.9", 690 "solstone-journal-host": "0.7.0", 691 }, 692 ) 693 694 result = doctor.retired_host_shim_check(args(doctor)) 695 696 assert result.status == "warn" 697 assert result.severity == "advisory" 698 assert "solstone-journal-host 0.7.0" in result.detail 699 assert "solstone-journal 9.9.9" in result.detail 700 assert result.fix == "pip uninstall solstone-journal-host" 701 702 def test_healthy_cuda_leaf_is_ok(self, doctor, monkeypatch): 703 self.set_versions( 704 doctor, 705 monkeypatch, 706 solstone="9.9.9", 707 **{"solstone-journal-cuda": "9.9.9"}, 708 ) 709 710 exclusivity = doctor.journal_leaf_exclusivity_check(args(doctor)) 711 version = doctor.journal_package_version_check(args(doctor)) 712 shim = doctor.retired_host_shim_check(args(doctor)) 713 714 assert exclusivity.status == "ok" 715 assert version.status == "ok" 716 assert shim.status == "ok" 717 718 def test_journal_readiness_json_fails_on_missing_host_dependency( 719 self, doctor, monkeypatch, capsys 720 ): 721 def fake_find_spec(module): 722 return None if module == "frontmatter" else object() 723 724 monkeypatch.setattr(doctor.importlib.util, "find_spec", fake_find_spec) 725 monkeypatch.setattr(doctor.sys, "argv", ["journal doctor"]) 726 727 rc = doctor.main(["--readiness", "--json"]) 728 payload = json.loads(capsys.readouterr().out) 729 host_check = next( 730 check for check in payload["checks"] if check["name"] == "host_dependencies" 731 ) 732 733 assert rc == 1 734 assert host_check["severity"] == "blocker" 735 assert host_check["status"] == "fail" 736 assert "python-frontmatter" in host_check["detail"] 737 assert host_check["fix"] == doctor.HOST_DEPENDENCY_REINSTALL_GUIDANCE 738 739 def test_journal_readiness_jsonl_fails_on_missing_host_dependency( 740 self, doctor, monkeypatch, capsys 741 ): 742 def fake_find_spec(module): 743 return None if module == "flask" else object() 744 745 monkeypatch.setattr(doctor.importlib.util, "find_spec", fake_find_spec) 746 monkeypatch.setattr(doctor.sys, "argv", ["journal doctor"]) 747 748 rc = doctor.main(["--readiness", "--jsonl"]) 749 events = [json.loads(line) for line in capsys.readouterr().out.splitlines()] 750 host_index, host_event = next( 751 (index, event) 752 for index, event in enumerate(events) 753 if event["event"] == "check.completed" 754 and event["name"] == "host_dependencies" 755 ) 756 completed_index = next( 757 index 758 for index, event in enumerate(events) 759 if event["event"] == "doctor.completed" 760 ) 761 762 assert rc == 1 763 assert host_index < completed_index 764 assert host_event["severity"] == "blocker" 765 assert host_event["status"] == "failed" 766 assert "Flask" in host_event["detail"] 767 assert host_event["fix"] == doctor.HOST_DEPENDENCY_REINSTALL_GUIDANCE 768 assert events[completed_index]["status"] == "failed" 769 770 771class TestPortCheckRemoved: 772 def test_port_check_is_not_registered(self, doctor): 773 assert "port_5015_free" not in doctor.CHECK_MAP 774 assert "port_5015_free" not in { 775 check.name for check, _runner in doctor.UNIVERSAL_CHECKS 776 } 777 778 779def test_default_universal_battery_check_names(doctor): 780 assert {check.name for check, _runner in doctor.UNIVERSAL_CHECKS} == { 781 "python_version", 782 "sol_importable", 783 "journal_leaf_exclusivity", 784 "journal_package_version", 785 "retired_host_shim", 786 "local_bin_sol_reachable", 787 "stale_alias_symlink", 788 "skill_state", 789 } 790 791 792class TestStaleAliasSymlink: 793 @pytest.fixture(autouse=True) 794 def isolated_legacy_backups(self, doctor, monkeypatch, tmp_path): 795 backup_dir = tmp_path / "legacy-backups" 796 backup_dir.mkdir() 797 monkeypatch.setattr(install_guard, "_legacy_backup_dir", lambda: backup_dir) 798 self.backup_dir = backup_dir 799 800 def setup_import(self, doctor, monkeypatch): 801 monkeypatch.setattr( 802 doctor, 803 "import_install_guard", 804 lambda: (install_guard.AliasState, install_guard.check_alias), 805 ) 806 807 @staticmethod 808 def make_existing_target(path: Path) -> Path: 809 path.parent.mkdir(parents=True, exist_ok=True) 810 path.write_text("", encoding="utf-8") 811 return path 812 813 @staticmethod 814 def write_executable_script(path: Path, content: str = "#!/bin/sh\n") -> Path: 815 path.parent.mkdir(parents=True, exist_ok=True) 816 path.write_text(content, encoding="utf-8") 817 path.chmod(0o755) 818 return path 819 820 def patch_runtime_bin(self, monkeypatch, tmp_path: Path) -> Path: 821 bin_dir = tmp_path / "runtime" / "bin" 822 for binary in ("python", "sol", "journal"): 823 self.write_executable_script(bin_dir / binary) 824 monkeypatch.setattr(sys, "executable", str(bin_dir / "python")) 825 return bin_dir 826 827 @staticmethod 828 def write_regular_alias(home_root: Path, binary: str, content: str) -> Path: 829 alias = home_root / ".local" / "bin" / binary 830 alias.parent.mkdir(parents=True, exist_ok=True) 831 alias.write_text(content, encoding="utf-8") 832 alias.chmod(0o755) 833 return alias 834 835 @staticmethod 836 def assert_foreign_alias_warning(result, binary: str) -> None: 837 assert result.status == "warn" 838 assert result.detail == f"~/.local/bin/{binary} exists but is not a symlink" 839 840 def run_app_owned_check( 841 self, 842 doctor, 843 monkeypatch, 844 home_root: Path, 845 tmp_path: Path, 846 binary: str, 847 content: str, 848 ): 849 self.setup_import(doctor, monkeypatch) 850 repo = make_repo(tmp_path) 851 self.write_regular_alias(home_root, binary, content) 852 monkeypatch.setattr(doctor, "ROOT", repo) 853 return doctor.stale_alias_symlink_check(args(doctor), binary=binary) 854 855 @staticmethod 856 def legacy_target( 857 home_root: Path, legacy_parts: tuple[str, ...], binary: str 858 ) -> Path: 859 return home_root.joinpath(*legacy_parts) / "bin" / binary 860 861 def assert_report_only_alias( 862 self, 863 result, 864 alias: Path, 865 original_target: str, 866 expected_tag: str, 867 other_binary: str, 868 home_root: Path, 869 ) -> None: 870 assert result.status == "warn" 871 assert expected_tag in result.detail 872 assert result.fix is not None 873 assert "journal setup" in result.fix 874 assert alias.is_symlink() 875 assert os.readlink(alias) == original_target 876 assert list(self.backup_dir.glob("*.old-symlink-*")) == [] 877 assert not (home_root / ".local" / "bin" / other_binary).exists() 878 879 @pytest.mark.parametrize("binary", ("sol", "journal")) 880 def test_app_owned_child_launcher_ok( 881 self, doctor, monkeypatch, home_root, tmp_path, binary 882 ): 883 bin_dir = self.patch_runtime_bin(monkeypatch, tmp_path) 884 expected = bin_dir / binary 885 foreign = self.write_executable_script( 886 tmp_path / "foreign-runtime" / "bin" / binary 887 ) 888 assert expected != foreign 889 assert os.access(expected, os.X_OK) 890 assert os.access(foreign, os.X_OK) 891 content = install_guard.render_app_owned_child_launcher(str(expected), binary) 892 893 result = self.run_app_owned_check( 894 doctor, monkeypatch, home_root, tmp_path, binary, content 895 ) 896 897 assert result.status == "ok" 898 assert "is not a symlink" not in result.detail 899 assert result.fix is None 900 901 @pytest.mark.parametrize("binary", ("sol", "journal")) 902 def test_app_owned_child_launcher_marker_only_warns( 903 self, doctor, monkeypatch, home_root, tmp_path, binary 904 ): 905 self.patch_runtime_bin(monkeypatch, tmp_path) 906 content = "#!/bin/sh\n# managed-version: app-owned-child\n" 907 908 result = self.run_app_owned_check( 909 doctor, monkeypatch, home_root, tmp_path, binary, content 910 ) 911 912 self.assert_foreign_alias_warning(result, binary) 913 914 @pytest.mark.parametrize("binary", ("sol", "journal")) 915 def test_app_owned_child_launcher_malformed_body_warns( 916 self, doctor, monkeypatch, home_root, tmp_path, binary 917 ): 918 self.patch_runtime_bin(monkeypatch, tmp_path) 919 content = ( 920 '#!/bin/sh\n# managed-version: app-owned-child\nexec /not-quoted "$@"\n' 921 ) 922 923 result = self.run_app_owned_check( 924 doctor, monkeypatch, home_root, tmp_path, binary, content 925 ) 926 927 self.assert_foreign_alias_warning(result, binary) 928 929 @pytest.mark.parametrize("binary", ("sol", "journal")) 930 def test_app_owned_child_launcher_other_binary_warns( 931 self, doctor, monkeypatch, home_root, tmp_path, binary 932 ): 933 bin_dir = self.patch_runtime_bin(monkeypatch, tmp_path) 934 other_binary = "journal" if binary == "sol" else "sol" 935 content = install_guard.render_app_owned_child_launcher( 936 str(bin_dir / other_binary), 937 other_binary, 938 ) 939 940 result = self.run_app_owned_check( 941 doctor, monkeypatch, home_root, tmp_path, binary, content 942 ) 943 944 self.assert_foreign_alias_warning(result, binary) 945 946 @pytest.mark.parametrize("binary", ("sol", "journal")) 947 def test_app_owned_child_launcher_missing_target_warns( 948 self, doctor, monkeypatch, home_root, tmp_path, binary 949 ): 950 bin_dir = self.patch_runtime_bin(monkeypatch, tmp_path) 951 expected = bin_dir / binary 952 content = install_guard.render_app_owned_child_launcher(str(expected), binary) 953 expected.unlink() 954 955 result = self.run_app_owned_check( 956 doctor, monkeypatch, home_root, tmp_path, binary, content 957 ) 958 959 self.assert_foreign_alias_warning(result, binary) 960 961 @pytest.mark.parametrize("binary", ("sol", "journal")) 962 def test_app_owned_child_launcher_non_executable_target_warns( 963 self, doctor, monkeypatch, home_root, tmp_path, binary 964 ): 965 bin_dir = self.patch_runtime_bin(monkeypatch, tmp_path) 966 expected = bin_dir / binary 967 content = install_guard.render_app_owned_child_launcher(str(expected), binary) 968 expected.chmod(0o644) 969 970 result = self.run_app_owned_check( 971 doctor, monkeypatch, home_root, tmp_path, binary, content 972 ) 973 974 self.assert_foreign_alias_warning(result, binary) 975 976 @pytest.mark.parametrize("binary", ("sol", "journal")) 977 def test_app_owned_child_launcher_outside_executable_target_warns( 978 self, doctor, monkeypatch, home_root, tmp_path, binary 979 ): 980 bin_dir = self.patch_runtime_bin(monkeypatch, tmp_path) 981 expected = bin_dir / binary 982 foreign = self.write_executable_script( 983 tmp_path / "foreign-runtime" / "bin" / binary 984 ) 985 assert expected != foreign 986 content = install_guard.render_app_owned_child_launcher(str(foreign), binary) 987 988 result = self.run_app_owned_check( 989 doctor, monkeypatch, home_root, tmp_path, binary, content 990 ) 991 992 self.assert_foreign_alias_warning(result, binary) 993 994 @pytest.mark.parametrize("binary", ("sol", "journal")) 995 def test_app_owned_child_launcher_outside_symlink_to_expected_target_warns( 996 self, doctor, monkeypatch, home_root, tmp_path, binary 997 ): 998 bin_dir = self.patch_runtime_bin(monkeypatch, tmp_path) 999 expected = bin_dir / binary 1000 outside = tmp_path / "foreign-runtime" / "bin" / binary 1001 outside.parent.mkdir(parents=True, exist_ok=True) 1002 outside.symlink_to(expected) 1003 assert outside.resolve() == expected 1004 content = install_guard.render_app_owned_child_launcher(str(outside), binary) 1005 1006 result = self.run_app_owned_check( 1007 doctor, monkeypatch, home_root, tmp_path, binary, content 1008 ) 1009 1010 self.assert_foreign_alias_warning(result, binary) 1011 1012 def test_absent_ok(self, doctor, monkeypatch, home_root, tmp_path): 1013 self.setup_import(doctor, monkeypatch) 1014 repo = make_repo(tmp_path) 1015 monkeypatch.setattr(doctor, "ROOT", repo) 1016 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") 1017 assert result.status == "ok" 1018 1019 def test_owned_ok(self, doctor, monkeypatch, home_root, tmp_path): 1020 self.setup_import(doctor, monkeypatch) 1021 repo = make_repo(tmp_path) 1022 make_alias(home_root, ensure_expected_target(repo)) 1023 monkeypatch.setattr(doctor, "ROOT", repo) 1024 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") 1025 assert result.status == "ok" 1026 1027 def test_cross_repo_warns(self, doctor, monkeypatch, home_root, tmp_path): 1028 self.setup_import(doctor, monkeypatch) 1029 repo = make_repo(tmp_path) 1030 make_alias(home_root, other_target(tmp_path)) 1031 monkeypatch.setattr(doctor, "ROOT", repo) 1032 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") 1033 assert result.status == "warn" 1034 assert list(self.backup_dir.glob("sol.old-symlink-*")) == [] 1035 1036 def test_dangling_warns(self, doctor, monkeypatch, home_root, tmp_path): 1037 self.setup_import(doctor, monkeypatch) 1038 repo = make_repo(tmp_path) 1039 missing = tmp_path / "missing" / ".venv" / "bin" / "sol" 1040 make_alias(home_root, missing) 1041 monkeypatch.setattr(doctor, "ROOT", repo) 1042 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") 1043 assert result.status == "warn" 1044 assert (home_root / ".local" / "bin" / "sol").is_symlink() 1045 1046 def test_not_symlink_warns(self, doctor, monkeypatch, home_root, tmp_path): 1047 self.setup_import(doctor, monkeypatch) 1048 repo = make_repo(tmp_path) 1049 alias = home_root / ".local" / "bin" / "sol" 1050 alias.parent.mkdir(parents=True, exist_ok=True) 1051 alias.write_text("not a symlink", encoding="utf-8") 1052 monkeypatch.setattr(doctor, "ROOT", repo) 1053 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") 1054 assert result.status == "warn" 1055 assert list(self.backup_dir.glob("sol.old-symlink-*")) == [] 1056 1057 def test_worktree_skip(self, doctor, monkeypatch, home_root, tmp_path): 1058 self.setup_import(doctor, monkeypatch) 1059 repo = make_repo(tmp_path, worktree=True) 1060 monkeypatch.setattr(doctor, "ROOT", repo) 1061 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") 1062 assert result.status == "skip" 1063 1064 def test_import_failure_skips(self, doctor, monkeypatch): 1065 monkeypatch.setattr( 1066 doctor, 1067 "import_install_guard", 1068 lambda: (_ for _ in ()).throw(ImportError("boom")), 1069 ) 1070 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") 1071 assert result.status == "skip" 1072 assert "could not import solstone.think.install_guard" in result.detail 1073 1074 @pytest.mark.parametrize( 1075 ("legacy_parts", "expected_tag"), 1076 [ 1077 ((".local", "share", "uv", "tools", "solstone"), "uv-tool"), 1078 ((".local", "share", "pipx", "venvs", "solstone"), "pipx-xdg"), 1079 ((".local", "pipx", "venvs", "solstone"), "pipx-legacy"), 1080 ], 1081 ) 1082 @pytest.mark.parametrize("binary", ("sol", "journal")) 1083 def test_legacy_alias_reports_only( 1084 self, 1085 doctor, 1086 monkeypatch, 1087 home_root, 1088 tmp_path, 1089 legacy_parts, 1090 expected_tag, 1091 binary, 1092 ): 1093 self.setup_import(doctor, monkeypatch) 1094 repo = make_repo(tmp_path) 1095 target = self.make_existing_target( 1096 self.legacy_target(home_root, legacy_parts, binary) 1097 ) 1098 alias = make_alias(home_root, target, binary=binary) 1099 original_target = os.readlink(alias) 1100 monkeypatch.setattr(doctor, "ROOT", repo) 1101 1102 result = doctor.stale_alias_symlink_check(args(doctor), binary=binary) 1103 1104 other_binary = "journal" if binary == "sol" else "sol" 1105 self.assert_report_only_alias( 1106 result, 1107 alias, 1108 original_target, 1109 expected_tag, 1110 other_binary, 1111 home_root, 1112 ) 1113 1114 @pytest.mark.parametrize( 1115 ("legacy_parts", "expected_tag"), 1116 [ 1117 ((".local", "share", "uv", "tools", "solstone"), "uv-tool"), 1118 ((".local", "share", "pipx", "venvs", "solstone"), "pipx-xdg"), 1119 ], 1120 ) 1121 @pytest.mark.parametrize("binary", ("sol", "journal")) 1122 def test_dangling_legacy_alias_reports_only( 1123 self, 1124 doctor, 1125 monkeypatch, 1126 home_root, 1127 tmp_path, 1128 legacy_parts, 1129 expected_tag, 1130 binary, 1131 ): 1132 self.setup_import(doctor, monkeypatch) 1133 repo = make_repo(tmp_path) 1134 target = self.legacy_target(home_root, legacy_parts, binary) 1135 target.parent.mkdir(parents=True, exist_ok=True) 1136 alias = make_alias(home_root, target, binary=binary) 1137 original_target = os.readlink(alias) 1138 monkeypatch.setattr(doctor, "ROOT", repo) 1139 1140 result = doctor.stale_alias_symlink_check(args(doctor), binary=binary) 1141 1142 other_binary = "journal" if binary == "sol" else "sol" 1143 self.assert_report_only_alias( 1144 result, 1145 alias, 1146 original_target, 1147 expected_tag, 1148 other_binary, 1149 home_root, 1150 ) 1151 1152 def test_non_legacy_target_warns(self, doctor, monkeypatch, home_root, tmp_path): 1153 self.setup_import(doctor, monkeypatch) 1154 repo = make_repo(tmp_path) 1155 target = self.make_existing_target( 1156 tmp_path / "opt" / "random" / "foo" / "bin" / "sol" 1157 ) 1158 make_alias(home_root, target) 1159 monkeypatch.setattr(doctor, "ROOT", repo) 1160 1161 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") 1162 1163 assert result.status == "warn" 1164 assert list(self.backup_dir.glob("sol.old-symlink-*")) == [] 1165 1166 def test_partial_migration_recovery_detail( 1167 self, doctor, monkeypatch, home_root, tmp_path 1168 ): 1169 self.setup_import(doctor, monkeypatch) 1170 repo = make_repo(tmp_path) 1171 backup = self.backup_dir / "sol.old-symlink-20260101000000" 1172 backup.write_text("", encoding="utf-8") 1173 monkeypatch.setattr(doctor, "ROOT", repo) 1174 1175 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") 1176 1177 assert result.status == "warn" 1178 assert "partial migration detected" in result.detail 1179 assert str(backup) in result.detail 1180 1181 def test_journal_binary_not_owned_warns( 1182 self, doctor, monkeypatch, home_root, tmp_path 1183 ): 1184 self.setup_import(doctor, monkeypatch) 1185 repo = make_repo(tmp_path) 1186 target = tmp_path / "other" / ".venv" / "bin" / "journal" 1187 target.parent.mkdir(parents=True, exist_ok=True) 1188 target.write_text("", encoding="utf-8") 1189 make_alias(home_root, target, binary="journal") 1190 monkeypatch.setattr(doctor, "ROOT", repo) 1191 1192 result = doctor.stale_alias_symlink_check(args(doctor), binary="journal") 1193 1194 assert result.status == "warn" 1195 1196 def test_journal_battery_warn_does_not_fail_exit( 1197 self, doctor, monkeypatch, home_root, tmp_path, capsys 1198 ): 1199 self.setup_import(doctor, monkeypatch) 1200 repo = make_repo(tmp_path) 1201 target = tmp_path / "other" / ".venv" / "bin" / "journal" 1202 target.parent.mkdir(parents=True, exist_ok=True) 1203 target.write_text("", encoding="utf-8") 1204 make_alias(home_root, target, binary="journal") 1205 monkeypatch.setattr(doctor, "ROOT", repo) 1206 1207 def journal_stale_check(check_args): 1208 return doctor.stale_alias_symlink_check(check_args, binary="journal") 1209 1210 monkeypatch.setattr( 1211 doctor, 1212 "select_battery", 1213 lambda _args: [(doctor.STALE_ALIAS_CHECK, journal_stale_check)], 1214 ) 1215 1216 assert doctor.main([]) == 0 1217 assert "1 warnings" in capsys.readouterr().out 1218 1219 1220class TestJsonAndExitCodes: 1221 def test_json_output(self, doctor, monkeypatch, capsys): 1222 monkeypatch.setattr( 1223 doctor, 1224 "run_checks", 1225 lambda _args: [ 1226 doctor.CheckResult("a", "blocker", "ok", "fine", None), 1227 doctor.CheckResult("b", "advisory", "warn", "careful", "fix me"), 1228 ], 1229 ) 1230 rc = doctor.main(["--json"]) 1231 payload = json.loads(capsys.readouterr().out) 1232 assert rc == 0 1233 assert sorted(payload) == ["checks", "summary"] 1234 assert set(payload["checks"][0]) == { 1235 "name", 1236 "severity", 1237 "status", 1238 "detail", 1239 "fix", 1240 } 1241 1242 def test_exit_code_matrix(self, doctor, monkeypatch, capsys): 1243 monkeypatch.setattr( 1244 doctor, 1245 "run_checks", 1246 lambda _args: [doctor.CheckResult("a", "blocker", "fail", "boom", None)], 1247 ) 1248 assert doctor.main([]) == 1 1249 capsys.readouterr() 1250 1251 monkeypatch.setattr( 1252 doctor, 1253 "run_checks", 1254 lambda _args: [doctor.CheckResult("a", "advisory", "fail", "boom", None)], 1255 ) 1256 assert doctor.main([]) == 0 1257 capsys.readouterr() 1258 1259 monkeypatch.setattr( 1260 doctor, 1261 "run_checks", 1262 lambda _args: [doctor.CheckResult("a", "blocker", "skip", "skip", None)], 1263 ) 1264 assert doctor.main([]) == 0 1265 1266 def test_summary_line_format(self, doctor, monkeypatch, capsys): 1267 monkeypatch.setattr( 1268 doctor, 1269 "run_checks", 1270 lambda _args: [ 1271 doctor.CheckResult("a", "blocker", "fail", "boom", None), 1272 doctor.CheckResult("b", "advisory", "warn", "warn", None), 1273 doctor.CheckResult("c", "blocker", "skip", "skip", None), 1274 ], 1275 ) 1276 doctor.main([]) 1277 output = capsys.readouterr().out.strip().splitlines() 1278 assert output[-1] == "doctor: 3 checks, 1 failed, 1 warnings, 1 skipped" 1279 1280 def test_doctor_jsonl_emits_started_and_completed( 1281 self, doctor, monkeypatch, capsys 1282 ): 1283 monkeypatch.setattr( 1284 doctor, 1285 "run_checks", 1286 lambda _args: [doctor.CheckResult("a", "blocker", "ok", "fine", None)], 1287 ) 1288 1289 rc = doctor.main(["--jsonl"]) 1290 events = [json.loads(line) for line in capsys.readouterr().out.splitlines()] 1291 1292 assert rc == 0 1293 assert events[0]["event"] == "doctor.started" 1294 assert events[-1]["event"] == "doctor.completed" 1295 assert events[-1]["status"] == "ok" 1296 1297 def test_doctor_jsonl_emits_check_completed_per_check( 1298 self, doctor, monkeypatch, capsys 1299 ): 1300 monkeypatch.setattr( 1301 doctor, 1302 "run_checks", 1303 lambda _args: [ 1304 doctor.CheckResult(check.name, check.severity, "ok", "fine", None) 1305 for check, _func in doctor.UNIVERSAL_CHECKS 1306 ], 1307 ) 1308 1309 doctor.main(["--jsonl"]) 1310 events = [json.loads(line) for line in capsys.readouterr().out.splitlines()] 1311 1312 checks = [event for event in events if event["event"] == "check.completed"] 1313 assert len(checks) == len(doctor.UNIVERSAL_CHECKS) 1314 1315 def test_doctor_jsonl_status_translates_short_to_long( 1316 self, doctor, monkeypatch, capsys 1317 ): 1318 monkeypatch.setattr( 1319 doctor, 1320 "run_checks", 1321 lambda _args: [ 1322 doctor.CheckResult("ok", "blocker", "ok", "ok", None), 1323 doctor.CheckResult("warn", "advisory", "warn", "warn", None), 1324 doctor.CheckResult("fail", "advisory", "fail", "fail", None), 1325 doctor.CheckResult("skip", "blocker", "skip", "skip", None), 1326 ], 1327 ) 1328 1329 rc = doctor.main(["--jsonl"]) 1330 events = [json.loads(line) for line in capsys.readouterr().out.splitlines()] 1331 1332 assert rc == 0 1333 statuses = { 1334 event["name"]: event["status"] 1335 for event in events 1336 if event["event"] == "check.completed" 1337 } 1338 assert statuses == { 1339 "ok": "ok", 1340 "warn": "warning", 1341 "fail": "failed", 1342 "skip": "skipped", 1343 } 1344 assert events[-1]["status"] == "warning" 1345 1346 def test_doctor_jsonl_json_and_jsonl_mutually_exclusive(self, doctor): 1347 with pytest.raises(SystemExit) as raised: 1348 doctor.main(["--json", "--jsonl"]) 1349 1350 assert raised.value.code == 2 1351 1352 def test_doctor_jsonl_preserves_json_payload_unchanged( 1353 self, doctor, monkeypatch, capsys 1354 ): 1355 monkeypatch.setattr( 1356 doctor, 1357 "run_checks", 1358 lambda _args: [ 1359 doctor.CheckResult("a", "advisory", "warn", "careful", None) 1360 ], 1361 ) 1362 1363 rc = doctor.main(["--json"]) 1364 payload = json.loads(capsys.readouterr().out) 1365 1366 assert rc == 0 1367 assert payload["checks"][0]["status"] == "warn" 1368 1369 def test_doctor_jsonl_subprocess_e2e(self): 1370 result = subprocess.run( 1371 [sys.executable, "-m", "solstone.think.sol_cli", "doctor", "--jsonl"], 1372 capture_output=True, 1373 text=True, 1374 cwd=ROOT, 1375 timeout=60, 1376 ) 1377 1378 assert result.returncode in ( 1379 0, 1380 1, 1381 ), f"unexpected exit code {result.returncode}: {result.stderr}" 1382 lines = [line for line in result.stdout.splitlines() if line.strip()] 1383 assert lines 1384 for line in lines: 1385 json.loads(line) 1386 1387 1388def test_doctor_import_does_not_pull_numpy_or_observe_layers(): 1389 snippet = ( 1390 "import sys\n" 1391 "import solstone.think.doctor\n" 1392 "for m in sorted(sys.modules):\n" 1393 " if (\n" 1394 " m == 'numpy'\n" 1395 " or m.startswith('numpy.')\n" 1396 " or m == 'solstone.observe'\n" 1397 " or m.startswith('solstone.observe.')\n" 1398 " ):\n" 1399 " print('LEAKED:' + m)\n" 1400 " sys.exit(3)\n" 1401 "print('OK')\n" 1402 ) 1403 result = subprocess.run( 1404 [sys.executable, "-c", snippet], 1405 capture_output=True, 1406 text=True, 1407 cwd=ROOT, 1408 env={ 1409 **os.environ, 1410 "SOLSTONE_JOURNAL": str(ROOT / "tests" / "fixtures" / "journal"), 1411 }, 1412 timeout=60, 1413 ) 1414 1415 assert result.returncode == 0, result.stdout + result.stderr 1416 assert "LEAKED" not in result.stdout 1417 1418 1419def test_readiness_battery_does_not_import_inference_or_installer_layers(): 1420 # Proves doctor's readiness battery does not import inference/installer layers. 1421 snippet = ( 1422 "import sys\n" 1423 "from solstone.think import doctor\n" 1424 "doctor.run_checks(doctor.parse_args(['--readiness']))\n" 1425 "bad = sorted(\n" 1426 " m\n" 1427 " for m in sys.modules\n" 1428 " if m == 'solstone.think.install_models'\n" 1429 " or m == 'solstone.observe.transcribe'\n" 1430 " or m.startswith('solstone.observe.transcribe.')\n" 1431 ")\n" 1432 "if bad:\n" 1433 " print('LEAKED:' + ','.join(bad))\n" 1434 " sys.exit(3)\n" 1435 "print('OK')\n" 1436 ) 1437 result = subprocess.run( 1438 [sys.executable, "-c", snippet], 1439 capture_output=True, 1440 text=True, 1441 cwd=ROOT, 1442 env={ 1443 **os.environ, 1444 "SOLSTONE_JOURNAL": str(ROOT / "tests" / "fixtures" / "journal"), 1445 }, 1446 timeout=60, 1447 ) 1448 1449 assert result.returncode == 0, result.stdout + result.stderr 1450 assert "LEAKED" not in result.stdout 1451 1452 1453def test_journal_readiness_battery_does_not_import_host_or_inference_modules(): 1454 snippet = ( 1455 "import sys\n" 1456 "sys.argv = ['journal doctor']\n" 1457 "from solstone.think import doctor\n" 1458 "doctor.run_checks(doctor.parse_args(['--readiness']))\n" 1459 "bad = sorted(\n" 1460 " m\n" 1461 " for m in sys.modules\n" 1462 " if m in {'frontmatter', 'flask', 'onnxruntime', " 1463 "'solstone.think.install_models', 'solstone.observe.transcribe'}\n" 1464 " or m.startswith('solstone.observe.transcribe.')\n" 1465 ")\n" 1466 "if bad:\n" 1467 " print('LEAKED:' + ','.join(bad))\n" 1468 " sys.exit(3)\n" 1469 "print('OK')\n" 1470 ) 1471 result = subprocess.run( 1472 [sys.executable, "-c", snippet], 1473 capture_output=True, 1474 text=True, 1475 cwd=ROOT, 1476 env={ 1477 **os.environ, 1478 "SOLSTONE_JOURNAL": str(ROOT / "tests" / "fixtures" / "journal"), 1479 }, 1480 timeout=60, 1481 ) 1482 1483 assert result.returncode == 0, result.stdout + result.stderr 1484 assert "LEAKED" not in result.stdout 1485 1486 1487def test_journal_doctor_readiness_subprocess_json_shape(): 1488 """End-to-end: journal readiness doctor produces valid diagnostic JSON.""" 1489 repo_root = Path(__file__).resolve().parent.parent 1490 result = subprocess.run( 1491 [ 1492 sys.executable, 1493 "-m", 1494 "solstone.think.sol_cli", 1495 "doctor", 1496 "--readiness", 1497 "--json", 1498 ], 1499 capture_output=True, 1500 text=True, 1501 cwd=repo_root, 1502 timeout=60, 1503 ) 1504 # Exit code: 0 if all checks pass, 1 if any blocker fails. Either is valid 1505 # here; this test asserts CLI routing and payload shape, not machine health. 1506 assert result.returncode in ( 1507 0, 1508 1, 1509 ), f"unexpected exit code {result.returncode}: {result.stderr}" 1510 payload = json.loads(result.stdout) 1511 assert "checks" in payload and isinstance(payload["checks"], list) 1512 assert "summary" in payload and isinstance(payload["summary"], dict) 1513 from solstone.think import doctor as doctor_module 1514 1515 assert {check["name"] for check in payload["checks"]} == { 1516 check.name for check, _runner in doctor_module.JOURNAL_READINESS_CHECKS 1517 } 1518 1519 1520def test_doctor_runs_with_minimal_path_env(tmp_path): 1521 """Doctor must complete with PATH=/usr/bin:/bin (launchd-style minimal env).""" 1522 journal = tmp_path / "journal" 1523 journal.mkdir() 1524 env = { 1525 "PATH": "/usr/bin:/bin", 1526 "HOME": str(tmp_path), 1527 "SOLSTONE_JOURNAL": str(journal), 1528 } 1529 result = subprocess.run( 1530 [ 1531 sys.executable, 1532 "-m", 1533 "solstone.think.sol_cli", 1534 "doctor", 1535 "--readiness", 1536 "--json", 1537 ], 1538 env=env, 1539 capture_output=True, 1540 text=True, 1541 timeout=60, 1542 ) 1543 assert result.returncode in {0, 1}, ( 1544 f"doctor crashed: rc={result.returncode}\n" 1545 f"stdout={result.stdout}\nstderr={result.stderr}" 1546 ) 1547 payload = json.loads(result.stdout) 1548 from solstone.think import doctor as doctor_module 1549 1550 names = {check["name"] for check in payload["checks"]} 1551 assert names == { 1552 check.name for check, _runner in doctor_module.JOURNAL_READINESS_CHECKS 1553 } 1554 assert not any( 1555 name.startswith("service_") or name == "journal_sync" for name in names 1556 )