"""Tests for ``laya_router.cli`` PATH / symlink helpers. These cover the path exposure logic added so ``laya-router`` and ``laya-hook`` work from any shell without manual ``PATH`` setup. """ from __future__ import annotations import os from pathlib import Path from laya_router.cli import ( _apply_path_symlinks, _bin_link_targets, _remove_path_symlinks, _user_path_bin_dir, ensure_path_symlinks, remove_path_symlinks, ) class _FakeSettings: """Minimal stand-in for ``Settings`` carrying just the paths we touch.""" def __init__(self, router: Path, hook: Path): self.router_bin = router self.hook_bin = hook self.mcp_bin = router.with_name("laya-mcp") def _make_fake_binaries(tmp_path: Path) -> tuple[Path, Path, _FakeSettings]: """Build two venv-style binaries on disk and return (router, hook, settings).""" src_dir = tmp_path / "venv_bin" src_dir.mkdir() router = src_dir / "laya-router" hook = src_dir / "laya-hook" router.write_text("#!/bin/sh\necho router\n") hook.write_text("#!/bin/sh\necho hook\n") return router, hook, _FakeSettings(router, hook) def test_bin_link_targets_returns_both_binaries(): from laya_router.config import load_settings targets = _bin_link_targets(load_settings()) names = [name for name, _ in targets] assert names == ["laya-router", "laya-hook", "laya-mcp"] assert targets[0][1].is_file() assert targets[1][1].is_file() def test_user_path_bin_dir_prefers_local_bin(monkeypatch, tmp_path): monkeypatch.setenv("PATH", f"{tmp_path}:/usr/bin") preferred = Path.home() / ".local" / "bin" if preferred.is_dir() and os.access(preferred, os.W_OK): assert _user_path_bin_dir() == preferred else: assert _user_path_bin_dir() == tmp_path def test_user_path_bin_dir_skips_system_dirs(monkeypatch): # Force "no writable dir exists" by overriding to a non-existent path. monkeypatch.setenv("LAYA_ROUTER_BIN_DIR", "/this/path/does/not/exist") monkeypatch.setenv("PATH", "/usr/local/bin:/usr/bin:/bin:/sbin") assert _user_path_bin_dir() is None def test_user_path_bin_dir_respects_env_override(monkeypatch, tmp_path): monkeypatch.setenv("LAYA_ROUTER_BIN_DIR", str(tmp_path)) assert _user_path_bin_dir() == tmp_path def test_user_path_bin_dir_env_override_rejects_unwritable(monkeypatch, tmp_path): monkeypatch.setenv("LAYA_ROUTER_BIN_DIR", "/this/path/does/not/exist") assert _user_path_bin_dir() is None def test_apply_path_symlinks_creates_links(tmp_path): router, hook, settings = _make_fake_binaries(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() _apply_path_symlinks(settings, bin_dir) assert (bin_dir / "laya-router").is_symlink() assert (bin_dir / "laya-router").resolve() == router.resolve() assert (bin_dir / "laya-hook").is_symlink() assert (bin_dir / "laya-hook").resolve() == hook.resolve() def test_apply_path_symlinks_is_noop_when_correct(tmp_path): _, _, settings = _make_fake_binaries(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() _apply_path_symlinks(settings, bin_dir) first = (bin_dir / "laya-router").lstat() _apply_path_symlinks(settings, bin_dir) second = (bin_dir / "laya-router").lstat() # Re-running on a correct symlink must not rewrite it. assert first.st_mtime_ns == second.st_mtime_ns def test_apply_path_symlinks_repairs_wrong_target(tmp_path): router, _, settings = _make_fake_binaries(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() (bin_dir / "laya-router").symlink_to("/etc/passwd") _apply_path_symlinks(settings, bin_dir) assert (bin_dir / "laya-router").resolve() == router.resolve() def test_apply_path_symlinks_repairs_broken_symlink(tmp_path): router, _, settings = _make_fake_binaries(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() (bin_dir / "laya-router").symlink_to("/nonexistent/path") _apply_path_symlinks(settings, bin_dir) assert (bin_dir / "laya-router").resolve() == router.resolve() def test_apply_path_symlinks_skips_unrelated_file(tmp_path, capsys): router, _, settings = _make_fake_binaries(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() existing = bin_dir / "laya-router" existing.write_text("user's binary") _apply_path_symlinks(settings, bin_dir) captured = capsys.readouterr() assert "已被占用" in captured.err # The user's file must not be clobbered. assert existing.read_text() == "user's binary" # And the other one should still get a fresh symlink. assert (bin_dir / "laya-hook").is_symlink() assert (bin_dir / "laya-router").resolve() != router.resolve() def test_remove_path_symlinks_removes_both(tmp_path): _, _, settings = _make_fake_binaries(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() _apply_path_symlinks(settings, bin_dir) assert (bin_dir / "laya-router").is_symlink() assert (bin_dir / "laya-hook").is_symlink() _remove_path_symlinks(settings, bin_dir) assert not (bin_dir / "laya-router").exists() assert not (bin_dir / "laya-hook").exists() def test_remove_path_symlinks_idempotent(tmp_path): _, _, settings = _make_fake_binaries(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() # No symlinks created — remove should still be safe. _remove_path_symlinks(settings, bin_dir) _remove_path_symlinks(settings, bin_dir) def test_public_helpers_warn_when_no_writable_path(monkeypatch, capsys): monkeypatch.setenv("LAYA_ROUTER_BIN_DIR", "/this/path/does/not/exist") settings = _FakeSettings(Path("/nonexistent/router"), Path("/nonexistent/hook")) ensure_path_symlinks(settings) captured = capsys.readouterr() assert "没有可写的目录" in captured.err # remove_path_symlinks is silent in the same situation. remove_path_symlinks(settings)