/ cli / tests / test_commands.py
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