From 1487796d74a7b171f2c53c4212e446759d3a4bec Mon Sep 17 00:00:00 2001 From: historia Date: Fri, 28 Aug 2026 18:18:47 -0400 Subject: feat: offer to download prebuilt mac/win binaries, new macos build fallback if xcode is not installed --- app/tests/test_backends_audiocpp.py | 521 +++++++++++++++++++++++++++++++++++- 1 file changed, 520 insertions(+), 1 deletion(-) (limited to 'app/tests/test_backends_audiocpp.py') diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 7f14b68..4c00001 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -1,14 +1,19 @@ """Tests for the audio.cpp backend setup module (backends/audiocpp.py).""" import argparse +import hashlib import io import json +import shutil import sys +import tarfile import tempfile import threading import unittest +import zipfile from contextlib import redirect_stdout from pathlib import Path +from typing import Optional from unittest.mock import MagicMock, patch from converter import config @@ -574,7 +579,7 @@ class BackendOptionsTests(unittest.TestCase): with patch("sys.platform", "darwin"): options, default_index = make_server.catalog._backend_options() self.assertEqual(options, - [("cpu - Apple Metal (recorded as cpu)", "cpu")]) + [("cpu - Apple Metal", "cpu")]) self.assertEqual(default_index, 0) def test_darwin_marks_detected_cpu(self): @@ -1299,6 +1304,8 @@ class BuildAudiocppTests(unittest.TestCase): def test_darwin_uses_metal_script_without_backend(self): with patch.object(common, "run_console_subprocess", return_value=0) as run, \ + patch.object(make_server.build, "_metal_compiler_available", + return_value=True), \ patch("sys.platform", "darwin"): rc = make_server.build.build_audiocpp(self.checkout, "cpu") self.assertEqual(rc, 0) @@ -1311,9 +1318,46 @@ class BuildAudiocppTests(unittest.TestCase): self.assertIn("--deployment-build", argv) self.assertEqual(run.call_args[1]["cwd"], self.checkout) + def test_darwin_without_metal_compiler_runs_cmake_directly(self): + # No Xcode (no offline Metal compiler): build_metal.sh would abort + # at its probe, so the build must fall back to a direct cmake + # invocation with the Metal shaders embedded as source — which + # needs only the Command Line Tools. + with patch.object(common, "run_console_subprocess", + return_value=0) as run, \ + patch.object(make_server.build, "_metal_compiler_available", + return_value=False), \ + patch("sys.platform", "darwin"): + rc = make_server.build.build_audiocpp(self.checkout, "cpu") + self.assertEqual(rc, 0) + argv = run.call_args[0][0] + self.assertEqual(argv[:2], ["bash", "-c"]) + script = argv[2] + self.assertIn("cmake -S . -B build/macos-metal-release", script) + self.assertIn("-DENGINE_ENABLE_METAL=ON", script) + self.assertIn("-DGGML_METAL_EMBED_LIBRARY=ON", script) + self.assertIn("-DAUDIOCPP_DEPLOYMENT_BUILD=ON", script) + self.assertIn("cmake --build build/macos-metal-release", script) + self.assertIn("--target audiocpp_server", script) + self.assertNotIn("build_metal.sh", script) + self.assertEqual(run.call_args[1]["cwd"], self.checkout) + + def test_metal_compiler_probe(self): + # The probe mirrors build_metal.sh's own check: a failing (or + # missing) xcrun metal means the offline compiler is absent. + with patch.object(common, "run_console_subprocess_quiet", + return_value=None): + self.assertFalse( + make_server.build._metal_compiler_available()) + with patch.object(common, "run_console_subprocess_quiet", + return_value=MagicMock(returncode=0)): + self.assertTrue(make_server.build._metal_compiler_available()) + def test_darwin_cuda_backend_gets_no_arch_flags(self): with patch.object(common, "run_console_subprocess", return_value=0) as run, \ + patch.object(make_server.build, "_metal_compiler_available", + return_value=True), \ patch("sys.platform", "darwin"): rc = make_server.build.build_audiocpp(self.checkout, "cuda") self.assertEqual(rc, 0) @@ -3087,3 +3131,478 @@ class LaunchHintTests(unittest.TestCase): "-Target audiocpp_server -DeploymentBuild", out) self.assertIn("windows-cpu-release", out) self.assertIn("build_windows_hip.ps1", out) + + +def _asset(name, size=1234, digest=None): + """A GitHub release asset entry shaped like the API returns.""" + return {"name": name, "size": size, "digest": digest, + "browser_download_url": f"https://example.test/{name}"} + + +def _release(assets, tag="v9.9.9"): + return {"tag_name": tag, "assets": assets} + + +def _sha256_file(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _make_tar_gz(tmp: Path, name: str, entries: dict, + mode: int = 0o755) -> Path: + archive = tmp / name + with tarfile.open(archive, "w:gz") as tf: + for member, content in entries.items(): + data = content.encode("utf-8") + info = tarfile.TarInfo(member) + info.size = len(data) + info.mode = mode + tf.addfile(info, io.BytesIO(data)) + return archive + + +def _make_zip(tmp: Path, name: str, entries: dict) -> Path: + archive = tmp / name + with zipfile.ZipFile(archive, "w") as zf: + for member, content in entries.items(): + zf.writestr(member, content) + return archive + + +class PrebuiltSelectTests(unittest.TestCase): + """Asset selection for the prebuilt release downloads.""" + + ASSETS = [ + _asset("audio-v9.9.9-bin-macos-arm64-metal.tar.gz"), + _asset("audio-v9.9.9-bin-macos-x64-metal.tar.gz"), + _asset("audio-v9.9.9-bin-windows-x64-cpu.zip"), + _asset("audio-v9.9.9-bin-windows-x64-vulkan.zip"), + _asset("audio-v9.9.9-bin-windows-x64-cuda12.4.zip"), + _asset("audio-v9.9.9-bin-windows-x64-cuda13.3.zip"), + _asset("audio-v9.9.9-cudart-windows-x64-cuda12.4.zip"), + _asset("audio-v9.9.9-cudart-windows-x64-cuda13.3.zip"), + _asset("audio-v9.9.9-bin-ubuntu-x64-cpu.tar.gz"), + ] + + def test_darwin_arm64_selects_the_metal_tarball(self): + pair = make_server.prebuilt.select_assets( + self.ASSETS, "cpu", platform="darwin", machine="arm64") + self.assertEqual( + pair[0]["name"], "audio-v9.9.9-bin-macos-arm64-metal.tar.gz") + self.assertIsNone(pair[1]) + + def test_darwin_x64_selects_its_own_tarball(self): + pair = make_server.prebuilt.select_assets( + self.ASSETS, "cpu", platform="darwin", machine="x86_64") + self.assertEqual( + pair[0]["name"], "audio-v9.9.9-bin-macos-x64-metal.tar.gz") + + def test_darwin_has_no_cuda_asset(self): + self.assertIsNone(make_server.prebuilt.select_assets( + self.ASSETS, "cuda", platform="darwin", machine="arm64")) + + def test_windows_selects_cpu_and_vulkan_zips(self): + for backend, name in (("cpu", "audio-v9.9.9-bin-windows-x64-cpu.zip"), + ("vulkan", + "audio-v9.9.9-bin-windows-x64-vulkan.zip")): + pair = make_server.prebuilt.select_assets( + self.ASSETS, backend, platform="win32", machine="AMD64") + self.assertEqual(pair[0]["name"], name) + self.assertIsNone(pair[1]) + + def test_windows_cuda_pairs_the_binary_with_its_cudart(self): + pair = make_server.prebuilt.select_assets( + self.ASSETS, "cuda", platform="win32", machine="AMD64", + cuda_variant="12.4") + self.assertEqual(pair[0]["name"], + "audio-v9.9.9-bin-windows-x64-cuda12.4.zip") + self.assertEqual(pair[1]["name"], + "audio-v9.9.9-cudart-windows-x64-cuda12.4.zip") + + def test_windows_cuda_without_cudart_asset_is_rejected(self): + assets = [_asset("audio-v9.9.9-bin-windows-x64-cuda12.4.zip")] + self.assertIsNone(make_server.prebuilt.select_assets( + assets, "cuda", platform="win32", machine="AMD64", + cuda_variant="12.4")) + + def test_windows_hip_has_no_asset(self): + self.assertIsNone(make_server.prebuilt.select_assets( + self.ASSETS, "hip", platform="win32", machine="AMD64")) + + def test_windows_arm64_has_no_asset(self): + self.assertIsNone(make_server.prebuilt.select_assets( + self.ASSETS, "cpu", platform="win32", machine="arm64")) + + def test_linux_never_selects(self): + self.assertIsNone(make_server.prebuilt.select_assets( + self.ASSETS, "cuda", platform="linux", machine="x86_64")) + + def test_prebuilt_supported_matrix(self): + supported = make_server.prebuilt.prebuilt_supported + self.assertTrue(supported("cpu", platform="darwin")) + self.assertFalse(supported("cuda", platform="darwin")) + self.assertTrue(supported("cpu", platform="win32")) + self.assertTrue(supported("vulkan", platform="win32")) + self.assertTrue(supported("cuda", platform="win32")) + self.assertFalse(supported("hip", platform="win32")) + self.assertFalse(supported("cuda", platform="linux")) + + def test_default_cuda_variant_follows_the_driver(self): + def driver(version): + proc = MagicMock() + proc.returncode = 0 + proc.stdout = f"{version}\n".encode("utf-8") + return proc + + with patch.object(make_server.prebuilt.common, + "run_console_subprocess_quiet", + return_value=driver("580.82.07")): + self.assertEqual( + make_server.prebuilt._default_cuda_variant(), "13.3") + with patch.object(make_server.prebuilt.common, + "run_console_subprocess_quiet", + return_value=driver("579.10")): + self.assertEqual( + make_server.prebuilt._default_cuda_variant(), "12.4") + with patch.object(make_server.prebuilt.common, + "run_console_subprocess_quiet", + return_value=None): + self.assertEqual( + make_server.prebuilt._default_cuda_variant(), "12.4") + + +class PrebuiltInstallTests(unittest.TestCase): + """install_prebuilt: download, verify, extract, mark.""" + + def _checkout(self) -> Path: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + return _make_checkout(Path(tmp.name)) + + def _marker(self, checkout: Path, backend: str) -> Path: + return make_server.prebuilt.marker_path(checkout, backend, + platform="darwin") + + def _install_darwin(self, checkout: Path, *, digest: str, + asset_name="audio-v9.9.9-bin-macos-x64-metal.tar.gz", + archive=None): + if archive is None: + archive = _make_tar_gz( + Path(self.tmp.name), asset_name, + {"./audiocpp_server": "#!/bin/sh\n", + "./tools/model_manager_v2.py": "# tool\n"}) + asset = _asset(asset_name, size=archive.stat().st_size, + digest=f"sha256:{digest}") + release = _release([asset]) + + def fake_download(url, dest, *, emit=None, cancel=None): + self.assertEqual(url, asset["browser_download_url"]) + shutil.copyfile(archive, dest) + return 0 + + with patch("sys.platform", "darwin"), \ + patch.object(make_server.prebuilt, "fetch_latest_release", + return_value=release), \ + patch.object(make_server.prebuilt, "_download", + side_effect=fake_download) as mk_dl, \ + patch.object(make_server.prebuilt.common, + "run_console_subprocess", return_value=0): + rc = make_server.prebuilt.install_prebuilt(checkout, "cpu") + return rc, mk_dl + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = tmp + + def test_darwin_install_extracts_and_marks(self): + checkout = self._checkout() + archive = _make_tar_gz( + Path(self.tmp.name), "audio-v9.9.9-bin-macos-x64-metal.tar.gz", + {"./audiocpp_server": "#!/bin/sh\n", + "./tools/model_manager_v2.py": "# tool\n"}, mode=0o644) + rc, _ = self._install_darwin( + checkout, digest=_sha256_file(archive), archive=archive) + self.assertEqual(rc, 0) + bin_dir = checkout / "build" / "macos-metal-release" / "bin" + server = bin_dir / "audiocpp_server" + self.assertTrue(server.exists()) + # GitHub's artifact round-trip loses the tar permission bits; the + # install must restore the exec bit the hub relies on to launch. + self.assertTrue(server.stat().st_mode & 0o111) + self.assertTrue((bin_dir / "tools" / "model_manager_v2.py").exists()) + marker = json.loads( + (checkout / "build" / "macos-metal-release" / "prebuilt.json") + .read_text(encoding="utf-8")) + self.assertEqual(marker["tag"], "v9.9.9") + self.assertEqual(marker["asset"], + "audio-v9.9.9-bin-macos-x64-metal.tar.gz") + + def test_install_replaces_a_previous_build_dir(self): + checkout = self._checkout() + stale = checkout / "build" / "macos-metal-release" / "bin" + stale.mkdir(parents=True) + (stale / "junk.txt").write_text("stale", encoding="utf-8") + archive = _make_tar_gz( + Path(self.tmp.name), "audio-v9.9.9-bin-macos-x64-metal.tar.gz", + {"./audiocpp_server": "#!/bin/sh\n"}) + rc, _ = self._install_darwin( + checkout, digest=_sha256_file(archive), archive=archive) + self.assertEqual(rc, 0) + self.assertFalse((stale / "junk.txt").exists()) + self.assertTrue((stale / "audiocpp_server").exists()) + + def test_reinstall_of_the_same_release_is_a_noop(self): + checkout = self._checkout() + archive = _make_tar_gz( + Path(self.tmp.name), "audio-v9.9.9-bin-macos-x64-metal.tar.gz", + {"./audiocpp_server": "#!/bin/sh\n"}) + rc, _ = self._install_darwin( + checkout, digest=_sha256_file(archive), archive=archive) + self.assertEqual(rc, 0) + rc, mk_dl = self._install_darwin( + checkout, digest=_sha256_file(archive), archive=archive) + self.assertEqual(rc, 0) + mk_dl.assert_not_called() + + def test_checksum_mismatch_aborts_without_installing(self): + checkout = self._checkout() + rc, _ = self._install_darwin(checkout, + digest="sha256:" + "0" * 64) + self.assertEqual(rc, 1) + self.assertFalse( + (checkout / "build" / "macos-metal-release" / "bin" + / "audiocpp_server").exists()) + self.assertIsNone( + make_server.prebuilt.installed_release(checkout, "cpu")) + + def test_windows_cuda_installs_both_zips_into_the_preset_dir(self): + checkout = self._checkout() + main = _make_zip( + Path(self.tmp.name), "audio-v9.9.9-bin-windows-x64-cuda13.3.zip", + {"audiocpp_server.exe": "MZ", + "ggml-cuda.dll": "MZ"}) + cudart = _make_zip( + Path(self.tmp.name), "audio-v9.9.9-cudart-windows-x64-cuda13.3.zip", + {"cudart64_13.dll": "MZ"}) + release = _release([ + _asset(main.name, size=main.stat().st_size, + digest=f"sha256:{_sha256_file(main)}"), + _asset(cudart.name, size=cudart.stat().st_size, + digest=f"sha256:{_sha256_file(cudart)}"), + ]) + archives = {f"https://example.test/{p.name}": p + for p in (main, cudart)} + + def fake_download(url, dest, *, emit=None, cancel=None): + shutil.copyfile(archives[url], dest) + return 0 + + with patch("sys.platform", "win32"), \ + patch.object(make_server.prebuilt, "fetch_latest_release", + return_value=release), \ + patch.object(make_server.prebuilt, "_download", + side_effect=fake_download), \ + patch.object(make_server.prebuilt.common, + "run_console_subprocess", return_value=0): + rc = make_server.prebuilt.install_prebuilt( + checkout, "cuda", cuda_variant="13.3") + self.assertEqual(rc, 0) + bin_dir = checkout / "build" / "windows-cuda-release" / "bin" + self.assertTrue((bin_dir / "audiocpp_server.exe").exists()) + self.assertTrue((bin_dir / "cudart64_13.dll").exists()) + self.assertEqual(make_server.prebuilt.installed_release( + checkout, "cuda", platform="win32")["tag"], "v9.9.9") + + def test_unsupported_backend_fails_loudly(self): + checkout = self._checkout() + with patch("sys.platform", "linux"): + rc = make_server.prebuilt.install_prebuilt(checkout, "cuda") + self.assertEqual(rc, 1) + + def test_marker_roundtrip_and_garbage(self): + checkout = self._checkout() + self.assertIsNone(make_server.prebuilt.installed_release( + checkout, "cpu", platform="darwin")) + marker = self._marker(checkout, "cpu") + marker.parent.mkdir(parents=True) + marker.write_text(json.dumps({"tag": "v1.2.3"}), + encoding="utf-8") + self.assertEqual(make_server.prebuilt.installed_release( + checkout, "cpu", platform="darwin")["tag"], "v1.2.3") + marker.write_text("not json", encoding="utf-8") + self.assertIsNone(make_server.prebuilt.installed_release( + checkout, "cpu", platform="darwin")) + + def test_install_dir_mirrors_the_source_build_layout(self): + checkout = Path("/tmp/acpp") + self.assertEqual( + make_server.prebuilt.install_dir(checkout, "cpu", + platform="darwin"), + checkout / "build" / "macos-metal-release" / "bin") + self.assertEqual( + make_server.prebuilt.install_dir(checkout, "cuda", + platform="win32"), + checkout / "build" / "windows-cuda-release" / "bin") + self.assertIsNone( + make_server.prebuilt.install_dir(checkout, "hip", + platform="win32")) + self.assertIsNone( + make_server.prebuilt.install_dir(checkout, "cpu", + platform="linux")) + + +class PrebuiltUpdateRoutingTests(unittest.TestCase): + """update() routes prebuilt installs to a release re-download.""" + + def _checkout(self) -> Path: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + return _make_checkout(Path(tmp.name)) + + def _mark_prebuilt(self, checkout: Path, tag: str) -> None: + marker = make_server.prebuilt.marker_path(checkout, "cpu", + platform="darwin") + marker.parent.mkdir(parents=True) + (marker.parent / "bin").mkdir(exist_ok=True) + marker.write_text(json.dumps({"tag": tag, "asset": "x"}), + encoding="utf-8") + + def _run(self, checkout: Path, *, marker: Optional[dict], + release: Optional[dict]): + with patch("sys.platform", "darwin"), \ + patch.object(make_server.build, "find_local_checkout", + return_value=checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=None), \ + patch.object(make_server.build, "load_server_config", + return_value={"backend": "cpu"}), \ + patch.object(make_server.prebuilt, "installed_release", + return_value=marker), \ + patch.object(make_server.prebuilt, "fetch_latest_release", + return_value=release), \ + patch.object(make_server.prebuilt, "install_prebuilt", + return_value=0) as mk_install, \ + patch.object(common, "git_update", + return_value=0) as mk_git: + rc = make_server.build.update() + return rc, mk_install, mk_git + + def test_newer_release_triggers_a_redownload(self): + checkout = self._checkout() + self._mark_prebuilt(checkout, "v0.6.0") + rc, mk_install, mk_git = self._run( + checkout, marker={"tag": "v0.6.0", "asset": "x"}, + release=_release([], tag="v0.7.0")) + self.assertEqual(rc, 0) + mk_git.assert_not_called() + mk_install.assert_called_once_with(checkout, "cpu", emit=None, + cancel=None) + + def test_current_release_is_a_noop(self): + checkout = self._checkout() + rc, mk_install, mk_git = self._run( + checkout, marker={"tag": "v0.7.0", "asset": "x"}, + release=_release([], tag="v0.7.0")) + self.assertEqual(rc, 0) + mk_install.assert_not_called() + mk_git.assert_not_called() + + def test_unreachable_github_keeps_the_install(self): + checkout = self._checkout() + rc, mk_install, mk_git = self._run( + checkout, marker={"tag": "v0.7.0", "asset": "x"}, + release=None) + self.assertEqual(rc, 0) + mk_install.assert_not_called() + mk_git.assert_not_called() + + def test_source_builds_still_route_through_git(self): + checkout = self._checkout() + (checkout / "server.json").write_text( + json.dumps({"models": [], "backend": "cpu"}), + encoding="utf-8") + with patch("sys.platform", "darwin"), \ + patch.object(make_server.build, "find_local_checkout", + return_value=checkout), \ + patch.object(make_server.build.servers, "pid_for", + return_value=None), \ + patch.object(make_server.prebuilt, "installed_release", + return_value=None), \ + patch.object(common, "git_update", + return_value=0) as mk_git, \ + patch.object(make_server.build, "build_audiocpp", + return_value=0) as mk_build: + rc = make_server.build.update() + self.assertEqual(rc, 0) + mk_git.assert_called_once() + mk_build.assert_called_once_with(checkout, "cpu", emit=None, + cancel=None) + + +class PrebuiltFlagTests(unittest.TestCase): + """--prebuilt resolves into a concrete install mode.""" + + def _args(self, value): + return make_server.wizard.build_parser().parse_args( + ["--prebuilt", value] if value else []) + + def test_auto_downloads_on_macos_and_windows(self): + with patch("sys.platform", "darwin"): + self.assertEqual( + make_server.wizard._flag_build_mode(self._args("auto"), + "cpu"), "prebuilt") + with patch("sys.platform", "win32"): + self.assertEqual( + make_server.wizard._flag_build_mode(self._args("auto"), + "cuda"), "prebuilt") + self.assertEqual( + make_server.wizard._flag_build_mode(self._args("auto"), + "hip"), "source") + + def test_auto_builds_from_source_on_linux(self): + self.assertEqual( + make_server.wizard._flag_build_mode(self._args("auto"), + "cuda"), "source") + + def test_yes_and_no_force_their_modes(self): + self.assertEqual( + make_server.wizard._flag_build_mode(self._args("yes"), "hip"), + "prebuilt") + self.assertEqual( + make_server.wizard._flag_build_mode(self._args("no"), "cpu"), + "source") + + def test_darwin_wizard_honors_the_form_mode(self): + # On macOS the combined form asks "Get audiocpp_server" as a + # three-way choice; the answer must drive the install mode. + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + checkout = _make_checkout(Path(tmp.name)) + catalog = make_server.catalog.load_model_catalog(checkout) + qwen_index = next(i for i, entry in enumerate(catalog) + if entry["family"] == "qwen3_tts") + + def fake_tree(*args, **kwargs): + return [(qwen_index, "Qwen3-TTS-12Hz-1.7B-Base-GGUF")] + + def fake_form(stdscr, title, fields, **kwargs): + by_key = {f["key"]: f for f in fields} + return {f["key"]: f["value"] for f in fields} | { + "backend": by_key["backend"]["value"], + "build_mode": mode, + } + + for mode, expected_build in (("skip", False), ("source", True), + ("prebuilt", True)): + with patch("sys.platform", "darwin"), \ + patch.object(make_server.build, "find_local_checkout", + return_value=checkout), \ + patch.object(tui, "checkbox_tree", + side_effect=fake_tree), \ + patch.object(tui, "form", side_effect=fake_form): + settings = make_server.wizard._wizard( + None, make_server.wizard.build_parser().parse_args([]), + make_server.wizard.build_parser()) + self.assertIsNotNone(settings) + self.assertEqual(settings["build_mode"], mode) + self.assertEqual(settings["build"], expected_build) -- cgit v1.2.3