import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from threading import Thread from click.testing import CliRunner from astrbot.cli.__main__ import cli from astrbot.cli.commands import cmd_service from astrbot.cli.commands.cmd_service import ( ServiceState, WebUIStatus, _build_launchd_plist, _build_systemd_unit, _check_webui, _get_app_log_config, _health_label, _load_dashboard_port, _load_or_init_config, service, ) class _HealthyHandler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.end_headers() self.wfile.write(b"ok") def log_message(self, *_args): return def test_service_command_is_registered(): result = CliRunner().invoke(cli, ["help", "service"]) assert result.exit_code == 0 assert "install" in result.output assert "logs" in result.output assert "restart" in result.output assert "status" in result.output assert "start" in result.output assert "stop" in result.output assert "uninstall" in result.output def test_service_logs_group_exposes_log_file_controls(): result = CliRunner().invoke(service, ["logs", "--help"]) assert result.exit_code == 0 assert "enable" in result.output assert "disable" in result.output assert "status" in result.output def test_service_install_requires_initialized_root(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) result = CliRunner().invoke(service, ["install", "--executable", "astrbot"]) assert result.exit_code == 1 assert "Use 'astrbot init' before installing the service" in result.output def test_service_install_rejects_windows(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) monkeypatch.setattr(cmd_service.platform, "system", lambda: "Windows") result = CliRunner().invoke(service, ["install", "--executable", "astrbot"]) assert result.exit_code == 1 assert "Unsupported platform: Windows" in result.output def test_systemd_unit_uses_astrbot_executable_and_working_directory(): unit = _build_systemd_unit( "astrbot", Path("/home/astrbot/.local/bin/astrbot"), Path("/home/astrbot/AstrBot Root"), ) assert 'WorkingDirectory="/home/astrbot/AstrBot Root"' in unit assert "ExecStart=/home/astrbot/.local/bin/astrbot run" in unit assert "Environment=PYTHONUNBUFFERED=1" in unit def test_launchd_plist_uses_astrbot_executable_and_working_directory(): plist = _build_launchd_plist( "astrbot", Path("/Users/astrbot/.local/bin/astrbot"), Path("/Users/astrbot/AstrBot"), Path("/Users/astrbot/Library/Logs/AstrBot"), ) assert plist["Label"] == "app.astrbot.astrbot" assert plist["ProgramArguments"] == ["/Users/astrbot/.local/bin/astrbot", "run"] assert plist["WorkingDirectory"] == "/Users/astrbot/AstrBot" assert plist["EnvironmentVariables"] == {"PYTHONUNBUFFERED": "1"} def test_launch_agent_start_waits_until_loaded_before_kickstart(monkeypatch, tmp_path): plist_path = tmp_path / "app.astrbot.astrbot.plist" plist_path.touch() events = [] loaded_states = [False, False, True] monkeypatch.setattr(cmd_service.shutil, "which", lambda name: "/bin/launchctl") monkeypatch.setattr(cmd_service, "_launch_agent_path", lambda _name: plist_path) monkeypatch.setattr(cmd_service.time, "sleep", lambda _seconds: None) def fake_run_capture(command): if command[1] == "print": events.append("print") loaded = loaded_states.pop(0) if loaded_states else True return cmd_service.subprocess.CompletedProcess( command, 0 if loaded else 113, stdout="", stderr="not loaded", ) if command[1] == "kickstart": events.append("kickstart") return cmd_service.subprocess.CompletedProcess(command, 0) raise AssertionError(f"Unexpected capture command: {command}") def fake_run_checked(command, _failure_message): events.append(command[1]) monkeypatch.setattr(cmd_service, "_run_capture", fake_run_capture) monkeypatch.setattr(cmd_service, "_run_checked", fake_run_checked) cmd_service._start_launch_agent("astrbot") assert "bootstrap" in events assert "enable" in events assert "kickstart" in events assert events.index("bootstrap") < events.index("kickstart") def test_load_dashboard_port_reads_cmd_config(tmp_path): config_path = tmp_path / "data" / "cmd_config.json" config_path.parent.mkdir() config_path.write_text( json.dumps({"dashboard": {"port": 7788}}), encoding="utf-8-sig", ) dashboard_port = _load_dashboard_port(tmp_path) assert dashboard_port.port == 7788 assert dashboard_port.detail is None def test_check_webui_reports_accessible_http_response(): server = ThreadingHTTPServer(("127.0.0.1", 0), _HealthyHandler) thread = Thread(target=server.serve_forever, daemon=True) thread.start() try: webui_status = _check_webui(server.server_port, timeout=1.0) finally: server.shutdown() server.server_close() thread.join(timeout=1) assert webui_status.accessible is True assert webui_status.status_code == 200 def test_health_label_requires_service_and_webui(): active = ServiceState(manager="systemd --user", installed=True, state="active") inactive = ServiceState(manager="systemd --user", installed=True, state="inactive") reachable = WebUIStatus(url="http://127.0.0.1:6185/", accessible=True) unreachable = WebUIStatus(url="http://127.0.0.1:6185/", accessible=False) assert _health_label(active, reachable) == "healthy" assert _health_label(active, unreachable) == "degraded" assert _health_label(inactive, reachable) == "degraded" assert _health_label(inactive, unreachable) == "unhealthy" def test_service_status_reports_port_and_webui_health(monkeypatch, tmp_path): (tmp_path / ".astrbot").touch() config_path = tmp_path / "data" / "cmd_config.json" config_path.parent.mkdir() config_path.write_text( json.dumps({"dashboard": {"port": 7788}}), encoding="utf-8-sig", ) monkeypatch.setattr( cmd_service, "_get_service_state", lambda _name: ServiceState( manager="systemd --user", installed=True, state="active", ), ) monkeypatch.setattr( cmd_service, "_check_webui", lambda port, _timeout: WebUIStatus( url=f"http://127.0.0.1:{port}/", accessible=True, status_code=200, detail="HTTP 200", ), ) result = CliRunner().invoke(service, ["status", "--workdir", str(tmp_path)]) assert result.exit_code == 0 assert "Health: healthy" in result.output assert "Dashboard port: 7788" in result.output assert "WebUI accessible: yes" in result.output assert "WebUI HTTP status: 200" in result.output def test_service_start_dispatches_to_platform_control(monkeypatch): calls = [] monkeypatch.setattr( cmd_service, "_control_service", lambda name, action: calls.append((name, action)), ) result = CliRunner().invoke(service, ["start", "--name", "astrbot-test"]) assert result.exit_code == 0 assert calls == [("astrbot-test", "start")] assert "Started service: astrbot-test" in result.output def test_service_uninstall_requires_confirmation(monkeypatch): calls = [] monkeypatch.setattr( cmd_service, "_uninstall_service", lambda name: calls.append(name) or name, ) result = CliRunner().invoke(service, ["uninstall"], input="n\n") assert result.exit_code == 1 assert calls == [] def test_service_logs_source_app_reads_application_log(monkeypatch, tmp_path): (tmp_path / ".astrbot").touch() log_path = tmp_path / "data" / "logs" / "astrbot.log" log_path.parent.mkdir(parents=True) log_path.write_text("first\nsecond\nthird\n", encoding="utf-8") result = CliRunner().invoke( service, ["logs", "--source", "app", "--workdir", str(tmp_path), "--lines", "2"], ) assert result.exit_code == 0 assert "first" not in result.output assert "second" in result.output assert "third" in result.output def test_service_logs_hides_stderr_by_default(monkeypatch, tmp_path): out_log = tmp_path / "astrbot.out.log" err_log = tmp_path / "astrbot.err.log" out_log.write_text("normal output\n", encoding="utf-8") err_log.write_text("stderr output\n", encoding="utf-8") monkeypatch.setattr(cmd_service.platform, "system", lambda: "Darwin") monkeypatch.setattr( cmd_service, "_service_log_paths", lambda _name: (out_log, err_log), ) default_result = CliRunner().invoke(service, ["logs", "--lines", "10"]) stderr_result = CliRunner().invoke( service, ["logs", "--lines", "10", "--include-stderr"], ) assert default_result.exit_code == 0 assert "normal output" in default_result.output assert "stderr output" not in default_result.output assert stderr_result.exit_code == 0 assert "normal output" in stderr_result.output assert "stderr output" in stderr_result.output def test_service_app_log_enable_updates_config(tmp_path): (tmp_path / ".astrbot").touch() result = CliRunner().invoke( service, [ "logs", "enable", "--workdir", str(tmp_path), "--path", "logs/custom.log", ], ) assert result.exit_code == 0 config = _load_or_init_config(tmp_path) log_config = _get_app_log_config(tmp_path, config) assert log_config.enabled is True assert log_config.configured_path == "logs/custom.log" assert log_config.path == tmp_path / "data" / "logs" / "custom.log"