From 0cdf2d00cdc1a9af5f5b0b14241a2afe717661f6 Mon Sep 17 00:00:00 2001 From: Andrew Branson Date: Tue, 2 Jun 2026 00:17:51 +0200 Subject: Add Sailfish MCP build and device helpers Add OBS root scoping, user-session/browser/touch workflow device tools, local SDK metadata refresh, and asynchronous RPM build job status support. --- tests/test_server.py | 323 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 320 insertions(+), 3 deletions(-) (limited to 'tests/test_server.py') diff --git a/tests/test_server.py b/tests/test_server.py index e04b22b..26d01b7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -28,6 +28,7 @@ class McpServerTests(unittest.TestCase): devices={"test": DeviceConfig(name="test", ssh_target="root@test")}, paths=PathConfig( git_root=root, + obs_root=root / "OBS", ssh_config=root / "ssh_config", build_sailfishos=root / "tools" / "build_sailfishos.py", osc_api_alias="jolla", @@ -62,6 +63,10 @@ class McpServerTests(unittest.TestCase): 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_device_touch_workflow", names) + self.assertIn("sailfish_device_user_session_command", names) + self.assertIn("sailfish_device_browser_launch", names) + self.assertIn("sailfish_sdk_refresh_metadata", names) self.assertIn("sailfish_qml_check_translator_ternaries", names) def test_qml_ternary_checker_reports_inline_ternary_qstrid(self) -> None: @@ -149,6 +154,7 @@ class McpServerTests(unittest.TestCase): 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.obs_root, Path.home() / "OBS") self.assertEqual(config.paths.build_sailfishos, BUNDLED_BUILD_HELPER) self.assertIsNone(config.paths.local_sdk) self.assertTrue(config.paths.build_sailfishos.exists()) @@ -158,6 +164,7 @@ class McpServerTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) local_sdk = root / "sdk-chroot" + obs_root = root / "OBS" ssh_config = root / "ssh_config" config_path = root / "config.json" config_path.write_text( @@ -166,6 +173,7 @@ class McpServerTests(unittest.TestCase): "devices": {"phone": "root@phone"}, "paths": { "git_root": str(root), + "obs_root": str(obs_root), "ssh_config": str(ssh_config), "local_sdk": str(local_sdk), }, @@ -175,6 +183,7 @@ class McpServerTests(unittest.TestCase): ) config = load_config(config_path) self.assertEqual(config.paths.local_sdk, local_sdk) + self.assertEqual(config.paths.obs_root, obs_root) self.assertEqual(config.paths.ssh_config, ssh_config) self.assertEqual(config.devices["phone"].release, "") @@ -198,6 +207,35 @@ class McpServerTests(unittest.TestCase): config = load_config(config_path) self.assertEqual(config.devices["phone"].release, "devel") + def test_repo_find_allows_obs_root_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + obs_checkout = root / "OBS" / "home:example" / "browser" + obs_checkout.mkdir(parents=True) + server = self.make_server(root) + with ( + patch("sailfish_devel_mcp.tools.shutil.which", return_value="/usr/bin/rg"), + patch("sailfish_devel_mcp.tools.run") as mocked_run, + ): + mocked_run.return_value = CommandResult(("rg",), 0, "match\n", "") + response = server.handle( + { + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "sailfish_repo_find", + "arguments": { + "path": str(obs_checkout), + "query": "Package", + }, + }, + } + ) + self.assertFalse(response["result"].get("isError", False)) + argv = list(mocked_run.call_args.args[0]) + self.assertEqual(argv[-1], str(obs_checkout)) + def test_screenshot_prepare_command_uses_home_ownership(self) -> None: device = DeviceConfig(name="test", ssh_target="root@test") command = _screenshot_prepare_command( @@ -288,6 +326,169 @@ class McpServerTests(unittest.TestCase): remote = mocked_run.call_args.args[0][-1] self.assertIn("evdev_trace -i", remote) + def test_device_touch_workflow_screenshots_discovers_and_taps(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.side_effect = [ + CommandResult(("ssh", "root@test"), 0, "screenshot\n", ""), + CommandResult(("ssh", "root@test"), 0, "inputs\n", ""), + CommandResult(("ssh", "root@test"), 0, "tap=ok\n", ""), + ] + response = server.handle( + { + "jsonrpc": "2.0", + "id": 9, + "method": "tools/call", + "params": { + "name": "sailfish_device_touch_workflow", + "arguments": {"action": "tap", "x": 12, "y": 34}, + }, + } + ) + self.assertFalse(response["result"].get("isError", False)) + self.assertEqual( + response["result"]["structuredContent"]["step_order"], + ["screenshot_before", "touchscreen_discovery", "touch"], + ) + remotes = [call.args[0][-1] for call in mocked_run.call_args_list] + self.assertIn("saveScreenshot", remotes[0]) + self.assertIn("/proc/bus/input/devices", remotes[1]) + self.assertIn("X=12", remotes[2]) + self.assertIn("Y=34", remotes[2]) + + def test_user_session_command_can_run_as_defaultuser(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": 10, + "method": "tools/call", + "params": { + "name": "sailfish_device_user_session_command", + "arguments": { + "command": ["systemctl", "--user", "status", "app.service"], + "run_as_user": True, + }, + }, + } + ) + self.assertFalse(response["result"].get("isError", False)) + remote = mocked_run.call_args.args[0][-1] + self.assertIn("runuser -u defaultuser -- env", remote) + self.assertIn("XDG_RUNTIME_DIR=/run/user/100000", remote) + self.assertIn("DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/100000/dbus/user_bus_socket", remote) + self.assertIn("systemctl --user status app.service", remote) + + def test_browser_launch_uses_display_env_and_reports_topmost_pid(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": 11, + "method": "tools/call", + "params": { + "name": "sailfish_device_browser_launch", + "arguments": {"url": "https://example.com"}, + }, + } + ) + self.assertFalse(response["result"].get("isError", False)) + remote = mocked_run.call_args.args[0][-1] + self.assertIn("WAYLAND_DISPLAY=../../display/wayland-0", remote) + self.assertIn("/usr/bin/invoker --type=browser,silica-qt5 -A --", remote) + self.assertIn("/usr/bin/sailfish-browser https://example.com", remote) + self.assertIn("privateTopmostWindowProcessId", remote) + + def test_obs_buildlog_uses_nostream_api_by_default(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(("osc",), 0, "", "") + response = server.handle( + { + "jsonrpc": "2.0", + "id": 12, + "method": "tools/call", + "params": { + "name": "sailfish_obs_buildlog", + "arguments": { + "project": "home:example", + "package": "browser", + "repository": "5.0.0", + "arch": "aarch64", + }, + }, + } + ) + self.assertFalse(response["result"].get("isError", False)) + argv = list(mocked_run.call_args.args[0]) + self.assertEqual(argv[:3], ["osc", "-A", "jolla"]) + self.assertEqual(argv[3], "api") + self.assertEqual( + argv[4], + "/build/home%3Aexample/5.0.0/aarch64/browser/_log?nostream=1", + ) + + def test_sdk_refresh_metadata_uses_local_sdk_main_target(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() + self.write_fake_target( + local_sdk.parent, + "aarch64", + ssu_release="live", + version_id="5.2.0.4", + ) + 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", + local_sdk=local_sdk, + ), + ) + server = McpServer(config) + with patch("sailfish_devel_mcp.tools.run") as mocked_run: + mocked_run.return_value = CommandResult(("docker",), 0, "", "") + response = server.handle( + { + "jsonrpc": "2.0", + "id": 13, + "method": "tools/call", + "params": { + "name": "sailfish_sdk_refresh_metadata", + "arguments": {"device": "phone"}, + }, + } + ) + self.assertFalse(response["result"].get("isError", False)) + argv = list(mocked_run.call_args.args[0]) + self.assertEqual(argv[:3], ["docker", "run", "--rm"]) + wrapper = argv[-1] + self.assertIn("sb2 -t aarch64.default -m sdk-install -R zypper ref", wrapper) + self.assertEqual( + response["result"]["structuredContent"]["main_target"], + "aarch64.default", + ) + def test_build_defaults_prefer_configured_local_sdk(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -315,8 +516,12 @@ class McpServerTests(unittest.TestCase): ), ) server = McpServer(config) - with patch("sailfish_devel_mcp.tools.run") as mocked_run: - mocked_run.return_value = CommandResult(("python3", str(helper)), 0, "", "") + with patch("sailfish_devel_mcp.tools._start_background_command") as mocked_start: + mocked_start.return_value = { + "job_id": "build-test", + "status_path": str(root / "status.json"), + "log_path": str(root / "build.log"), + } response = server.handle( { "jsonrpc": "2.0", @@ -329,13 +534,108 @@ class McpServerTests(unittest.TestCase): } ) self.assertFalse(response["result"].get("isError", False)) - argv = list(mocked_run.call_args.args[0]) + argv = list(mocked_start.call_args.args[1]) 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) + self.assertEqual( + response["result"]["structuredContent"]["job_id"], + "build-test", + ) + + def test_build_without_release_defaults_to_live_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")}, + 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._start_background_command") as mocked_start: + mocked_start.return_value = { + "job_id": "build-test", + "status_path": str(root / "status.json"), + "log_path": str(root / "build.log"), + } + response = server.handle( + { + "jsonrpc": "2.0", + "id": 14, + "method": "tools/call", + "params": { + "name": "sailfish_build_rpm", + "arguments": { + "project_path": str(root), + "arch": "aarch64", + }, + }, + } + ) + self.assertFalse(response["result"].get("isError", False)) + argv = list(mocked_start.call_args.args[1]) + 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 test_build_wait_true_uses_synchronous_runner(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" + 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")}, + paths=PathConfig( + git_root=root, + ssh_config=root / "ssh_config", + build_sailfishos=helper, + ), + ) + server = McpServer(config) + with patch("sailfish_devel_mcp.tools.run") as mocked_run: + mocked_run.return_value = CommandResult(("python3", str(helper)), 0, "ok", "") + response = server.handle( + { + "jsonrpc": "2.0", + "id": 15, + "method": "tools/call", + "params": { + "name": "sailfish_build_rpm", + "arguments": { + "project_path": str(root), + "arch": "aarch64", + "wait": True, + }, + }, + } + ) + + self.assertFalse(response["result"].get("isError", False)) + self.assertEqual(response["result"]["structuredContent"]["returncode"], 0) + argv = list(mocked_run.call_args.args[0]) + self.assertIn("--arch", argv) + self.assertIn("aarch64", argv) def write_fake_target( self, @@ -413,6 +713,23 @@ class McpServerTests(unittest.TestCase): self.assertIsNone(missing) self.assertIsNone(exact_mismatch) + def test_build_helper_local_sdk_default_ignores_workflow_release(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") + workflows = root / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "build.yml").write_text("env:\n RELEASE: 5.0.0\n", encoding="utf-8") + + with patch.dict(os.environ, {}, clear=True): + self.assertEqual( + build_sailfishos.requested_release((root,), None), + "5.0.0", + ) + self.assertIsNone(build_sailfishos.explicit_requested_release(None)) + self.assertEqual(build_sailfishos.local_sdk_requested_release(None), "live") + if __name__ == "__main__": unittest.main() -- cgit v1.2.3