From 1f14c5483ee111105f94d66fb3b82208946d914a Mon Sep 17 00:00:00 2001 From: Andrew Branson Date: Fri, 15 May 2026 12:21:39 +0200 Subject: Initial Sailfish devel MCP --- tests/test_server.py | 418 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 tests/test_server.py (limited to 'tests') diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..e04b22b --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,418 @@ +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() -- cgit v1.2.3