diff --git a/Server/src/cli/commands/camera.py b/Server/src/cli/commands/camera.py index 5f4f82718..e2d7f98c2 100644 --- a/Server/src/cli/commands/camera.py +++ b/Server/src/cli/commands/camera.py @@ -51,8 +51,8 @@ def ping(): unity-mcp camera ping """ config = get_config() - result = run_command(config, "manage_camera", {"action": "ping"}) - format_output(result, config) + result = run_command("manage_camera", {"action": "ping"}, config) + click.echo(format_output(result, config.format)) @camera.command("list") @@ -65,8 +65,8 @@ def list_cameras(): unity-mcp camera list """ config = get_config() - result = run_command(config, "manage_camera", {"action": "list_cameras"}) - format_output(result, config) + result = run_command("manage_camera", {"action": "list_cameras"}, config) + click.echo(format_output(result, config.format)) @camera.command("brain-status") @@ -79,8 +79,8 @@ def brain_status(): unity-mcp camera brain-status """ config = get_config() - result = run_command(config, "manage_camera", {"action": "get_brain_status"}) - format_output(result, config) + result = run_command("manage_camera", {"action": "get_brain_status"}, config) + click.echo(format_output(result, config.format)) # ============================================================================= @@ -125,8 +125,8 @@ def create(name, preset, follow, look_at, priority, fov): if props: params["properties"] = props - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) @camera.command("ensure-brain") @@ -155,8 +155,8 @@ def ensure_brain(camera_ref, blend_style, blend_duration): if props: params["properties"] = props - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) # ============================================================================= @@ -189,8 +189,8 @@ def set_target(target, search_method, follow, look_at): "searchMethod": search_method, "properties": props if props else None, }) - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) @camera.command("set-lens") @@ -228,8 +228,8 @@ def set_lens(target, search_method, fov, near, far, ortho_size, dutch): "searchMethod": search_method, "properties": props if props else None, }) - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) @camera.command("set-priority") @@ -251,8 +251,8 @@ def set_priority(target, search_method, priority): "searchMethod": search_method, "properties": {"priority": priority}, }) - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) # ============================================================================= @@ -286,8 +286,8 @@ def set_body(target, search_method, body_type, props): "searchMethod": search_method, "properties": properties if properties else None, }) - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) @camera.command("set-aim") @@ -316,8 +316,8 @@ def set_aim(target, search_method, aim_type, props): "searchMethod": search_method, "properties": properties if properties else None, }) - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) @camera.command("set-noise") @@ -346,8 +346,8 @@ def set_noise(target, search_method, amplitude, frequency): "searchMethod": search_method, "properties": props if props else None, }) - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) # ============================================================================= @@ -379,8 +379,8 @@ def add_extension(target, extension_type, search_method, props): "searchMethod": search_method, "properties": properties, }) - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) @camera.command("remove-extension") @@ -402,8 +402,8 @@ def remove_extension(target, extension_type, search_method): "searchMethod": search_method, "properties": {"extensionType": extension_type}, }) - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) # ============================================================================= @@ -432,8 +432,8 @@ def set_blend(style, duration): if props: params["properties"] = props - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) @camera.command("force") @@ -453,8 +453,8 @@ def force_camera(target, search_method): "target": target, "searchMethod": search_method, }) - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) @camera.command("release") @@ -467,8 +467,8 @@ def release_override(): unity-mcp camera release """ config = get_config() - result = run_command(config, "manage_camera", {"action": "release_override"}) - format_output(result, config) + result = run_command("manage_camera", {"action": "release_override"}, config) + click.echo(format_output(result, config.format)) # ============================================================================= @@ -523,8 +523,8 @@ def screenshot(camera_ref, file_name, super_size, include_image, max_resolution, params["viewTarget"] = view_target if output_folder: params["outputFolder"] = output_folder - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) @camera.command("screenshot-multiview") @@ -550,5 +550,5 @@ def screenshot_multiview(max_resolution, view_target, output_folder): params["viewTarget"] = view_target if output_folder: params["outputFolder"] = output_folder - result = run_command(config, "manage_camera", params) - format_output(result, config) + result = run_command("manage_camera", params, config) + click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index 8b7746657..5b0dce795 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -325,8 +325,13 @@ def execute_menu(menu_path: str): is_flag=True, help="Include details for failed/skipped tests only." ) +@click.option( + "--clear-stuck", + is_flag=True, + help="Clear an orphaned running job that is blocking new runs, instead of starting a run." +) @handle_unity_errors -def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, failed_only: bool): +def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, failed_only: bool, clear_stuck: bool): """Run Unity tests. \b @@ -335,9 +340,15 @@ def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, f unity-mcp editor tests --mode PlayMode unity-mcp editor tests --async unity-mcp editor tests --wait 60 --failed-only + unity-mcp editor tests --clear-stuck """ config = get_config() + if clear_stuck: + result = run_command("run_tests", {"clear_stuck": True}, config) + click.echo(format_output(result, config.format)) + return + params: dict[str, Any] = {"mode": mode} if wait is not None: params["wait_timeout"] = wait diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index 0426e63b5..803554baa 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -170,12 +170,29 @@ async def run_tests( init_timeout: Annotated[int | None, "Initialization timeout in milliseconds. PlayMode tests may need longer " "due to domain reload (default: 15000). Recommended: 120000 for PlayMode."] = None, + clear_stuck: Annotated[bool, + "Clear an orphaned running job instead of starting a run. Use when a job " + "was lost to a domain reload and is blocking every subsequent run."] = False, ) -> RunTestsStartResponse | MCPResponse: + unity_instance = await get_unity_instance_from_context(ctx) + + # Runs before both the init_timeout check and preflight on purpose: neither is relevant to + # clearing, and requires_no_tests would reject the very call that exists to clear the + # orphaned job blocking it. + if clear_stuck: + response = await unity_transport.send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "run_tests", + {"clear_stuck": True}, + ) + if isinstance(response, dict): + return MCPResponse(**response) + return MCPResponse(success=False, error=str(response)) + if init_timeout is not None and init_timeout <= 0: return MCPResponse(success=False, error="init_timeout must be a positive integer (milliseconds) or None") - unity_instance = await get_unity_instance_from_context(ctx) - gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True) if isinstance(gate, MCPResponse): return gate diff --git a/Server/tests/integration/test_run_tests_async.py b/Server/tests/integration/test_run_tests_async.py index a8098ea6c..79bd24af7 100644 --- a/Server/tests/integration/test_run_tests_async.py +++ b/Server/tests/integration/test_run_tests_async.py @@ -93,6 +93,90 @@ async def test_run_tests_rejects_zero_init_timeout(): assert "init_timeout" in resp.error +@pytest.mark.asyncio +async def test_run_tests_clear_stuck_forwards_only_the_flag(monkeypatch): + from services.tools.run_tests import run_tests + + captured = {} + + async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): + captured["command_type"] = command_type + captured["params"] = params + return {"success": True, "message": "Stuck job cleared.", "data": {"cleared": True}} + + import services.tools.run_tests as mod + monkeypatch.setattr( + mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) + + resp = await run_tests(DummyContext(), clear_stuck=True) + + # C# reads @params["clear_stuck"] verbatim (RunTests.cs:23), so the key must stay snake_case. + assert captured["command_type"] == "run_tests" + assert captured["params"] == {"clear_stuck": True} + assert resp.success is True + assert resp.data == {"cleared": True} + + +@pytest.mark.asyncio +async def test_run_tests_clear_stuck_bypasses_preflight(monkeypatch): + """#1272: preflight(requires_no_tests=True) would reject the call that clears the job blocking it.""" + from services.tools.run_tests import run_tests + + async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): + return {"success": True, "message": "Stuck job cleared.", "data": {"cleared": True}} + + async def exploding_preflight(*args, **kwargs): + raise AssertionError("clear_stuck must short-circuit before preflight") + + import services.tools.run_tests as mod + monkeypatch.setattr( + mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) + monkeypatch.setattr(mod, "preflight", exploding_preflight) + + resp = await run_tests(DummyContext(), clear_stuck=True) + assert resp.success is True + + +@pytest.mark.asyncio +async def test_run_tests_clear_stuck_ignores_invalid_init_timeout(monkeypatch): + """Recovery must be unconditional: an unrelated bad arg must not block clearing.""" + from services.tools.run_tests import run_tests + + async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): + return {"success": True, "message": "Stuck job cleared.", "data": {"cleared": True}} + + import services.tools.run_tests as mod + monkeypatch.setattr( + mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) + + resp = await run_tests(DummyContext(), clear_stuck=True, init_timeout=0) + assert resp.success is True + + +@pytest.mark.asyncio +async def test_run_tests_without_clear_stuck_still_preflights(monkeypatch): + from services.tools.run_tests import run_tests + + calls = [] + + async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): + return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "EditMode"}} + + async def recording_preflight(*args, **kwargs): + calls.append(kwargs) + return None + + import services.tools.run_tests as mod + monkeypatch.setattr( + mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) + monkeypatch.setattr(mod, "preflight", recording_preflight) + + resp = await run_tests(DummyContext(), mode="EditMode") + assert len(calls) == 1 + assert calls[0]["requires_no_tests"] is True + assert resp.success is True + + @pytest.mark.asyncio async def test_get_test_job_forwards_job_id(monkeypatch): from services.tools.run_tests import get_test_job diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index ccaebaa26..65f64f781 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -470,11 +470,29 @@ def test_camera_screenshot_scene_view(self, runner, mock_unity_response): ]) assert result.exit_code == 0 mock_run.assert_called_once() - params = mock_run.call_args[0][2] + assert mock_run.call_args[0][0] == "manage_camera" + params = mock_run.call_args[0][1] assert params["captureSource"] == "scene_view" assert params["viewTarget"] == "Canvas" assert params["includeImage"] is True + def test_camera_ping_prints_output(self, runner, mock_unity_response): + """The camera group must actually emit its result. + + Asserting only on run_command's arguments is what let the group ship + while formatting to a discarded string and printing nothing at all. + """ + with patch("cli.commands.camera.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["camera", "ping"]) + assert result.exit_code == 0 + assert result.output.strip() != "" + + def test_camera_respects_json_format(self, runner, mock_unity_response): + """--format json must reach format_output, not be swallowed by a config object.""" + with patch("cli.commands.camera.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, ["--format", "json", "camera", "ping"]) + assert result.exit_code == 0 + json.loads(result.output) # ============================================================================= diff --git a/Server/uv.lock b/Server/uv.lock index a7e843476..81a005bf9 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -858,7 +858,7 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "10.0.0" +version = "10.1.0" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/website/docs/reference/tools/testing/run_tests.md b/website/docs/reference/tools/testing/run_tests.md index 07e90da52..1fcf8a1e3 100644 --- a/website/docs/reference/tools/testing/run_tests.md +++ b/website/docs/reference/tools/testing/run_tests.md @@ -26,6 +26,7 @@ Starts a Unity test run asynchronously and returns a job_id immediately. Poll wi | `include_failed_tests` | `bool` | — | Include details for failed/skipped tests only (default: false) | | `include_details` | `bool` | — | Include details for all tests (default: false) | | `init_timeout` | `int \| None` | — | Initialization timeout in milliseconds. PlayMode tests may need longer due to domain reload (default: 15000). Recommended: 120000 for PlayMode. | +| `clear_stuck` | `bool` | — | Clear an orphaned running job instead of starting a run. Use when a job was lost to a domain reload and is blocking every subsequent run. | ## Returns