from __future__ import annotations import json import os from pathlib import Path import tempfile import unittest from unittest.mock import patch from sailfish_devel_mcp.config import ( BUNDLED_BUILD_HELPER, Config, DeviceConfig, PathConfig, load_config, ) from sailfish_devel_mcp.runner import CommandResult from sailfish_devel_mcp.server import McpServer from sailfish_devel_mcp.tools import _device_home_path, _screenshot_prepare_command from sailfish_devel_mcp.vendor import build_sailfishos class McpServerTests(unittest.TestCase): def make_server(self, root: Path) -> McpServer: config = Config( path=None, default_device="test", devices={"test": DeviceConfig(name="test", ssh_target="root@test")}, paths=PathConfig( git_root=root, ssh_config=root / "ssh_config", build_sailfishos=root / "tools" / "build_sailfishos.py", osc_api_alias="jolla", ), ) return McpServer(config) def test_initialize_uses_requested_supported_version(self) -> None: with tempfile.TemporaryDirectory() as tmp: server = self.make_server(Path(tmp)) response = server.handle( { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}, }, } ) self.assertIsNotNone(response) self.assertEqual(response["result"]["protocolVersion"], "2025-06-18") def test_tools_list_contains_device_and_qml_tools(self) -> None: with tempfile.TemporaryDirectory() as tmp: server = self.make_server(Path(tmp)) response = server.handle( {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}} ) names = {tool["name"] for tool in response["result"]["tools"]} self.assertIn("sailfish_device_topmost_pid", names) self.assertIn("sailfish_device_touch", names) self.assertIn("sailfish_qml_check_translator_ternaries", names) def test_qml_ternary_checker_reports_inline_ternary_qstrid(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) qml = root / "Example.qml" qml.write_text( 'Text { text: enabled ? qsTrId("a") : qsTrId("b") }\n', encoding="utf-8", ) server = self.make_server(root) response = server.handle( { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "sailfish_qml_check_translator_ternaries", "arguments": {"path": str(root)}, }, } ) self.assertTrue(response["result"]["isError"]) findings = response["result"]["structuredContent"]["findings"] self.assertEqual(len(findings), 1) self.assertEqual(findings[0]["line"], 1) def test_spec_summary_parses_core_fields(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) rpm = root / "rpm" rpm.mkdir() spec = rpm / "sample.spec" spec.write_text( "\n".join( [ "Name: sample", "Version: 1.2.3", "Release: 1", "Summary: Sample package", "BuildRequires: pkgconfig(Qt5Core)", "Requires: lipstick", ] ), encoding="utf-8", ) server = self.make_server(root) response = server.handle( { "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "sailfish_spec_summary", "arguments": {"repo_path": str(root)}, }, } ) summary = response["result"]["structuredContent"]["summary"] self.assertEqual(summary["Name"], "sample") self.assertEqual(summary["Version"], "1.2.3") self.assertEqual(summary["BuildRequires"], ["pkgconfig(Qt5Core)"]) def test_server_writes_json_serializable_tool_result(self) -> None: with tempfile.TemporaryDirectory() as tmp: server = self.make_server(Path(tmp)) response = server.handle( { "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "sailfish_devices", "arguments": {}}, } ) json.dumps(response) def test_default_config_uses_bundled_build_helper(self) -> None: with tempfile.TemporaryDirectory() as tmp: missing = Path(tmp) / "missing-config.json" with patch.dict(os.environ, {}, clear=True): config = load_config(missing) self.assertEqual(config.default_device, "device") self.assertEqual(config.devices["device"].ssh_target, "root@device") self.assertEqual(config.devices["device"].username, "defaultuser") self.assertEqual(_device_home_path(config.devices["device"]), "/home/defaultuser") self.assertNotIn("ssh_config", config.devices["device"].public_dict()) self.assertEqual(config.paths.ssh_config, Path.home() / ".ssh" / "config") self.assertEqual(config.paths.build_sailfishos, BUNDLED_BUILD_HELPER) self.assertIsNone(config.paths.local_sdk) self.assertTrue(config.paths.build_sailfishos.exists()) self.assertNotIn("build-sailfishos-skill", str(config.paths.build_sailfishos)) def test_config_loads_local_sdk_from_paths(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) local_sdk = root / "sdk-chroot" ssh_config = root / "ssh_config" config_path = root / "config.json" config_path.write_text( json.dumps( { "devices": {"phone": "root@phone"}, "paths": { "git_root": str(root), "ssh_config": str(ssh_config), "local_sdk": str(local_sdk), }, } ), encoding="utf-8", ) config = load_config(config_path) self.assertEqual(config.paths.local_sdk, local_sdk) self.assertEqual(config.paths.ssh_config, ssh_config) self.assertEqual(config.devices["phone"].release, "") def test_config_keeps_device_release_literal(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) config_path = root / "config.json" config_path.write_text( json.dumps( { "devices": { "phone": { "ssh_target": "root@phone", "release": "devel", } } } ), encoding="utf-8", ) config = load_config(config_path) self.assertEqual(config.devices["phone"].release, "devel") def test_screenshot_prepare_command_uses_home_ownership(self) -> None: device = DeviceConfig(name="test", ssh_target="root@test") command = _screenshot_prepare_command( device, "/home/defaultuser/Pictures/Screenshots/example.png", ) self.assertIn("stat -Lc %U", command) self.assertIn("install -d -m 775", command) self.assertIn("install -d -m 755", command) self.assertIn("privileged", command) def test_device_touch_tap_builds_input_injection_command(self) -> None: with tempfile.TemporaryDirectory() as tmp: server = self.make_server(Path(tmp)) with patch("sailfish_devel_mcp.tools.run") as mocked_run: mocked_run.return_value = CommandResult( ("ssh", "root@test"), 0, "input_device=/dev/input/event5\ntap=ok\n", "", ) response = server.handle( { "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": { "name": "sailfish_device_touch", "arguments": { "action": "tap", "input_device": "/dev/input/event5", "x": 12, "y": 34, }, }, } ) self.assertFalse(response["result"].get("isError", False)) argv = mocked_run.call_args.args[0] remote = argv[-1] self.assertEqual(argv[:3], ["ssh", "-F", str(Path(tmp) / "ssh_config")]) self.assertIn("INPUT_DEVICE=/dev/input/event5", remote) self.assertIn("X=12", remote) self.assertIn("Y=34", remote) self.assertIn("ABS_MT_TRACKING_ID", remote) def test_device_touch_discover_uses_evdev_trace_when_available(self) -> None: with tempfile.TemporaryDirectory() as tmp: server = self.make_server(Path(tmp)) with patch("sailfish_devel_mcp.tools.run") as mocked_run: mocked_run.return_value = CommandResult(("ssh", "root@test"), 0, "", "") response = server.handle( { "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": "sailfish_device_touch", "arguments": {"action": "discover"}, }, } ) self.assertFalse(response["result"].get("isError", False)) remote = mocked_run.call_args.args[0][-1] self.assertNotIn("evdev_trace -i", remote) self.assertIn("/proc/bus/input/devices", remote) def test_device_touch_discover_can_include_evdev_trace(self) -> None: with tempfile.TemporaryDirectory() as tmp: server = self.make_server(Path(tmp)) with patch("sailfish_devel_mcp.tools.run") as mocked_run: mocked_run.return_value = CommandResult(("ssh", "root@test"), 0, "", "") response = server.handle( { "jsonrpc": "2.0", "id": 8, "method": "tools/call", "params": { "name": "sailfish_device_touch", "arguments": { "action": "discover", "include_evdev_trace": True, }, }, } ) self.assertFalse(response["result"].get("isError", False)) remote = mocked_run.call_args.args[0][-1] self.assertIn("evdev_trace -i", remote) def test_build_defaults_prefer_configured_local_sdk(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) (root / "rpm").mkdir() (root / "rpm" / "sample.spec").write_text("Name: sample\n", encoding="utf-8") helper = root / "build_sailfishos.py" local_sdk = root / "sdk-chroot" helper.write_text("#!/usr/bin/env python3\n", encoding="utf-8") config = Config( path=None, default_device="phone", devices={ "phone": DeviceConfig( name="phone", ssh_target="root@phone", architecture="aarch64", release="live", ) }, paths=PathConfig( git_root=root, ssh_config=root / "ssh_config", build_sailfishos=helper, local_sdk=local_sdk, ), ) server = McpServer(config) with patch("sailfish_devel_mcp.tools.run") as mocked_run: mocked_run.return_value = CommandResult(("python3", str(helper)), 0, "", "") response = server.handle( { "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": { "name": "sailfish_build_rpm", "arguments": {"project_path": str(root), "device": "phone"}, }, } ) self.assertFalse(response["result"].get("isError", False)) argv = list(mocked_run.call_args.args[0]) self.assertIn("--local-sdk", argv) self.assertIn(str(local_sdk), argv) self.assertIn("--release", argv) self.assertIn("live", argv) self.assertIn("--arch", argv) self.assertIn("aarch64", argv) def write_fake_target( self, root: Path, name: str, *, ssu_release: str, version_id: str, flavour: str = "devel", ) -> None: target = root / "targets" / name (target / "etc" / "ssu").mkdir(parents=True) (target / "etc").mkdir(exist_ok=True) (target / "etc" / "ssu" / "ssu.ini").write_text( f"[rnd]\nrelease={ssu_release}\nflavour={flavour}\n", encoding="utf-8", ) (target / "etc" / "sailfish-release").write_text( f'VERSION_ID={version_id}\nSAILFISH_FLAVOUR="{flavour}"\n', encoding="utf-8", ) def test_build_helper_selects_matching_local_sdk_targets(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) local_sdk = root / "sdks" / "sfossdk" / "sdk-chroot" local_sdk.parent.mkdir(parents=True) local_sdk.touch() sdk_root = local_sdk.parent self.write_fake_target(sdk_root, "aarch64", ssu_release="live", version_id="5.2.0.4") self.write_fake_target( sdk_root, "aarch64-5.0.0", ssu_release="5.0.0", version_id="5.0.0.191", ) self.assertEqual(build_sailfishos.normalize_release_tag("live"), "live") self.assertEqual(build_sailfishos.normalize_release_tag("devel"), "devel") self.assertEqual(build_sailfishos.normalize_local_release("devel"), "devel") live = build_sailfishos.select_local_sdk_builds( local_sdk, "live", ["aarch64"], False, root, ) versioned = build_sailfishos.select_local_sdk_builds( local_sdk, "5.0.0", ["aarch64"], False, root, ) missing = build_sailfishos.select_local_sdk_builds( local_sdk, "4.5.0", ["aarch64"], False, root, ) exact_mismatch = build_sailfishos.select_local_sdk_builds( local_sdk, "5.0.0.55", ["aarch64"], False, root, ) self.assertEqual(live, [build_sailfishos.LocalSdkBuild("aarch64", "aarch64")]) self.assertEqual( versioned, [build_sailfishos.LocalSdkBuild("aarch64", "aarch64-5.0.0")], ) self.assertIsNone(missing) self.assertIsNone(exact_mismatch) if __name__ == "__main__": unittest.main()