using System.Collections.Generic; using System.IO; using System.Linq; using NUnit.Framework; using Unity.Pipeline.Config; using Unity.Pipeline.Editor.BuildProcessors; using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; using UnityEditor.SceneManagement; using UnityEditor.TestTools; using UnityEngine; using UnityEngine.TestTools; namespace Unity.Pipeline.Tests.Editor { /// /// Tests for Pipeline runtime build support using RuntimePipelineConfig. Validates build /// processor behavior and the transient RuntimePipelineConfig/RuntimePipelineBuildInfo asset /// generation/cleanup. /// class RuntimePipelineBuildTests { private const string TransientConfigAssetPath = "Assets/Settings/Pipeline/Resources/RuntimePipelineConfig.asset"; private const string BuildInfoAssetPath = "Assets/Settings/Pipeline/Resources/RuntimePipelineBuildInfo.asset"; private PipelineRuntimeBuildProcessor m_BuildProcessor; private bool m_PreviousDevelopment; [SetUp] public void SetUp() { RuntimePipelineResourceTestGuard.AssertNoConfigAssetExists(); m_BuildProcessor = new PipelineRuntimeBuildProcessor(); DeleteIfExists(TransientConfigAssetPath); DeleteIfExists(BuildInfoAssetPath); // Force a Development Build context: OnPreprocessBuild bails out before baking // anything for a non-development build, so without this every asset-generation // assertion below would trivially pass for the wrong reason. The non-development path // has its own test. m_PreviousDevelopment = EditorUserBuildSettings.development; EditorUserBuildSettings.development = true; } [TearDown] public void TearDown() { EditorUserBuildSettings.development = m_PreviousDevelopment; DeleteIfExists(TransientConfigAssetPath); DeleteIfExists(BuildInfoAssetPath); RuntimePipelineResourceTestGuard.DeleteConfigIfExists(); } private static void DeleteIfExists(string path) { if (AssetDatabase.LoadAssetAtPath(path) != null) AssetDatabase.DeleteAsset(path); } private static void CreateConfig(bool enableInBuilds) { var config = ScriptableObject.CreateInstance(); config.enableInBuilds = enableInBuilds; config.port = 7920; config.Save(); Object.DestroyImmediate(config); } /// /// Write both transient build-only assets directly, simulating what an interrupted build /// (crash, force-quit) leaves behind before OnPostprocessBuild ever ran. Uses port 7999 and /// an obviously-fake reload root so a test can tell "this is the stale leftover" apart from /// whatever the current authored config/CollectProjectRoots would actually produce. /// private static void SeedStaleTransientAssets(bool enableInBuilds) { EnsureFolder(Path.GetDirectoryName(TransientConfigAssetPath).Replace('\\', '/')); var staleConfig = ScriptableObject.CreateInstance(); staleConfig.enableInBuilds = enableInBuilds; staleConfig.port = 7999; AssetDatabase.CreateAsset(staleConfig, TransientConfigAssetPath); var staleBuildInfo = ScriptableObject.CreateInstance(); staleBuildInfo.allowedReloadRoots = new List { "Z:/stale/leftover/root" }; AssetDatabase.CreateAsset(staleBuildInfo, BuildInfoAssetPath); AssetDatabase.SaveAssets(); } private static void EnsureFolder(string folder) { var parts = folder.Split('/'); var current = parts[0]; for (var i = 1; i < parts.Length; i++) { var next = current + "/" + parts[i]; if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, parts[i]); current = next; } } #if UNITY_6000_3_OR_NEWER private BuildCallbackContext CreateMockBuildReport() #else private BuildReport CreateMockBuildReport() #endif { // Neither preprocess nor postprocess actually reads the report parameter. return null; } // authorDisabledConfig: whether an authored RuntimePipelineConfig with enableInBuilds=false // exists (vs. no config file at all). seedStaleAssets: whether a leftover ENABLED transient // asset pair (as an interrupted earlier build would leave) is present beforehand. The // seedStaleAssets:true rows are the regression case: before the fix, an early return in // OnPreprocessBuild left the stale enabled asset in place, so a build that should have // Pipeline disabled would still ship it. [TestCase(false, false)] [TestCase(false, true)] [TestCase(true, false)] [TestCase(true, true)] public void Preprocess_PipelineNotShipping_LeavesNoTransientAssets(bool authorDisabledConfig, bool seedStaleAssets) { if (authorDisabledConfig) CreateConfig(enableInBuilds: false); if (seedStaleAssets) SeedStaleTransientAssets(enableInBuilds: true); Assert.DoesNotThrow(() => m_BuildProcessor.OnPreprocessBuild(CreateMockBuildReport())); Assert.IsNull(AssetDatabase.LoadAssetAtPath(BuildInfoAssetPath), "No build-info asset should remain when Pipeline is not shipping in this build."); Assert.IsNull(AssetDatabase.LoadAssetAtPath(TransientConfigAssetPath), "No transient config asset should remain when Pipeline is not shipping in this build " + "— including a stale, possibly-enabled one left behind by a previous interrupted build."); } [Test] public void Preprocess_StaleTransientAssets_PipelineEnabled_ReplacesWithCurrentValues() { // Stale leftover pretends Pipeline was disabled and points at a fake reload root. SeedStaleTransientAssets(enableInBuilds: false); // Current authored config: enabled, port 7920 (CreateConfig's fixed value). CreateConfig(enableInBuilds: true); m_BuildProcessor.OnPreprocessBuild(CreateMockBuildReport()); var baked = AssetDatabase.LoadAssetAtPath(TransientConfigAssetPath); Assert.IsNotNull(baked, "OnPreprocessBuild should bake the current authored config"); Assert.IsTrue(baked.enableInBuilds, "Baked asset must reflect the current authored config, not the stale leftover"); Assert.AreEqual(7920, baked.port, "Baked asset must reflect the current authored config's port, not the stale leftover's 7999"); var buildInfo = AssetDatabase.LoadAssetAtPath(BuildInfoAssetPath); Assert.IsNotNull(buildInfo); var expectedRoots = PipelineRuntimeBuildProcessor.CollectProjectRoots(); CollectionAssert.AreEquivalent(expectedRoots, buildInfo.allowedReloadRoots, "Build-info asset must reflect the current project roots, not the stale leftover's fake root"); } [Test] public void Preprocess_StaleAssetWithMismatchedType_IsStillPurged() { // Simulate the worst-case leftover: an asset at RuntimePipelineConfig's path that can no // longer be loaded AS RuntimePipelineConfig (e.g. a script reference broken by a package // upgrade). A typed AssetDatabase.LoadAssetAtPath check would miss // this entirely and leave it on disk, inside Resources, for the next build to package. EnsureFolder(Path.GetDirectoryName(TransientConfigAssetPath).Replace('\\', '/')); var wrongTypeAsset = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(wrongTypeAsset, TransientConfigAssetPath); AssetDatabase.SaveAssets(); Assert.IsNull(AssetDatabase.LoadAssetAtPath(TransientConfigAssetPath), "Precondition: a typed load must not find the mismatched-type asset — that's the gap this test guards against"); m_BuildProcessor.OnPreprocessBuild(CreateMockBuildReport()); Assert.IsNull(AssetDatabase.LoadAssetAtPath(TransientConfigAssetPath), "A stale asset must be purged even when it cannot be loaded as its expected type"); } [Test] public void BuildProcessor_ConfigEnabled_WritesBuildInfoWithProjectRoots() { CreateConfig(enableInBuilds: true); m_BuildProcessor.OnPreprocessBuild(CreateMockBuildReport()); var buildInfo = AssetDatabase.LoadAssetAtPath(BuildInfoAssetPath); Assert.IsNotNull(buildInfo, "OnPreprocessBuild should create the build-info asset when enabled"); var expectedRoots = PipelineRuntimeBuildProcessor.CollectProjectRoots(); CollectionAssert.AreEquivalent(expectedRoots, buildInfo.allowedReloadRoots); } [Test] public void BuildProcessor_ConfigEnabled_WritesTransientConfigAssetWithSameValues() { CreateConfig(enableInBuilds: true); m_BuildProcessor.OnPreprocessBuild(CreateMockBuildReport()); var baked = AssetDatabase.LoadAssetAtPath(TransientConfigAssetPath); Assert.IsNotNull(baked, "OnPreprocessBuild should bake the authored config into a transient Resources asset"); Assert.IsTrue(baked.enableInBuilds); Assert.AreEqual(7920, baked.port); } [Test] public void BuildProcessor_PostprocessBuild_DeletesBuildInfoAndConfigAssets() { CreateConfig(enableInBuilds: true); m_BuildProcessor.OnPreprocessBuild(CreateMockBuildReport()); Assert.IsNotNull(AssetDatabase.LoadAssetAtPath(BuildInfoAssetPath), "Precondition: build-info asset must exist before postprocess runs"); Assert.IsNotNull(AssetDatabase.LoadAssetAtPath(TransientConfigAssetPath), "Precondition: transient config asset must exist before postprocess runs"); // OnPostprocessBuild's signature is not versioned by UNITY_6000_3_OR_NEWER (unlike // OnPreprocessBuild), so it always takes a BuildReport — pass null directly rather than // via CreateMockBuildReport(), whose return type tracks the versioned preprocess signature. m_BuildProcessor.OnPostprocessBuild(null); Assert.IsNull(AssetDatabase.LoadAssetAtPath(BuildInfoAssetPath), "OnPostprocessBuild must remove the transient build-info asset"); Assert.IsNull(AssetDatabase.LoadAssetAtPath(TransientConfigAssetPath), "OnPostprocessBuild must remove the transient config asset"); } // enableInBuilds is on, but a release build that does not opt in carries no Pipeline code at // all — the runtime assemblies and the bundled Roslyn DLLs are constrained to // "UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE" — so nothing in the player // could read the transient assets and they must not be baked. seedStaleAssets covers the // interrupted-build leftover. (The opt-in branch reads PlayerSettings' define symbols, which // this fixture does not mutate; it is covered by a player build instead.) [TestCase(false)] [TestCase(true)] public void Preprocess_EnabledButNotDevelopmentBuild_LeavesNoTransientAssets(bool seedStaleAssets) { CreateConfig(enableInBuilds: true); if (seedStaleAssets) SeedStaleTransientAssets(enableInBuilds: true); EditorUserBuildSettings.development = false; Assert.DoesNotThrow(() => m_BuildProcessor.OnPreprocessBuild(CreateMockBuildReport())); Assert.IsNull(AssetDatabase.LoadAssetAtPath(BuildInfoAssetPath), "A non-development build must not get a build-info asset."); Assert.IsNull(AssetDatabase.LoadAssetAtPath(TransientConfigAssetPath), "A non-development build must not get a transient config asset."); } } /// /// Platform-specific build tests following Unity test patterns. /// Tests actual build generation with authored RuntimePipelineConfig settings. /// [RequirePlatformSupport(BuildTarget.StandaloneWindows64)] class RuntimePipelineWindows64BuildTests : RuntimePipelineBuildTestBase { protected override BuildTarget Target => BuildTarget.StandaloneWindows64; } /// /// Abstract base class for build generation tests. /// Follows Unity test patterns for build validation. /// abstract class RuntimePipelineBuildTestBase { protected abstract BuildTarget Target { get; } protected string BuildDirectory => $"TestBuild/{GetType().Name}"; private const string TransientConfigAssetPath = "Assets/Settings/Pipeline/Resources/RuntimePipelineConfig.asset"; private const string TransientBuildInfoAssetPath = "Assets/Settings/Pipeline/Resources/RuntimePipelineBuildInfo.asset"; private bool m_PreviousDevelopment; [SetUp] public void SetUp() { RuntimePipelineResourceTestGuard.AssertNoConfigAssetExists(); // BuildOptions.Development on this test's BuildPipeline.BuildPlayer call does not // reliably flip the global EditorUserBuildSettings.development before OnPreprocessBuild // runs, so force it directly — otherwise the processor treats this as a release build // and bails out before baking the assets these tests inspect. m_PreviousDevelopment = EditorUserBuildSettings.development; EditorUserBuildSettings.development = true; } [TearDown] public void TearDown() { EditorUserBuildSettings.development = m_PreviousDevelopment; RuntimePipelineResourceTestGuard.DeleteConfigIfExists(); } [OneTimeSetUp] public void OneTimeSetUp() { if (Directory.Exists(BuildDirectory)) Directory.Delete(BuildDirectory, true); } [OneTimeTearDown] public void OneTimeTearDown() { if (Directory.Exists(BuildDirectory)) Directory.Delete(BuildDirectory, true); } [Test] public void BuildPlayerWithRuntimeConfig_ValidConfiguration_BuildSucceeds() { if (!IsPlatformSupportInstalled(Target)) { Assert.Ignore($"Platform support for {Target} is not installed"); } // Note: When calling BuildPlayer the list of scenes is ALWAYS overridden with what is in the current build profile var testScenePath = CreateTestSceneWithConfig(validConfiguration: true); try { var buildOptions = new BuildPlayerOptions { scenes = new[] { testScenePath }, locationPathName = Path.Combine(BuildDirectory, "TestBuild.exe"), target = Target, options = BuildOptions.Development // Use development for faster builds }; var report = BuildPipeline.BuildPlayer(buildOptions); Assert.AreEqual(BuildResult.Succeeded, report.summary.result, $"Build should succeed: {string.Join("; ", report.steps.SelectMany(s => s.messages).Select(m => m.content))}"); var outputFiles = report.GetFiles(); Assert.IsTrue(outputFiles.Any(), "Build should produce output files"); Assert.IsTrue(File.Exists(buildOptions.locationPathName), "Executable should exist after successful build"); } finally { AssetDatabase.DeleteAsset(testScenePath); if (AssetDatabase.LoadAssetAtPath(TransientConfigAssetPath) != null) AssetDatabase.DeleteAsset(TransientConfigAssetPath); if (AssetDatabase.LoadAssetAtPath(TransientBuildInfoAssetPath) != null) AssetDatabase.DeleteAsset(TransientBuildInfoAssetPath); } } /// Create an empty test scene and author matching Pipeline settings (Unity still requires >=1 scene to build). protected string CreateTestSceneWithConfig(bool validConfiguration) { var scenePath = $"Assets/TestScene_{Target}_{System.Guid.NewGuid():N}.unity"; var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene); EditorSceneManager.SaveScene(scene, scenePath); var config = ScriptableObject.CreateInstance(); config.enableInBuilds = validConfiguration; config.Save(); Object.DestroyImmediate(config); return scenePath; } /// Check if platform support is installed for the target. protected bool IsPlatformSupportInstalled(BuildTarget target) { try { var group = BuildPipeline.GetBuildTargetGroup(target); return true; // If we can get the group, platform support is available } catch { return false; } } } }