test_commands.py
1 # Copyright 2026 Alibaba Group Holding Ltd. 2 # 3 # Licensed under the Apache License, Version 2.0 (the "License"); 4 # you may not use this file except in compliance with the License. 5 # You may obtain a copy of the License at 6 # 7 # http://www.apache.org/licenses/LICENSE-2.0 8 # 9 # Unless required by applicable law or agreed to in writing, software 10 # distributed under the License is distributed on an "AS IS" BASIS, 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 # See the License for the specific language governing permissions and 13 # limitations under the License. 14 15 """Tests for CLI commands with mocked SDK calls. 16 17 Strategy: patch ``opensandbox_cli.main.ClientContext`` and ``resolve_config`` 18 so the root ``cli`` callback creates our mock instead of a real SDK client. 19 """ 20 21 from __future__ import annotations 22 23 import json 24 from datetime import timedelta 25 from pathlib import Path 26 from unittest.mock import MagicMock, patch 27 28 import pytest 29 from click.testing import CliRunner 30 from opensandbox.models.sandboxes import SandboxImageSpec 31 32 from opensandbox_cli.main import cli 33 from opensandbox_cli.output import OutputFormatter 34 35 36 @pytest.fixture() 37 def runner() -> CliRunner: 38 return CliRunner() 39 40 41 def _build_mock_client_context( 42 *, 43 manager: MagicMock | None = None, 44 sandbox: MagicMock | None = None, 45 output_format: str = "json", 46 ) -> MagicMock: 47 ctx = MagicMock() 48 ctx.resolved_config = { 49 "api_key": "test-key", 50 "domain": "localhost:8080", 51 "protocol": "http", 52 "request_timeout": 30, 53 "use_server_proxy": False, 54 "color": False, 55 "default_image": None, 56 "default_timeout": None, 57 } 58 ctx.config_path = Path("/tmp/mock-config.toml") 59 ctx.cli_overrides = { 60 "api_key": None, 61 "domain": None, 62 "protocol": None, 63 "request_timeout": None, 64 "use_server_proxy": None, 65 } 66 ctx.output = OutputFormatter(output_format, color=False) 67 def _make_output(fmt: str) -> OutputFormatter: 68 formatter = OutputFormatter(fmt, color=False) 69 ctx.output = formatter 70 return formatter 71 ctx.make_output.side_effect = _make_output 72 ctx.get_manager.return_value = manager or MagicMock() 73 ctx.connect_sandbox.return_value = sandbox or MagicMock() 74 ctx.resolve_sandbox_id.side_effect = lambda prefix: prefix # passthrough 75 ctx.connection_config = MagicMock() 76 ctx.close = MagicMock() 77 return ctx 78 79 80 def _invoke( 81 runner: CliRunner, 82 args: list[str], 83 *, 84 manager: MagicMock | None = None, 85 sandbox: MagicMock | None = None, 86 output_format: str = "json", 87 ) -> object: 88 """Invoke CLI with mocked ClientContext.""" 89 mock_ctx = _build_mock_client_context( 90 manager=manager, sandbox=sandbox, output_format=output_format 91 ) 92 93 with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ 94 patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx): 95 mock_resolve.return_value = mock_ctx.resolved_config 96 result = runner.invoke(cli, args, catch_exceptions=False) 97 return result 98 99 100 # --------------------------------------------------------------------------- 101 # Config commands (no SDK mocking needed) 102 # --------------------------------------------------------------------------- 103 104 105 class TestConfigInit: 106 def test_init_creates_file(self, runner: CliRunner, tmp_path: Path) -> None: 107 cfg_path = tmp_path / "config.toml" 108 result = runner.invoke(cli, ["--config", str(cfg_path), "config", "init"]) 109 assert result.exit_code == 0 110 assert "Config file created" in result.output 111 112 def test_init_refuses_overwrite(self, runner: CliRunner, tmp_path: Path) -> None: 113 cfg_path = tmp_path / "config.toml" 114 cfg_path.write_text("existing") 115 result = runner.invoke(cli, ["--config", str(cfg_path), "config", "init"]) 116 assert "already exists" in result.output 117 118 def test_init_force_overwrites(self, runner: CliRunner, tmp_path: Path) -> None: 119 cfg_path = tmp_path / "config.toml" 120 cfg_path.write_text("old") 121 result = runner.invoke(cli, ["--config", str(cfg_path), "config", "init", "--force"]) 122 assert result.exit_code == 0 123 assert "Config file created" in result.output 124 125 126 class TestConfigShow: 127 def test_show_json_output(self, runner: CliRunner) -> None: 128 result = runner.invoke(cli, ["--api-key", "test-key", "config", "show", "-o", "json"]) 129 assert result.exit_code == 0 130 data = json.loads(result.output) 131 assert "api_key" in data 132 assert data["api_key"] == "te****ey" 133 assert data["config_path"].endswith(".opensandbox/config.toml") 134 assert "config_file_exists" in data 135 136 def test_show_table_output(self, runner: CliRunner) -> None: 137 result = runner.invoke(cli, ["--api-key", "test-key", "config", "show"]) 138 assert result.exit_code == 0 139 assert "api_key" in result.output 140 assert "test-key" not in result.output 141 assert "te****ey" in result.output 142 143 def test_global_request_timeout_flag_overrides_resolved_config(self, runner: CliRunner) -> None: 144 result = runner.invoke(cli, ["--request-timeout", "45", "config", "show", "-o", "json"]) 145 assert result.exit_code == 0 146 data = json.loads(result.output) 147 assert data["request_timeout"] == 45 148 149 150 class TestConfigSet: 151 def test_set_updates_existing_field(self, runner: CliRunner, tmp_path: Path) -> None: 152 cfg_path = tmp_path / "config.toml" 153 runner.invoke(cli, ["--config", str(cfg_path), "config", "init"]) 154 result = runner.invoke( 155 cli, 156 ["--config", str(cfg_path), "config", "set", "connection.domain", "new.host"], 157 ) 158 assert result.exit_code == 0 159 assert "Set connection.domain = new.host" in result.output 160 161 def test_set_rejects_flat_key(self, runner: CliRunner, tmp_path: Path) -> None: 162 cfg_path = tmp_path / "config.toml" 163 cfg_path.write_text("[connection]\n") 164 result = runner.invoke( 165 cli, 166 ["--config", str(cfg_path), "config", "set", "flat_key", "value"], 167 ) 168 assert result.exit_code != 0 169 assert "section.field" in result.output 170 171 def test_set_uses_root_config_path(self, runner: CliRunner, tmp_path: Path) -> None: 172 cfg_path = tmp_path / "custom.toml" 173 runner.invoke(cli, ["--config", str(cfg_path), "config", "init"]) 174 175 result = runner.invoke( 176 cli, 177 ["--config", str(cfg_path), "config", "set", "connection.domain", "team.host"], 178 ) 179 180 assert result.exit_code == 0 181 assert 'domain = "team.host"' in cfg_path.read_text() 182 183 def test_set_fails_when_config_file_is_missing(self, runner: CliRunner, tmp_path: Path) -> None: 184 cfg_path = tmp_path / "missing.toml" 185 result = runner.invoke( 186 cli, 187 ["--config", str(cfg_path), "config", "set", "connection.domain", "team.host"], 188 ) 189 assert result.exit_code != 0 190 assert "Run 'osb config init' first." in result.output 191 192 193 # --------------------------------------------------------------------------- 194 # Sandbox commands 195 # --------------------------------------------------------------------------- 196 197 198 class TestSandboxList: 199 def test_list_invokes_manager(self, runner: CliRunner) -> None: 200 mock_mgr = MagicMock() 201 mock_result = MagicMock() 202 mock_result.sandbox_infos = [] 203 mock_mgr.list_sandbox_infos.return_value = mock_result 204 205 result = _invoke(runner, ["sandbox", "list", "-o", "json"], manager=mock_mgr) 206 assert result.exit_code == 0 207 mock_mgr.list_sandbox_infos.assert_called_once() 208 209 def test_list_normalizes_state_filters_case_insensitively(self, runner: CliRunner) -> None: 210 mock_mgr = MagicMock() 211 mock_result = MagicMock() 212 mock_result.sandbox_infos = [] 213 mock_mgr.list_sandbox_infos.return_value = mock_result 214 215 result = _invoke( 216 runner, 217 ["sandbox", "list", "-o", "json", "--state", "running", "--state", "PAUSED"], 218 manager=mock_mgr, 219 ) 220 221 assert result.exit_code == 0 222 filt = mock_mgr.list_sandbox_infos.call_args.args[0] 223 assert filt.states == ["Running", "Paused"] 224 225 def test_list_rejects_unknown_state_filter(self, runner: CliRunner) -> None: 226 mock_mgr = MagicMock() 227 result = _invoke( 228 runner, 229 ["sandbox", "list", "--state", "runing"], 230 manager=mock_mgr, 231 ) 232 233 assert result.exit_code != 0 234 assert "Invalid sandbox state 'runing'" in result.output 235 mock_mgr.list_sandbox_infos.assert_not_called() 236 237 def test_list_help_uses_one_indexed_pages(self, runner: CliRunner) -> None: 238 result = runner.invoke(cli, ["sandbox", "list", "--help"]) 239 assert result.exit_code == 0 240 assert "Page number (1-indexed)." in result.output 241 242 def test_list_rejects_page_zero(self, runner: CliRunner) -> None: 243 result = _invoke(runner, ["sandbox", "list", "--page", "0"]) 244 assert result.exit_code != 0 245 assert "0 is not in the range x>=1" in result.output 246 247 def test_list_passes_user_page_through_to_sdk(self, runner: CliRunner) -> None: 248 mock_mgr = MagicMock() 249 mock_result = MagicMock() 250 mock_result.sandbox_infos = [] 251 mock_result.pagination.model_dump.return_value = { 252 "page": 1, 253 "page_size": 20, 254 "total_items": 0, 255 "total_pages": 0, 256 "has_next_page": False, 257 } 258 mock_mgr.list_sandbox_infos.return_value = mock_result 259 260 result = _invoke( 261 runner, 262 ["sandbox", "list", "--page", "1", "--page-size", "20", "-o", "json"], 263 manager=mock_mgr, 264 ) 265 266 assert result.exit_code == 0 267 filt = mock_mgr.list_sandbox_infos.call_args.args[0] 268 assert filt.page == 1 269 data = json.loads(result.output) 270 assert data["pagination"]["page"] == 1 271 assert data["items"] == [] 272 273 274 class TestSandboxCreate: 275 def test_create_uses_config_defaults(self, runner: CliRunner) -> None: 276 mock_sb = MagicMock() 277 mock_sb.id = "sb-123" 278 mock_ctx = _build_mock_client_context(sandbox=mock_sb) 279 mock_ctx.resolved_config["default_image"] = "python:3.12" 280 mock_ctx.resolved_config["default_timeout"] = "15m" 281 282 with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ 283 patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ 284 patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: 285 mock_resolve.return_value = mock_ctx.resolved_config 286 result = runner.invoke(cli, ["sandbox", "create", "-o", "json"], catch_exceptions=False) 287 288 assert result.exit_code == 0 289 mock_create.assert_called_once() 290 assert mock_create.call_args.args[0] == "python:3.12" 291 assert mock_create.call_args.kwargs["timeout"].total_seconds() == 900 292 293 def test_create_requires_image_when_no_default(self, runner: CliRunner) -> None: 294 result = _invoke(runner, ["sandbox", "create"]) 295 assert result.exit_code != 0 296 assert "Sandbox image is required" in result.output 297 298 def test_create_supports_timeout_none(self, runner: CliRunner) -> None: 299 mock_sb = MagicMock() 300 mock_sb.id = "sb-123" 301 302 mock_ctx = _build_mock_client_context(sandbox=mock_sb) 303 with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ 304 patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ 305 patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: 306 mock_resolve.return_value = mock_ctx.resolved_config 307 result = runner.invoke( 308 cli, 309 ["sandbox", "create", "-o", "json", "--image", "python:3.12", "--timeout", "none"], 310 catch_exceptions=False, 311 ) 312 313 assert result.exit_code == 0 314 assert mock_create.call_args.kwargs["timeout"] is None 315 data = json.loads(result.output) 316 assert data["timeout"] == "manual-cleanup" 317 318 def test_create_reports_sdk_default_timeout_when_unset(self, runner: CliRunner) -> None: 319 mock_sb = MagicMock() 320 mock_sb.id = "sb-123" 321 322 mock_ctx = _build_mock_client_context(sandbox=mock_sb) 323 with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ 324 patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ 325 patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: 326 mock_resolve.return_value = mock_ctx.resolved_config 327 result = runner.invoke( 328 cli, 329 ["sandbox", "create", "-o", "json", "--image", "python:3.12"], 330 catch_exceptions=False, 331 ) 332 333 assert result.exit_code == 0 334 assert "timeout" not in mock_create.call_args.kwargs 335 data = json.loads(result.output) 336 assert data["timeout"] == "sdk-default" 337 338 def test_create_supports_default_timeout_none(self, runner: CliRunner) -> None: 339 mock_sb = MagicMock() 340 mock_sb.id = "sb-123" 341 mock_ctx = _build_mock_client_context(sandbox=mock_sb) 342 mock_ctx.resolved_config["default_image"] = "python:3.12" 343 mock_ctx.resolved_config["default_timeout"] = "none" 344 345 with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ 346 patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ 347 patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: 348 mock_resolve.return_value = mock_ctx.resolved_config 349 result = runner.invoke(cli, ["sandbox", "create", "-o", "json"], catch_exceptions=False) 350 351 assert result.exit_code == 0 352 assert mock_create.call_args.kwargs["timeout"] is None 353 354 def test_create_passes_image_auth_to_sdk(self, runner: CliRunner) -> None: 355 mock_sb = MagicMock() 356 mock_sb.id = "sb-123" 357 358 mock_ctx = _build_mock_client_context(sandbox=mock_sb) 359 with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ 360 patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ 361 patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: 362 mock_resolve.return_value = mock_ctx.resolved_config 363 result = runner.invoke( 364 cli, 365 [ 366 "sandbox", 367 "create", 368 "-o", 369 "json", 370 "--image", 371 "private.example.com/team/app:latest", 372 "--image-auth-username", 373 "alice", 374 "--image-auth-password", 375 "secret-token", 376 ], 377 catch_exceptions=False, 378 ) 379 380 assert result.exit_code == 0 381 image_arg = mock_create.call_args.args[0] 382 assert isinstance(image_arg, SandboxImageSpec) 383 assert image_arg.image == "private.example.com/team/app:latest" 384 assert image_arg.auth is not None 385 assert image_arg.auth.username == "alice" 386 assert image_arg.auth.password == "secret-token" 387 388 def test_create_requires_both_image_auth_fields(self, runner: CliRunner) -> None: 389 result = _invoke( 390 runner, 391 [ 392 "sandbox", 393 "create", 394 "--image", 395 "private.example.com/team/app:latest", 396 "--image-auth-username", 397 "alice", 398 ], 399 ) 400 assert result.exit_code != 0 401 assert "Pass both --image-auth-username and --image-auth-password together." in result.output 402 403 def test_create_loads_volumes_from_file(self, runner: CliRunner, tmp_path: Path) -> None: 404 mock_sb = MagicMock() 405 mock_sb.id = "sb-123" 406 volumes_path = tmp_path / "volumes.json" 407 volumes_path.write_text(json.dumps([ 408 { 409 "name": "workdir", 410 "host": {"path": "/tmp/workdir"}, 411 "mountPath": "/workspace", 412 } 413 ])) 414 415 mock_ctx = _build_mock_client_context(sandbox=mock_sb) 416 with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ 417 patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ 418 patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: 419 mock_resolve.return_value = mock_ctx.resolved_config 420 result = runner.invoke( 421 cli, 422 ["sandbox", "create", "-o", "json", "--image", "python:3.12", "--volumes-file", str(volumes_path)], 423 catch_exceptions=False, 424 ) 425 426 assert result.exit_code == 0 427 mock_create.assert_called_once() 428 volumes = mock_create.call_args.kwargs["volumes"] 429 assert len(volumes) == 1 430 assert volumes[0].name == "workdir" 431 assert volumes[0].mount_path == "/workspace" 432 433 def test_create_builds_entrypoint_argv_from_repeated_flags(self, runner: CliRunner) -> None: 434 mock_sb = MagicMock() 435 mock_sb.id = "sb-123" 436 437 mock_ctx = _build_mock_client_context(sandbox=mock_sb) 438 with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ 439 patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ 440 patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: 441 mock_resolve.return_value = mock_ctx.resolved_config 442 result = runner.invoke( 443 cli, 444 [ 445 "sandbox", 446 "create", 447 "-o", 448 "json", 449 "--image", 450 "python:3.12", 451 "--entrypoint", 452 "python", 453 "--entrypoint", 454 "-m", 455 "--entrypoint", 456 "http.server", 457 ], 458 catch_exceptions=False, 459 ) 460 461 assert result.exit_code == 0 462 mock_create.assert_called_once() 463 assert mock_create.call_args.kwargs["entrypoint"] == [ 464 "python", 465 "-m", 466 "http.server", 467 ] 468 469 def test_create_passes_extensions_to_sdk(self, runner: CliRunner) -> None: 470 mock_sb = MagicMock() 471 mock_sb.id = "sb-123" 472 473 mock_ctx = _build_mock_client_context(sandbox=mock_sb) 474 with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ 475 patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ 476 patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: 477 mock_resolve.return_value = mock_ctx.resolved_config 478 result = runner.invoke( 479 cli, 480 [ 481 "sandbox", 482 "create", 483 "-o", 484 "json", 485 "--image", 486 "python:3.12", 487 "--extension", 488 "storage.id=abc123", 489 "--extension", 490 "runtime.profile=fast", 491 ], 492 catch_exceptions=False, 493 ) 494 495 assert result.exit_code == 0 496 mock_create.assert_called_once() 497 assert mock_create.call_args.kwargs["extensions"] == { 498 "storage.id": "abc123", 499 "runtime.profile": "fast", 500 } 501 502 503 class TestSandboxKill: 504 def test_kill_multiple(self, runner: CliRunner) -> None: 505 mock_mgr = MagicMock() 506 result = _invoke(runner, ["sandbox", "kill", "id1", "id2", "-o", "json"], manager=mock_mgr) 507 assert result.exit_code == 0 508 assert mock_mgr.kill_sandbox.call_count == 2 509 data = json.loads(result.output) 510 assert data == [ 511 {"sandbox_id": "id1", "status": "terminated"}, 512 {"sandbox_id": "id2", "status": "terminated"}, 513 ] 514 515 516 class TestSandboxPause: 517 def test_pause_calls_manager(self, runner: CliRunner) -> None: 518 mock_mgr = MagicMock() 519 result = _invoke(runner, ["sandbox", "pause", "sb-123"], manager=mock_mgr) 520 assert result.exit_code == 0 521 mock_mgr.pause_sandbox.assert_called_once_with("sb-123") 522 assert "Sandbox paused: sb-123" in result.output 523 524 525 class TestSandboxResume: 526 def test_resume_uses_sdk_resume_and_waits_for_readiness(self, runner: CliRunner) -> None: 527 mock_sb = MagicMock() 528 mock_sb.id = "sb-123" 529 530 mock_ctx = _build_mock_client_context(sandbox=mock_sb) 531 with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ 532 patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ 533 patch("opensandbox.sync.sandbox.SandboxSync.resume", return_value=mock_sb) as mock_resume: 534 mock_resolve.return_value = mock_ctx.resolved_config 535 result = runner.invoke(cli, ["sandbox", "resume", "sb-123"], catch_exceptions=False) 536 537 assert result.exit_code == 0 538 mock_resume.assert_called_once_with( 539 "sb-123", 540 connection_config=mock_ctx.connection_config, 541 skip_health_check=False, 542 ) 543 mock_sb.close.assert_called_once() 544 assert "Sandbox resumed: sb-123" in result.output 545 546 def test_resume_accepts_skip_health_check_and_timeout(self, runner: CliRunner) -> None: 547 mock_sb = MagicMock() 548 mock_sb.id = "sb-123" 549 550 mock_ctx = _build_mock_client_context(sandbox=mock_sb) 551 with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ 552 patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ 553 patch("opensandbox.sync.sandbox.SandboxSync.resume", return_value=mock_sb) as mock_resume: 554 mock_resolve.return_value = mock_ctx.resolved_config 555 result = runner.invoke( 556 cli, 557 ["sandbox", "resume", "sb-123", "--skip-health-check", "--resume-timeout", "45s"], 558 catch_exceptions=False, 559 ) 560 561 assert result.exit_code == 0 562 mock_resume.assert_called_once_with( 563 "sb-123", 564 connection_config=mock_ctx.connection_config, 565 skip_health_check=True, 566 resume_timeout=timedelta(seconds=45), 567 ) 568 mock_sb.close.assert_called_once() 569 570 571 class TestSandboxMetrics: 572 def test_metrics_fetches_snapshot(self, runner: CliRunner) -> None: 573 mock_sb = MagicMock() 574 mock_metrics = MagicMock() 575 mock_metrics.model_dump.return_value = { 576 "cpu_count": 2, 577 "cpu_used_percentage": 12.5, 578 "memory_total_in_mib": 1024, 579 "memory_used_in_mib": 256, 580 "timestamp": 1710000000000, 581 } 582 mock_sb.get_metrics.return_value = mock_metrics 583 584 result = _invoke(runner, ["sandbox", "metrics", "sb-1", "-o", "json"], sandbox=mock_sb) 585 assert result.exit_code == 0 586 data = json.loads(result.output) 587 assert data["cpu_used_percentage"] == 12.5 588 589 def test_metrics_watch_streams_json_samples(self, runner: CliRunner) -> None: 590 class _FakeResponse: 591 def __init__(self) -> None: 592 self.lines = [ 593 'data: {"cpu_count": 2, "cpu_used_percentage": 12.5, "memory_total_in_mib": 1024, "memory_used_in_mib": 256, "timestamp": 1710000000000}', 594 "", 595 'data: {"cpu_count": 2, "cpu_used_percentage": 18.0, "memory_total_in_mib": 1024, "memory_used_in_mib": 300, "timestamp": 1710000001000}', 596 ] 597 598 def __enter__(self) -> _FakeResponse: 599 return self 600 601 def __exit__(self, exc_type, exc, tb) -> None: 602 return None 603 604 def raise_for_status(self) -> None: 605 return None 606 607 def iter_lines(self): 608 yield from self.lines 609 610 mock_sb = MagicMock() 611 mock_sb.metrics._httpx_client.stream.return_value = _FakeResponse() 612 613 result = _invoke( 614 runner, 615 ["sandbox", "metrics", "sb-1", "--watch", "-o", "json"], 616 sandbox=mock_sb, 617 ) 618 assert result.exit_code == 0 619 lines = [json.loads(line) for line in result.output.strip().splitlines()] 620 assert len(lines) == 2 621 assert lines[0]["cpu_used_percentage"] == 12.5 622 assert lines[1]["memory_used_in_mib"] == 300 623 624 def test_metrics_watch_warns_and_continues_on_error_events(self, runner: CliRunner) -> None: 625 class _FakeResponse: 626 def __init__(self) -> None: 627 self.lines = [ 628 'data: {"cpu_count": 2, "cpu_used_percentage": 12.5, "memory_total_in_mib": 1024, "memory_used_in_mib": 256, "timestamp": 1710000000000}', 629 'data: {"error": "failed to get CPU percent"}', 630 'data: {"cpu_count": 2, "cpu_used_percentage": 18.0, "memory_total_in_mib": 1024, "memory_used_in_mib": 300, "timestamp": 1710000001000}', 631 ] 632 633 def __enter__(self) -> _FakeResponse: 634 return self 635 636 def __exit__(self, exc_type, exc, tb) -> None: 637 return None 638 639 def raise_for_status(self) -> None: 640 return None 641 642 def iter_lines(self): 643 yield from self.lines 644 645 mock_sb = MagicMock() 646 mock_sb.metrics._httpx_client.stream.return_value = _FakeResponse() 647 648 result = _invoke( 649 runner, 650 ["sandbox", "metrics", "sb-1", "--watch", "-o", "json"], 651 sandbox=mock_sb, 652 ) 653 assert result.exit_code == 0 654 decoder = json.JSONDecoder() 655 items: list[dict[str, object]] = [] 656 raw = result.output.strip() 657 index = 0 658 while index < len(raw): 659 while index < len(raw) and raw[index].isspace(): 660 index += 1 661 if index >= len(raw): 662 break 663 item, next_index = decoder.raw_decode(raw, index) 664 items.append(item) 665 index = next_index 666 667 assert len(items) == 3 668 assert items[0]["cpu_used_percentage"] == 12.5 669 assert items[1]["status"] == "warning" 670 assert items[1]["message"] == "Metrics stream error: failed to get CPU percent" 671 assert items[2]["cpu_used_percentage"] == 18.0 672 673 674 class TestSandboxEndpoint: 675 def test_endpoint_passes_valid_port_to_sdk(self, runner: CliRunner) -> None: 676 mock_sb = MagicMock() 677 mock_endpoint = MagicMock() 678 mock_endpoint.model_dump.return_value = {"endpoint": "http://example.test"} 679 mock_sb.get_endpoint.return_value = mock_endpoint 680 681 result = _invoke( 682 runner, 683 ["sandbox", "endpoint", "sb-1", "--port", "8080", "-o", "json"], 684 sandbox=mock_sb, 685 ) 686 687 assert result.exit_code == 0 688 mock_sb.get_endpoint.assert_called_once_with(8080) 689 690 def test_endpoint_rejects_invalid_port(self, runner: CliRunner) -> None: 691 mock_sb = MagicMock() 692 result = _invoke( 693 runner, 694 ["sandbox", "endpoint", "sb-1", "--port", "70000"], 695 sandbox=mock_sb, 696 ) 697 698 assert result.exit_code != 0 699 assert "70000 is not in the range 1<=x<=65535" in result.output 700 mock_sb.get_endpoint.assert_not_called() 701 702 703 # --------------------------------------------------------------------------- 704 # File commands 705 # --------------------------------------------------------------------------- 706 707 708 class TestFileCat: 709 def test_cat_outputs_content(self, runner: CliRunner) -> None: 710 mock_sb = MagicMock() 711 mock_sb.files.read_file.return_value = "hello world" 712 result = _invoke( 713 runner, 714 ["file", "cat", "sb-1", "/etc/hostname"], 715 sandbox=mock_sb, 716 output_format="table", 717 ) 718 assert result.exit_code == 0 719 assert "hello world" in result.output 720 mock_sb.files.read_file.assert_called_once_with("/etc/hostname", encoding="utf-8") 721 722 def test_cat_rejects_json_output(self, runner: CliRunner) -> None: 723 result = _invoke(runner, ["file", "cat", "sb-1", "/etc/hostname", "-o", "json"]) 724 assert result.exit_code != 0 725 assert "Invalid value for '-o' / '--output'" in result.output 726 727 728 class TestFileWrite: 729 def test_write_with_content_flag(self, runner: CliRunner) -> None: 730 mock_sb = MagicMock() 731 result = _invoke( 732 runner, 733 ["file", "write", "sb-1", "/tmp/test.txt", "-c", "content here"], 734 sandbox=mock_sb, 735 ) 736 assert result.exit_code == 0 737 assert "Written" in result.output 738 mock_sb.files.write_file.assert_called_once() 739 740 def test_write_parses_permission_mode(self, runner: CliRunner) -> None: 741 mock_sb = MagicMock() 742 result = _invoke( 743 runner, 744 ["file", "write", "sb-1", "/tmp/test.txt", "-c", "content here", "--mode", "644"], 745 sandbox=mock_sb, 746 ) 747 assert result.exit_code == 0 748 mock_sb.files.write_file.assert_called_once_with( 749 "/tmp/test.txt", "content here", encoding="utf-8", mode=644 750 ) 751 752 753 class TestFileTransfer: 754 def test_upload_streams_file_object(self, runner: CliRunner, tmp_path: Path) -> None: 755 mock_sb = MagicMock() 756 local_path = tmp_path / "upload.bin" 757 local_path.write_bytes(b"hello") 758 759 result = _invoke( 760 runner, 761 ["file", "upload", "sb-1", str(local_path), "/tmp/upload.bin"], 762 sandbox=mock_sb, 763 ) 764 assert result.exit_code == 0 765 uploaded = mock_sb.files.write_file.call_args.args[1] 766 assert hasattr(uploaded, "read") 767 assert not isinstance(uploaded, bytes) 768 769 def test_download_streams_chunks_to_disk(self, runner: CliRunner, tmp_path: Path) -> None: 770 mock_sb = MagicMock() 771 mock_sb.files.read_bytes_stream.return_value = iter([b"hel", b"lo"]) 772 local_path = tmp_path / "nested" / "download.txt" 773 774 result = _invoke( 775 runner, 776 ["file", "download", "sb-1", "/tmp/download.txt", str(local_path)], 777 sandbox=mock_sb, 778 ) 779 assert result.exit_code == 0 780 assert local_path.read_bytes() == b"hello" 781 mock_sb.files.read_bytes_stream.assert_called_once_with("/tmp/download.txt") 782 783 784 class TestFileRm: 785 def test_rm_deletes_files(self, runner: CliRunner) -> None: 786 mock_sb = MagicMock() 787 result = _invoke( 788 runner, ["file", "rm", "sb-1", "/tmp/a", "/tmp/b", "-o", "json"], sandbox=mock_sb 789 ) 790 assert result.exit_code == 0 791 mock_sb.files.delete_files.assert_called_once_with(["/tmp/a", "/tmp/b"]) 792 data = json.loads(result.output) 793 assert data == [ 794 {"path": "/tmp/a", "status": "deleted"}, 795 {"path": "/tmp/b", "status": "deleted"}, 796 ] 797 798 799 class TestFileMv: 800 def test_mv_moves_file(self, runner: CliRunner) -> None: 801 mock_sb = MagicMock() 802 result = _invoke( 803 runner, ["file", "mv", "sb-1", "/tmp/old", "/tmp/new"], sandbox=mock_sb 804 ) 805 assert result.exit_code == 0 806 assert "Moved: /tmp/old" in result.output and "/tmp/new" in result.output 807 808 809 class TestFileMkdir: 810 def test_mkdir_creates_dirs(self, runner: CliRunner) -> None: 811 mock_sb = MagicMock() 812 result = _invoke( 813 runner, ["file", "mkdir", "sb-1", "/tmp/dir1", "/tmp/dir2", "-o", "json"], sandbox=mock_sb 814 ) 815 assert result.exit_code == 0 816 data = json.loads(result.output) 817 assert data == [ 818 {"path": "/tmp/dir1", "status": "created"}, 819 {"path": "/tmp/dir2", "status": "created"}, 820 ] 821 822 def test_mkdir_parses_octal_mode(self, runner: CliRunner) -> None: 823 mock_sb = MagicMock() 824 result = _invoke( 825 runner, 826 ["file", "mkdir", "sb-1", "/tmp/dir1", "--mode", "755"], 827 sandbox=mock_sb, 828 ) 829 assert result.exit_code == 0 830 entry = mock_sb.files.create_directories.call_args.args[0][0] 831 assert entry.mode == 755 832 833 834 class TestFileRmdir: 835 def test_rmdir_removes_dirs(self, runner: CliRunner) -> None: 836 mock_sb = MagicMock() 837 result = _invoke( 838 runner, ["file", "rmdir", "sb-1", "/workspace/old", "-o", "json"], sandbox=mock_sb 839 ) 840 assert result.exit_code == 0 841 data = json.loads(result.output) 842 assert data == [{"path": "/workspace/old", "status": "removed"}] 843 844 845 class TestFileInfo: 846 def test_info_returns_one_aggregated_document(self, runner: CliRunner) -> None: 847 mock_sb = MagicMock() 848 entry = MagicMock() 849 entry.model_dump.return_value = { 850 "mode": 644, 851 "owner": "root", 852 "group": "root", 853 "size": 12, 854 "created_at": "2026-01-01T00:00:00Z", 855 "modified_at": "2026-01-02T00:00:00Z", 856 } 857 mock_sb.files.get_file_info.return_value = { 858 "/tmp/a": entry, 859 "/tmp/b": entry, 860 } 861 862 result = _invoke( 863 runner, 864 ["file", "info", "sb-1", "/tmp/a", "/tmp/b", "-o", "json"], 865 sandbox=mock_sb, 866 ) 867 868 assert result.exit_code == 0 869 data = json.loads(result.output) 870 assert [item["path"] for item in data] == ["/tmp/a", "/tmp/b"] 871 872 873 class TestFileChmod: 874 def test_chmod_parses_permission_mode(self, runner: CliRunner) -> None: 875 mock_sb = MagicMock() 876 result = _invoke( 877 runner, 878 ["file", "chmod", "sb-1", "/tmp/test.txt", "--mode", "755"], 879 sandbox=mock_sb, 880 ) 881 assert result.exit_code == 0 882 entry = mock_sb.files.set_permissions.call_args.args[0][0] 883 assert entry.mode == 755 884 885 886 class TestCommandSeparators: 887 def test_command_run_supports_shell_payload_after_separator(self, runner: CliRunner) -> None: 888 mock_sb = MagicMock() 889 execution = MagicMock() 890 execution.error = None 891 mock_sb.commands.run.return_value = execution 892 893 result = _invoke( 894 runner, 895 ["command", "run", "sb-1", "--", "sh", "-lc", "echo ready"], 896 sandbox=mock_sb, 897 output_format="raw", 898 ) 899 900 assert result.exit_code == 0 901 mock_sb.commands.run.assert_called_once() 902 assert mock_sb.commands.run.call_args.args[0] == "sh -lc 'echo ready'" 903 904 def test_command_run_help_mentions_separator_rule(self, runner: CliRunner) -> None: 905 result = runner.invoke(cli, ["command", "run", "--help"]) 906 assert result.exit_code == 0 907 assert "Separator rule: use `--` before the sandbox command payload." in result.output 908 909 def test_session_run_help_mentions_separator_rule(self, runner: CliRunner) -> None: 910 result = runner.invoke(cli, ["command", "session", "run", "--help"]) 911 assert result.exit_code == 0 912 assert "Separator rule: use `--` before the sandbox command payload." in result.output 913 914 915 # --------------------------------------------------------------------------- 916 # Egress commands 917 # --------------------------------------------------------------------------- 918 919 920 class TestEgressCommands: 921 def test_get_prints_policy(self, runner: CliRunner) -> None: 922 mock_sb = MagicMock() 923 mock_policy = MagicMock() 924 mock_policy.model_dump.return_value = { 925 "defaultAction": "deny", 926 "egress": [{"action": "allow", "target": "pypi.org"}], 927 } 928 mock_sb.get_egress_policy.return_value = mock_policy 929 930 result = _invoke(runner, ["egress", "get", "sb-1", "-o", "json"], sandbox=mock_sb) 931 assert result.exit_code == 0 932 data = json.loads(result.output) 933 assert data["defaultAction"] == "deny" 934 935 def test_patch_calls_sdk(self, runner: CliRunner) -> None: 936 mock_sb = MagicMock() 937 mock_sb.id = "sb-1" 938 result = _invoke( 939 runner, 940 ["egress", "patch", "sb-1", "--rule", "allow=pypi.org", "--rule", "deny=bad.example.com", "-o", "json"], 941 sandbox=mock_sb, 942 ) 943 assert result.exit_code == 0 944 mock_sb.patch_egress_rules.assert_called_once() 945 rules = mock_sb.patch_egress_rules.call_args.args[0] 946 assert len(rules) == 2 947 assert rules[0].action == "allow" 948 assert rules[0].target == "pypi.org" 949 assert rules[1].action == "deny" 950 assert rules[1].target == "bad.example.com" 951 952 953 # --------------------------------------------------------------------------- 954 # Command execution 955 # --------------------------------------------------------------------------- 956 957 958 class TestCommandRun: 959 def test_background_run(self, runner: CliRunner) -> None: 960 mock_sb = MagicMock() 961 mock_execution = MagicMock() 962 mock_execution.id = "exec-123" 963 mock_sb.commands.run.return_value = mock_execution 964 965 result = _invoke( 966 runner, 967 ["command", "run", "sb-1", "-d", "echo", "hello", "-o", "json"], 968 sandbox=mock_sb, 969 ) 970 assert result.exit_code == 0 971 data = json.loads(result.output) 972 assert data["execution_id"] == "exec-123" 973 assert data["mode"] == "background" 974 975 def test_foreground_run_rejects_json_output(self, runner: CliRunner) -> None: 976 result = _invoke( 977 runner, 978 ["command", "run", "sb-1", "-o", "json", "--", "echo", "hello"], 979 ) 980 assert result.exit_code != 0 981 assert "Allowed values: raw" in result.output 982 983 984 class TestCommandInterrupt: 985 def test_interrupt_calls_sdk(self, runner: CliRunner) -> None: 986 mock_sb = MagicMock() 987 result = _invoke( 988 runner, ["command", "interrupt", "sb-1", "exec-789"], sandbox=mock_sb 989 ) 990 assert result.exit_code == 0 991 mock_sb.commands.interrupt.assert_called_once_with("exec-789") 992 assert "Interrupted: exec-789" in result.output 993 994 995 class TestCommandSession: 996 def test_session_create(self, runner: CliRunner) -> None: 997 mock_sb = MagicMock() 998 mock_sb.id = "sb-1" 999 mock_sb.commands.create_session.return_value = "sess-123" 1000 result = _invoke( 1001 runner, 1002 ["command", "session", "create", "sb-1", "--workdir", "/workspace", "-o", "json"], 1003 sandbox=mock_sb, 1004 ) 1005 assert result.exit_code == 0 1006 data = json.loads(result.output) 1007 assert data["session_id"] == "sess-123" 1008 mock_sb.commands.create_session.assert_called_once_with(working_directory="/workspace") 1009 1010 def test_session_run(self, runner: CliRunner) -> None: 1011 mock_sb = MagicMock() 1012 mock_execution = MagicMock() 1013 mock_execution.error = None 1014 mock_sb.commands.run_in_session.return_value = mock_execution 1015 result = _invoke( 1016 runner, 1017 ["command", "session", "run", "sb-1", "sess-123", "--timeout", "30s", "--", "pwd"], 1018 sandbox=mock_sb, 1019 output_format="table", 1020 ) 1021 assert result.exit_code == 0 1022 mock_sb.commands.run_in_session.assert_called_once() 1023 assert mock_sb.commands.run_in_session.call_args.args[:2] == ("sess-123", "pwd") 1024 assert mock_sb.commands.run_in_session.call_args.kwargs["timeout"] == timedelta(seconds=30) 1025 1026 def test_session_delete(self, runner: CliRunner) -> None: 1027 mock_sb = MagicMock() 1028 result = _invoke( 1029 runner, 1030 ["command", "session", "delete", "sb-1", "sess-123"], 1031 sandbox=mock_sb, 1032 ) 1033 assert result.exit_code == 0 1034 mock_sb.commands.delete_session.assert_called_once_with("sess-123") 1035 assert "Deleted session: sess-123" in result.output 1036 1037 def test_session_run_rejects_json_output(self, runner: CliRunner) -> None: 1038 result = _invoke( 1039 runner, 1040 ["command", "session", "run", "sb-1", "sess-123", "-o", "json", "--", "pwd"], 1041 ) 1042 assert result.exit_code != 0 1043 assert "Invalid value for '-o' / '--output'" in result.output 1044 1045 1046 # --------------------------------------------------------------------------- 1047 # DevOps diagnostics 1048 # --------------------------------------------------------------------------- 1049 1050 1051 class TestDevopsCommands: 1052 def test_logs_fetches_plain_text(self, runner: CliRunner) -> None: 1053 mock_client = MagicMock() 1054 mock_response = MagicMock() 1055 mock_response.status_code = 200 1056 mock_response.text = "sandbox logs" 1057 mock_client.get.return_value = mock_response 1058 mock_ctx = _build_mock_client_context() 1059 mock_ctx.get_devops_client.return_value = mock_client 1060 1061 with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ 1062 patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx): 1063 mock_resolve.return_value = mock_ctx.resolved_config 1064 mock_ctx.output = OutputFormatter("table", color=False) 1065 result = runner.invoke(cli, ["devops", "logs", "sb-1"], catch_exceptions=False) 1066 1067 assert result.exit_code == 0 1068 assert "sandbox logs" in result.output 1069 mock_client.get.assert_called_once() 1070 assert mock_client.get.call_args.args[0] == "sandboxes/sb-1/diagnostics/logs" 1071 1072 def test_logs_reject_json_output(self, runner: CliRunner) -> None: 1073 result = _invoke(runner, ["devops", "logs", "sb-1", "-o", "json"]) 1074 assert result.exit_code != 0 1075 assert "Invalid value for '-o' / '--output'" in result.output