├── .gitignore ├── LICENSE.md ├── README.md ├── pact.cs └── pact.py /.gitignore: -------------------------------------------------------------------------------- 1 | package/* 2 | .DS_Store 3 | run.sh -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2025 Artem Sokolov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pact 2 | 3 | Package testing utility that automates installing multiple Unity versions, platform modules, and building your package for each combination of them. 4 | 5 | ## Usage 6 | 7 | ```bash 8 | # Test a package from Git repository 9 | ./pact.py --package-url https://github.com/user/package.git#develop \ 10 | -uv 2022.3.67f2 \ 11 | -p android -p ios \ 12 | -d "com.unity.ugui:2.0.0" 13 | 14 | # Test a local package directory 15 | ./pact.py --package /path/to/package.unitypackage \ 16 | -uv 2022.3.67f2 \ 17 | -p android 18 | 19 | # Help 20 | ./pact.py --help 21 | ``` 22 | 23 | ## Requirements 24 | 25 | - Unity Hub installed 26 | - Python 3.10+ 27 | - Git (for package URLs) 28 | 29 | ## Supported agent platforms 30 | 31 | - macOS 32 | 33 | ## Supported target platforms 34 | 35 | - Android 36 | - iOS 37 | - WebGL 38 | - MacOS 39 | 40 | ## Notes 41 | 42 | It’s inefficient as hell (_I mean, why would I bother optimizing it when even Unity Hub allows itself 7-second startup when running headless CLI on an Apple M4 Pro, like wtf?_) and primarily tailored to my personal needs. Discretion is advised. -------------------------------------------------------------------------------- /pact.cs: -------------------------------------------------------------------------------- 1 | #if UNITY_EDITOR 2 | 3 | using System; 4 | using System.IO; 5 | using System.Linq; 6 | using UnityEditor; 7 | using UnityEditor.Build; 8 | using UnityEditor.Build.Reporting; 9 | using UnityEditor.SceneManagement; 10 | 11 | // ReSharper disable once CheckNamespace 12 | public static class Pact 13 | { 14 | [Serializable] 15 | public class BuildConfiguration 16 | { 17 | public string[] DefinedSymbols; 18 | public bool IL2CPP; 19 | } 20 | 21 | private const string PACT_TEMP_DIR = "Assets"; 22 | 23 | public static void Build() 24 | { 25 | var config = GetCurrentConfiguration(); 26 | var scene = BootstrapScene(); 27 | var options = MakeBuildOptions(scene, config); 28 | var target = NamedBuildTarget.FromBuildTargetGroup(options.targetGroup); 29 | 30 | ApplyGlobalConfiguration(target, config); 31 | 32 | bool success = false; 33 | 34 | try 35 | { 36 | var report = BuildPipeline.BuildPlayer(options); 37 | success = report.summary.result == BuildResult.Succeeded; 38 | } 39 | finally 40 | { 41 | if (UnityEngine.Application.isBatchMode) 42 | { 43 | EditorApplication.Exit(success ? 0 : 1); 44 | } 45 | } 46 | } 47 | 48 | private static void ApplyGlobalConfiguration(NamedBuildTarget target, BuildConfiguration config) 49 | { 50 | PlayerSettings.SetApplicationIdentifier(target, "com.pact.app"); 51 | PlayerSettings.SetScriptingBackend(target, config.IL2CPP ? ScriptingImplementation.IL2CPP : ScriptingImplementation.Mono2x); 52 | 53 | if (target == NamedBuildTarget.Android) 54 | { 55 | PlayerSettings.Android.targetArchitectures = config.IL2CPP ? AndroidArchitecture.ARM64 : AndroidArchitecture.ARMv7; 56 | } 57 | 58 | if (target == NamedBuildTarget.WebGL) 59 | { 60 | PlayerSettings.WebGL.compressionFormat = WebGLCompressionFormat.Disabled; 61 | } 62 | } 63 | 64 | private static BuildPlayerOptions MakeBuildOptions(string scene, BuildConfiguration config) 65 | { 66 | var options = new BuildPlayerOptions(); 67 | options.target = EditorUserBuildSettings.activeBuildTarget; 68 | options.targetGroup = BuildPipeline.GetBuildTargetGroup(options.target); 69 | options.scenes = new[] { scene }; 70 | options.extraScriptingDefines = config.DefinedSymbols.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray(); 71 | 72 | switch (options.target) 73 | { 74 | case BuildTarget.Android: 75 | options.locationPathName = "Builds/android.apk"; 76 | break; 77 | case BuildTarget.iOS: 78 | options.locationPathName = "Builds/ios"; 79 | break; 80 | case BuildTarget.StandaloneOSX: 81 | options.locationPathName = "Builds/osx"; 82 | break; 83 | case BuildTarget.WebGL: 84 | options.locationPathName = "Builds/webgl"; 85 | break; 86 | default: 87 | throw new NotImplementedException( 88 | $"Making build options for {options.target} platform is not implemented"); 89 | } 90 | 91 | Console.WriteLine($"Pact Build Configuration:"); 92 | Console.WriteLine($"\tTarget: {options.target}"); 93 | Console.WriteLine($"\tTargetGroup: {options.targetGroup}"); 94 | Console.WriteLine($"\tScenes: {string.Join("; ", options.scenes)}"); 95 | Console.WriteLine($"\tExtra Scripting Defines: {string.Join("; ", options.extraScriptingDefines)}"); 96 | Console.WriteLine($"\tLocation Path Name: {options.locationPathName}"); 97 | 98 | 99 | return options; 100 | } 101 | 102 | private static string BootstrapScene() 103 | { 104 | var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene); 105 | var path = Path.Combine(PACT_TEMP_DIR, "EmptyScene.unity"); 106 | 107 | EditorSceneManager.SaveScene(scene, path); 108 | AssetDatabase.ImportAsset(path); 109 | 110 | return path; 111 | } 112 | 113 | private static BuildConfiguration GetDefaultConfiguration() 114 | { 115 | return new BuildConfiguration 116 | { 117 | IL2CPP = false, 118 | DefinedSymbols = Array.Empty(), 119 | }; 120 | } 121 | 122 | private static BuildConfiguration GetCurrentConfiguration() 123 | { 124 | var config = GetDefaultConfiguration(); 125 | var args = System.Environment.GetCommandLineArgs(); 126 | 127 | var jsonParamIndex = Array.LastIndexOf(args, "-buildParam") + 1; // next after buildparam 128 | if (jsonParamIndex > 0 && jsonParamIndex < args.Length) 129 | { 130 | EditorJsonUtility.FromJsonOverwrite(args[jsonParamIndex], config); 131 | } 132 | 133 | return config; 134 | } 135 | } 136 | 137 | #endif -------------------------------------------------------------------------------- /pact.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from abc import abstractmethod 5 | import argparse 6 | import io 7 | import sys 8 | import subprocess 9 | import os 10 | import logging 11 | import shutil 12 | import platform 13 | import re 14 | import json 15 | import pathlib 16 | from pathlib import Path 17 | from typing import Any, Mapping, NamedTuple 18 | 19 | logging.basicConfig() 20 | log = logging.getLogger("pact") 21 | 22 | def f_list(lst: list[Any]) -> str: 23 | return ', '.join(map(str, lst)) 24 | 25 | def f_set(lst: set[Any]) -> str: 26 | return ', '.join(map(str, lst)) 27 | 28 | def f_cmd(lst: list[str]) -> str: 29 | escaped = [f"\"{v}\"" if " " in v else v for v in lst] # poor man's escaping, but it should do 30 | return " ".join(escaped) 31 | 32 | # todo: add support for running on Windows 33 | # todo: add support for running on Linux 34 | class AgentPlatform: 35 | @abstractmethod 36 | def get_install_editor_extra_params(self) -> list[str]: 37 | pass 38 | 39 | @abstractmethod 40 | def get_hub_default_exec_path(self) -> Path: 41 | pass 42 | 43 | def get_hub_headless_prefix(self) -> list[str]: 44 | return ["--", "--headless"] # should be just ["--headless"] for linux, according to documentation 45 | 46 | def get_unity_executable_name(self) -> str: 47 | raise Exception("Not implemented for current agent platform") 48 | 49 | def __repr__(self) -> str: 50 | return self.__str__() 51 | 52 | class AgentPlatformGeneric(AgentPlatform): 53 | def get_install_editor_extra_params(self) -> list[str]: 54 | return [] 55 | 56 | def get_default_hub_executable_path(self) -> str: 57 | return "" 58 | 59 | def __str__(self) -> str: 60 | return "generic" 61 | 62 | class AgentPlatformMacOS(AgentPlatform): 63 | def get_install_editor_extra_params(self) -> list[str]: 64 | return ["--architecture", "arm64"] if platform.machine() == "arm64" else ["--architecture", "x86_64"] 65 | 66 | def get_hub_default_exec_path(self) -> Path: 67 | return Path("/Applications/Unity Hub.app/Contents/MacOS/Unity Hub") 68 | 69 | def get_unity_executable_name(self) -> str: 70 | return "Unity.app/Contents/MacOS/Unity" 71 | 72 | def __str__(self) -> str: 73 | return "macOS" 74 | 75 | # todo: add support for windows as target platform 76 | # todo: add support for linux as target platform 77 | class TargetPlatform: 78 | @abstractmethod 79 | def get_unity_modules(self) -> list[str]: 80 | pass 81 | 82 | @abstractmethod 83 | def get_name(self) -> str: 84 | pass 85 | 86 | def __str__(self) -> str: 87 | return self.get_name() 88 | 89 | def __repr__(self) -> str: 90 | return self.__str__() 91 | 92 | class TargetPlatformAndroid(TargetPlatform): 93 | def get_unity_modules(self) -> list[str]: 94 | return ["android", "android-sdk-ndk-tools", "android-open-jdk"] 95 | 96 | def get_name(self) -> str: 97 | return "android" 98 | 99 | class TargetPlatformIOS(TargetPlatform): 100 | def get_unity_modules(self) -> list[str]: 101 | return ["ios"] 102 | 103 | def get_name(self) -> str: 104 | return "ios" 105 | 106 | class TargetPlatformWebGL(TargetPlatform): 107 | def get_unity_modules(self) -> list[str]: 108 | return ["webgl"] 109 | 110 | def get_name(self) -> str: 111 | return "webgl" 112 | 113 | class TargetPlatformMacOS(TargetPlatform): 114 | def get_unity_modules(self) -> list[str]: 115 | return ["mac-il2cpp"] 116 | 117 | def get_name(self) -> str: 118 | return "osxuniversal" 119 | 120 | class UnityEditorVersion: 121 | VERSION_REGEX = r"([0-9]{4}.[0-9]+.[0-9]+(?:a|b|f)[0-9])" 122 | 123 | def __init__(self, version: str): 124 | s = version.split('@') # its either @ or just 125 | 126 | if len(s) > 1: 127 | self.version = s[0] 128 | self.changeset = s[1] 129 | else: 130 | self.version = version 131 | self.changeset = None 132 | 133 | if not re.match(UnityEditorVersion.VERSION_REGEX, self.version): 134 | raise Exception(f"Invalid Unity Editor version {self.version}") 135 | 136 | def __eq__(self, value: object, /) -> bool: 137 | return isinstance(value, UnityEditorVersion) and value.version == self.version 138 | 139 | def __ne__(self, value: object, /) -> bool: 140 | return not self.__eq__(value) 141 | 142 | def __hash__(self) -> int: 143 | return hash(self.version) 144 | 145 | def __str__(self) -> str: 146 | return f"{self.version} ({self.changeset})" if self.changeset else f"{self.version}" 147 | 148 | def __repr__(self) -> str: 149 | return self.__str__() 150 | 151 | class PackageManagerDependency: 152 | def __init__(self, value: str): 153 | s = value.split(":") 154 | 155 | if len(s) > 1: 156 | self.name = s[0] 157 | self.version = s[1] 158 | else: 159 | self.name = value 160 | self.version = "1.0.0" 161 | 162 | class ShellProgram: 163 | def __init__(self, exec_path: str): 164 | self._util_name = self.__class__.__name__ 165 | self._exec_path = exec_path 166 | 167 | def _get_prefix_args(self) -> list[str]: 168 | return [] 169 | 170 | def _call(self, *args: str) -> subprocess.CompletedProcess: 171 | op = [self._exec_path, *self._get_prefix_args(), *args] 172 | log.debug(f"({self._util_name}) executing {f_cmd(op)}") 173 | result = subprocess.run(op, capture_output=True, text=True) 174 | 175 | if result.returncode != 0: 176 | raise Exception(f"{self._exec_path} failed with code {result.returncode}") 177 | 178 | return result 179 | 180 | def _run(self, *args: str): 181 | op = [self._exec_path, *self._get_prefix_args(), *args] 182 | log.debug(f"({self._util_name}) executing {f_cmd(op)}") 183 | result = subprocess.run(op) 184 | 185 | if result.returncode != 0: 186 | raise Exception(f"{self._exec_path} failed with code {result.returncode}") 187 | 188 | return result 189 | 190 | class CSharpProject: 191 | def __init__(self, code_files: Mapping[Path, str], entry_point: str): 192 | self.code_files = code_files 193 | self.entry_point = entry_point 194 | 195 | def get_entry_point(self): 196 | return self.entry_point 197 | 198 | def get_code_files(self) -> Mapping[Path, str]: 199 | return self.code_files 200 | 201 | class UnityProject: 202 | def __init__(self, path: Path, cs_project: CSharpProject): 203 | self.path = path 204 | self.cs_project = cs_project 205 | 206 | def get_root_dir(self) -> Path: 207 | return self.path 208 | 209 | def get_assets_dir(self) -> Path: 210 | return self.path / "Assets" 211 | 212 | def get_packages_dir(self) -> Path: 213 | return self.path / "Packages" 214 | 215 | def get_entry_point(self) -> str: 216 | return self.cs_project.get_entry_point() 217 | 218 | def __str__(self) -> str: 219 | return str(self.get_root_dir()) 220 | 221 | class BuildParameters: 222 | def __init__(self, defined_symbols: list[str], enable_il2cpp: bool): 223 | self.defined_symbols = defined_symbols 224 | self.enable_il2cpp = enable_il2cpp 225 | 226 | def get_json(self): 227 | return json.dumps({ 228 | "DefinedSymbols": self.defined_symbols, 229 | "IL2CPP": self.enable_il2cpp 230 | }) 231 | 232 | def is_empty_defines(self): 233 | return len(self.defined_symbols) == 0 or not all(self.defined_symbols) 234 | 235 | def get_tech_name(self): 236 | result = "" 237 | 238 | if not self.is_empty_defines(): 239 | result += str.join('_', self.defined_symbols) 240 | 241 | if self.enable_il2cpp: 242 | result += "_il2cpp" 243 | 244 | return result if result else "default" 245 | 246 | class Unity(ShellProgram): 247 | def __init__(self, exec_path: Path, version: UnityEditorVersion): 248 | self.version = version 249 | 250 | super().__init__(str(exec_path)) 251 | 252 | def _get_prefix_args(self) -> list[str]: 253 | return ["-batchmode"] 254 | 255 | def create_project(self, project_path: Path, cs_project: CSharpProject, deps: list[PackageManagerDependency]): 256 | assets_path = project_path / "Assets" 257 | packages_path = project_path / "Packages" 258 | files = cs_project.get_code_files() 259 | dependencies = { "com.unity.modules.ui": "1.0.0" } # must include at least one package, otherwise Unity will install a whole bunch of them on its own 260 | 261 | for dep in deps: 262 | dependencies[dep.name] = dep.version 263 | 264 | if pathlib.Path(assets_path).exists(): 265 | # in case we are overwriting existing project 266 | shutil.rmtree(assets_path) 267 | 268 | self._run("-quit", "-createProject", str(project_path), "-noUpm", "-logFile", str(project_path.absolute().parent / f"{project_path.name}_create_log.log")); 269 | 270 | for file_name in files: 271 | (assets_path / file_name).write_text(files[file_name]) 272 | 273 | packages_path.mkdir(exist_ok=True) 274 | (packages_path / "manifest.json").write_text(json.dumps({"dependencies":dependencies})) 275 | 276 | return UnityProject(project_path, cs_project) 277 | 278 | def install_unity_package(self, project: UnityProject, path: Path): 279 | log.info(f"installing {path} to {project}") 280 | self._run("-quit", "-projectPath", str(project.get_root_dir()), "-importPackage", str(path), "-logFile", str(project.get_root_dir().absolute().parent / f"{project.get_root_dir().name}_install_{path.name}.log")); 281 | log.info("package installed successfully") 282 | 283 | def build_project(self, project: UnityProject, platform: TargetPlatform, build_param: BuildParameters): 284 | log_path = str(project.get_root_dir() / f"build_{platform}_{build_param.get_tech_name()}.log") 285 | param_json = build_param.get_json() 286 | 287 | symbols = "-" 288 | 289 | if not build_param.is_empty_defines(): 290 | symbols = str.join('|', build_param.defined_symbols) 291 | 292 | log.info(f"building {project} for {platform} (IL2CPP: {build_param.enable_il2cpp}; SYMBOLS: {symbols}) (full log at {log_path})") 293 | self._run("-projectPath", str(project.get_root_dir()), "-buildTarget", platform.get_name(), "-executeMethod", project.get_entry_point(), "-logFile", log_path, "-buildParam", param_json) 294 | log.info("built successfully") 295 | 296 | class UnityHub(ShellProgram): 297 | def __init__(self, agent: AgentPlatform, path: Path | None=None): 298 | self.agent = agent 299 | self.installed_editors = None 300 | self.base_install_path = None 301 | 302 | if path: 303 | if not path.exists(): 304 | raise Exception(f"Couldn't find UnityHub executable at {path}") 305 | else: 306 | default_path = agent.get_hub_default_exec_path() 307 | log.debug(f"hub default path at {default_path}") 308 | 309 | if not default_path or not default_path.exists(): 310 | raise Exception("Failed to find default UnityHub location, you have to provide it explicitly (use --help)") 311 | 312 | path = default_path 313 | 314 | super().__init__(str(path)) 315 | 316 | def _get_prefix_args(self) -> list[str]: 317 | return self.agent.get_hub_headless_prefix() 318 | 319 | def get_installed_editor_versions(self) -> list[UnityEditorVersion]: 320 | if self.installed_editors: 321 | return self.installed_editors 322 | 323 | self.installed_editors = [UnityEditorVersion(s) for s in set(re.findall(UnityEditorVersion.VERSION_REGEX, self._call("editors", "-i").stdout))] 324 | log.debug(f"installed editors: {f_list(self.installed_editors)}") 325 | return self.installed_editors 326 | 327 | def get_base_install_path(self) -> Path: 328 | if self.base_install_path: 329 | return self.base_install_path 330 | 331 | # install-path --get spits out a ton of garbage 332 | # there probably is a better way to parse this shit, but I don't care until this method breaks 333 | output = self._call("install-path", "--get").stdout.split('\n'); 334 | base_install_path = str.strip(output[-1]) 335 | if not base_install_path and len(output) > 1: 336 | base_install_path = str.strip(output[-2]) 337 | 338 | self.base_install_path = Path(base_install_path) 339 | log.debug(f"base install path: {self.base_install_path}") 340 | return self.base_install_path 341 | 342 | def get_install_path(self, version: UnityEditorVersion) -> Path: 343 | version_install_path = self.get_base_install_path() / str(version.version) 344 | log.debug(f"install path for {version}: {version_install_path}") 345 | return version_install_path 346 | 347 | def get_unity_exec_path(self, version: UnityEditorVersion) -> Path: 348 | exec_name = self.agent.get_unity_executable_name() 349 | editor_dir = self.get_install_path(version) 350 | return editor_dir / exec_name 351 | 352 | def get_installed_modules(self, version: UnityEditorVersion) -> list[str]: 353 | install_path = self.get_install_path(version) 354 | modules_json_path = install_path / "modules.json" 355 | installed_modules = [] 356 | 357 | if not modules_json_path.exists(): 358 | log.warning(f"Failed to find 'modules.json' for {version}, assuming no modules installed") 359 | else: 360 | modules_json_content = modules_json_path.read_text() 361 | data = json.loads(modules_json_content) 362 | UnityHub._extract_installed_modules(data, installed_modules) 363 | 364 | log.debug(f"installed modules {version}: {installed_modules}") 365 | return installed_modules 366 | 367 | def get_missing_modules(self, version: UnityEditorVersion, platforms: list[TargetPlatform]) -> list[str]: 368 | if not self.is_editor_installed(version): 369 | raise Exception(f"Unity Editor {version} is not installed") 370 | 371 | required = [module for platform in platforms for module in platform.get_unity_modules()] 372 | installed = self.get_installed_modules(version) 373 | return list(set(required).difference(installed)) 374 | 375 | def is_editor_installed(self, version: UnityEditorVersion) -> bool: 376 | return version in self.get_installed_editor_versions() 377 | 378 | def install_editor(self, version: UnityEditorVersion, platforms: list[TargetPlatform]): 379 | args = ["--version", version.version] 380 | 381 | if version.changeset: 382 | args = [*args, "--changeset", version.changeset] 383 | 384 | args = [*args, *self.agent.get_install_editor_extra_params()] 385 | 386 | log.info(f"installing editor {version}") 387 | self._run("install", *args) 388 | log.info("editor installed") 389 | 390 | if self.installed_editors: 391 | self.installed_editors.append(version) 392 | 393 | self.install_missing_modules(version, platforms) 394 | 395 | def install_missing_modules(self, version: UnityEditorVersion, platforms: list[TargetPlatform]): 396 | log.debug(f"checking missing modules for {version} and platforms {f_list(platforms)}") 397 | missing = self.get_missing_modules(version, platforms) 398 | self._auto_fix_module_names(version, missing) 399 | 400 | if len(missing) <= 0: 401 | log.debug(f"no missing modules for {version} and platforms {f_list(platforms)}") 402 | return 403 | 404 | log.info(f"installing missing modules for {version}: {f_list(missing)}") 405 | flags = [flag for module in missing for flag in ["--module", module]] 406 | args = ["--version", version.version, "--childModules", *flags] 407 | self._run("install-modules", *args) 408 | log.info("modules installed") 409 | 410 | def get_unity(self, version: UnityEditorVersion) -> Unity: 411 | if not self.is_editor_installed(version): 412 | raise Exception(f"Unity Editor {version} is not installed") 413 | 414 | return Unity(self.get_unity_exec_path(version), version) 415 | 416 | def _auto_fix_module_names(self, version: UnityEditorVersion, names: list[str]): 417 | install_path = self.get_install_path(version) 418 | modules_json_path = install_path / "modules.json" 419 | 420 | if not modules_json_path.exists(): 421 | return names 422 | 423 | modules_json_content = modules_json_path.read_text() 424 | data = json.loads(modules_json_content) 425 | all_modules = [] 426 | UnityHub._get_all_modules_names(data, all_modules) 427 | jdk_with_version = next((x for x in all_modules if str.startswith(x, "android-open-jdk-")), None) 428 | 429 | if not jdk_with_version: 430 | return 431 | 432 | for i in range(len(names)): 433 | if names[i] == "android-open-jdk": 434 | names[i] = jdk_with_version 435 | 436 | @staticmethod 437 | def _extract_installed_modules(json, result: list[str]): 438 | for module in json: 439 | installed = module["selected"] if dict(module).__contains__("selected") else False 440 | mod_id = module["id"] 441 | 442 | if installed: 443 | # here https://docs.unity3d.com/hub/manual/HubCLI.html it says that there is an android-open-jdk module 444 | # but when you query modules, there is only one jdk and it has version postfix 445 | 446 | if str.startswith(mod_id, "android-open-jdk"): 447 | result.append("android-open-jdk") 448 | 449 | result.append(mod_id) 450 | 451 | if dict(module).__contains__("subModules"): 452 | UnityHub._extract_installed_modules(module["subModules"], result) 453 | 454 | @staticmethod 455 | def _get_all_modules_names(json, result: list[str]): 456 | for module in json: 457 | mod_id = module["id"] 458 | result.append(mod_id) 459 | 460 | if dict(module).__contains__("subModules"): 461 | UnityHub._extract_installed_modules(module["subModules"], result) 462 | 463 | class Git(ShellProgram): 464 | def __init__(self): 465 | super().__init__("git") 466 | 467 | def clone(self, url: str, target_path: Path, branch: str | None): 468 | log.info(f"cloning {url} into {target_path}") 469 | param = ["clone", url, str(target_path), "--depth", "1"] 470 | if branch: 471 | param.append('--branch') 472 | param.append(branch) 473 | 474 | self._run(*param) 475 | 476 | class TestingPackage: 477 | @abstractmethod 478 | def install(self, unity: Unity, project: UnityProject): 479 | pass 480 | 481 | class TestingPackageEmpty(TestingPackage): 482 | def install(self, unity: Unity, project: UnityProject): 483 | pass 484 | 485 | class TestingPackageUnityPackage(TestingPackage): 486 | def __init__(self, path_to_package: Path): 487 | self.path_to_package = path_to_package 488 | 489 | if not path_to_package.exists(): 490 | raise Exception(f"Package at {path_to_package} not found") 491 | 492 | def install(self, unity: Unity, project: UnityProject): 493 | unity.install_unity_package(project, self.path_to_package) 494 | 495 | class TestingPackageDir(TestingPackage): 496 | def __init__(self, path: Path): 497 | self.path = path 498 | 499 | def install(self, unity: Unity, project: UnityProject): 500 | log.info(f"installing project at '{self.path}' to {project}") 501 | shutil.copytree(self.path, project.get_assets_dir() / "package") 502 | 503 | class TestingPackageGit(TestingPackageDir): 504 | def __init__(self, url: str, cache_path: Path): 505 | self.url = url 506 | self.cloned = False 507 | 508 | s = self.url.split('#') 509 | if len(s) > 1: 510 | self.url = s[0] 511 | self.branch = s[1] 512 | else: 513 | self.branch = None 514 | 515 | super().__init__(cache_path) 516 | 517 | def install(self, unity: Unity, project: UnityProject): 518 | if not self.cloned: 519 | if self.path.is_dir() and self.path.exists(): 520 | shutil.rmtree(self.path) 521 | 522 | git = Git() 523 | git.clone(self.url, self.path, self.branch) 524 | 525 | self.cloned = True 526 | 527 | super().install(unity, project) 528 | 529 | def get_supported_platforms() -> list[str]: 530 | return ["android", "ios", "webgl", "macos"] 531 | 532 | def get_target_platform(name: str) -> TargetPlatform: 533 | match name: 534 | case "android": 535 | return TargetPlatformAndroid() 536 | case "ios": 537 | return TargetPlatformIOS() 538 | case "webgl": 539 | return TargetPlatformWebGL() 540 | case "macos": 541 | return TargetPlatformMacOS() 542 | 543 | raise Exception(f"Unknown platform {name}") 544 | 545 | def get_agent_platform() -> AgentPlatform: 546 | match platform.system(): 547 | case "Darwin": 548 | return AgentPlatformMacOS() 549 | case _: 550 | return AgentPlatformGeneric() 551 | 552 | def main(): 553 | log.setLevel(logging.INFO) 554 | 555 | parser = argparse.ArgumentParser(prog="Pact", description="Package testing utility for Unity Engine packages") 556 | 557 | input_group = parser.add_mutually_exclusive_group(required=True) 558 | input_group.add_argument("--package", metavar="Unity Package", help="Unity Package (*.unitypackage)") 559 | input_group.add_argument("--package-url", metavar="Package URL", help="URL to git repository of a package") 560 | input_group.add_argument("--package-dir", metavar="Package directory", help="Path to directory that contains package") 561 | input_group.add_argument("--no-package", help="Skip package altogether, mainly for testing Pact itself", action="store_true") 562 | 563 | parser.add_argument("-uv", "--unity-version", metavar="Version", help="Version of Unity to use. Multiple versions could be provided. E.g. '2022.3.22f1' or '6000.2.6f2@4a4dcaec6541', where part after @ is changeset.\n" \ 564 | "IMPORTANT: if it's not one of the latest versions and this version of editor is not already installed, it must include changeset", action="extend", nargs="*") 565 | parser.add_argument("-uh", "--unity-hub", metavar="Path", help="Path to Unity Hub executable") 566 | parser.add_argument("-p", "--platform", metavar="Platform", help="Platform which will be built. Multiple platforms could be provided", choices=get_supported_platforms(), action="extend", nargs="*") 567 | parser.add_argument("-pd", "--projects-dir", help=f"Projects directory") 568 | parser.add_argument("-cd", "--cache-dir", help=f"Directory for package cache (when it needs to be downloaded)") 569 | parser.add_argument("-d", "--dependency", help="Dependency package, e.g. 'com.unity.modules.animation:1.0.0'. If version is not specified, '1.0.0' will be used. Multiple packages could be provided", action="extend", nargs="*") 570 | parser.add_argument( "--verbose", help="Enable extra logging", action="store_true") 571 | parser.add_argument("-cpp", "--il2cpp", help="Use IL2CPP runtime", action="store_true") 572 | parser.add_argument("-def", "--define", help="Adds configuration with specific symbols, e.g. 'DEBUG|STAGING'", action="extend", nargs="*") 573 | args = parser.parse_args() 574 | 575 | if args.verbose: 576 | log.setLevel(logging.DEBUG) 577 | 578 | try: 579 | root_dir = Path(os.path.dirname(sys.argv[0])) 580 | package_dir = Path(args.cache_dir) if args.cache_dir else root_dir / "cache" 581 | projects_dir = Path(args.projects_dir) if args.projects_dir else root_dir / "projects" 582 | dependencies = [PackageManagerDependency(d) for d in (args.dependency if args.dependency else [])] 583 | versions = [UnityEditorVersion(v) for v in (args.unity_version if args.unity_version else [])] 584 | platforms = [get_target_platform(p) for p in (args.platform if args.platform else [])] 585 | cs_project = CSharpProject({Path("pact.cs"): (root_dir / "pact.cs").read_text()}, "Pact.Build") 586 | definition_configurations = [symbols.split('|') for symbols in (args.define if args.define else [""])] 587 | il2cpp = True if args.il2cpp else False 588 | 589 | if len(versions) == 0: 590 | raise Exception("Nothing to test, because no '--unity-version' has been provided") 591 | 592 | if len(platforms) == 0: 593 | raise Exception("Nothing to test, because no '--platform' has been provided") 594 | 595 | if args.package_url: 596 | package = TestingPackageGit(args.package_url, package_dir) 597 | elif args.package_dir: 598 | package = TestingPackageDir(args.package_dir) 599 | elif args.no_package: 600 | package = TestingPackageEmpty() 601 | elif args.package: 602 | package = TestingPackageUnityPackage(Path(args.package).absolute()) 603 | else: 604 | raise Exception("Missing package location") 605 | 606 | agent = get_agent_platform() 607 | hub = UnityHub(agent, args.unity_hub) 608 | 609 | for version in versions: 610 | log.info(f"checking dependencies for {version}") 611 | 612 | if hub.is_editor_installed(version): 613 | hub.install_missing_modules(version, platforms) 614 | else: 615 | hub.install_editor(version, platforms) 616 | 617 | for version in versions: 618 | log.info(f"creating project for {version}") 619 | 620 | unity = hub.get_unity(version) 621 | project = unity.create_project(projects_dir / version.version, cs_project, dependencies) 622 | 623 | package.install(unity, project) 624 | 625 | for platform in platforms: 626 | for symbols in definition_configurations: 627 | params = BuildParameters(symbols, il2cpp) 628 | unity.build_project(project, platform, params) 629 | 630 | except Exception as e: 631 | log.error(f"Pact failed: {e}") 632 | log.error("\x1b[31;1mfail") 633 | os._exit(1) 634 | 635 | log.info("\x1b[32msuccess") 636 | 637 | if __name__ == '__main__': 638 | main() --------------------------------------------------------------------------------