using System.Xml; using NUnit.Framework; using Unity.Pipeline.Editor.BuildProcessors; using Unity.Pipeline.CodeReload; namespace Unity.Pipeline.Tests.Editor { /// /// Tests for the generated link.xml that preserves the interpreter's host-binding surface under /// IL2CPP managed stripping. The build-time contribution runs only inside a real player build, but /// the XML generator is pure and editor-callable, so its output is verified directly here. /// class CodeReloadLinkXmlGeneratorTests { [Test] public void GenerateLinkXml_IsWellFormedLinkerDocument() { var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(); var doc = new XmlDocument(); Assert.DoesNotThrow(() => doc.LoadXml(xml), "Generated link.xml must be well-formed XML."); Assert.AreEqual("linker", doc.DocumentElement.Name, "Root element must be ."); Assert.IsNotEmpty(doc.SelectNodes("/linker/assembly"), "Expected at least one group."); } [Test] public void GenerateLinkXml_PreservesUnityEngineColor_TheGreenYellowCase() { // Color.greenYellow exists in the Unity API but is stripped from an IL2CPP player when the // built game never references it. AllowType() registers the whole type, so preserving // Color with preserve="all" is exactly what keeps greenYellow (and every other member) // callable from reloaded code. var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(); var doc = new XmlDocument(); doc.LoadXml(xml); var colorNode = doc.SelectSingleNode( "/linker/assembly/type[@fullname='UnityEngine.Color']"); Assert.IsNotNull(colorNode, "UnityEngine.Color must be preserved so its members survive stripping."); Assert.AreEqual("all", colorNode.Attributes["preserve"]?.Value, "Color must be preserved with preserve=\"all\" to keep members like greenYellow."); } [Test] public void GenerateLinkXml_GroupsColorUnderCoreModule() { var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(); var doc = new XmlDocument(); doc.LoadXml(xml); var node = doc.SelectSingleNode( "/linker/assembly[@fullname='UnityEngine.CoreModule']/type[@fullname='UnityEngine.Color']"); Assert.IsNotNull(node, "Color should be grouped under its owning assembly, UnityEngine.CoreModule."); } [Test] public void GenerateLinkXml_WithPlayerAssemblies_PreservesUserAndEngineModulesWholesale() { // Demand-time auto-bind lets reloaded code call into any user or UnityEngine type, so the // dev build must preserve those assemblies wholesale; BCL and Unity.* package assemblies // stay per-type (the auto-bind policy declines the BCL, and Unity.* packages are AOT-heavy). var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml( new[] { "Assembly-CSharp", "MyGame.Gameplay", "UnityEngine.CoreModule", "UnityEngine.AIModule", "System.Core", "mscorlib", "Unity.Burst", "Unity.Pipeline" }, out _); var doc = new XmlDocument(); doc.LoadXml(xml); foreach (var expected in new[] { "Assembly-CSharp", "MyGame.Gameplay", "UnityEngine.CoreModule", "UnityEngine.AIModule" }) { var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{expected}']"); Assert.IsNotNull(node, $"{expected} must be preserved wholesale."); Assert.AreEqual("all", node.Attributes["preserve"]?.Value, $"{expected} must carry assembly-level preserve=\"all\"."); Assert.AreEqual(0, node.ChildNodes.Count, $"{expected} is wholesale — per-type entries are subsumed."); } foreach (var excluded in new[] { "System.Core", "mscorlib", "Unity.Burst", "Unity.Pipeline" }) { var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{excluded}']"); Assert.IsTrue(node == null || node.Attributes["preserve"] == null, $"{excluded} must not be preserved wholesale."); } } [Test] public void GenerateLinkXml_WithoutPlayerAssemblies_OnlyInterpreterEngineIsWholesale() { // Null player-assembly set (unreadable linker input, or the parameterless test entry // point) degrades to per-type host-surface preservation plus the always-on interpreter // engine assemblies — nothing else may be preserved wholesale. var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(); var doc = new XmlDocument(); doc.LoadXml(xml); foreach (XmlNode asm in doc.SelectNodes("/linker/assembly")) { var name = asm.Attributes["fullname"]?.Value; if (name == "Unity.Pipeline.IlInterpreter" || name == "UnityPipeline.System.Reflection.Metadata" || name == "UnityPipeline.System.Collections.Immutable") continue; Assert.IsNull(asm.Attributes["preserve"], $"without a player assembly set '{name}' may not be preserved wholesale"); } } [Test] public void GenerateLinkXml_AlwaysPreservesInterpreterEngineAssembliesWholesale() { // The interpreter parses pushed override IL via System.Reflection.Metadata and resolves // host members by name; under IL2CPP stripping those paths look unused. This preservation // used to live in a static Runtime/link.xml, but that file applied to release builds too // (real size cost for machinery that is compiled out of release players). The generator // only contributes to development builds, so it now owns this set — with or without a // player-assembly list. foreach (var xml in new[] { CodeReloadLinkXmlGenerator.GenerateLinkXml(), CodeReloadLinkXmlGenerator.GenerateLinkXml(new[] { "Assembly-CSharp" }, out _), }) { var doc = new XmlDocument(); doc.LoadXml(xml); foreach (var expected in new[] { "Unity.Pipeline.IlInterpreter", "UnityPipeline.System.Reflection.Metadata", "UnityPipeline.System.Collections.Immutable" }) { var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{expected}']"); Assert.IsNotNull(node, $"{expected} must be preserved so the interpreter survives stripping."); Assert.AreEqual("all", node.Attributes["preserve"]?.Value, $"{expected} must carry assembly-level preserve=\"all\"."); } } } [Test] public void GenerateLinkXml_UnityPrefixedGameAssemblies_AreTreatedAsUserCode() { // A bare "Unity"/"System" prefix would misclassify game assemblies that merely start // with the word — "UnityChanDemo", "SystemsRuntime" — as engine/BCL code, silently // dropping them from wholesale preservation and from the [CodeReload] target scan // (both share IsNonUserAssembly). Only real engine/BCL names are non-user. var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml( new[] { "UnityChanDemo", "SystemsRuntime", "Unity", "Unity.Burst", "System.Core" }, out _); var doc = new XmlDocument(); doc.LoadXml(xml); foreach (var user in new[] { "UnityChanDemo", "SystemsRuntime" }) { var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{user}']"); Assert.IsNotNull(node, $"{user} is user code and must be preserved wholesale."); Assert.AreEqual("all", node.Attributes["preserve"]?.Value, $"{user} must carry assembly-level preserve=\"all\"."); } foreach (var engine in new[] { "Unity", "Unity.Burst", "System.Core" }) { var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{engine}']"); Assert.IsTrue(node == null || node.Attributes["preserve"] == null, $"{engine} is engine/BCL code and must not be preserved wholesale."); } } // Rescue probe: this test assembly is Unity.Pipeline.Tests.Editor — it matches the // engine-assembly name pattern, yet carries this [CodeReload] member, standing in for a // game assembly named e.g. "Unity.MyGame". class EngineNamedAssemblyProbe { [CodeReload] void Tick() { } } [Test] public void GenerateLinkXml_FindsCodeReloadTargets_InEngineNamedAssemblies() { // The [CodeReload] attribute must be authoritative for the target scan; the engine-name // check is only a fast path. [CodeReload] lives in Unity.Pipeline, so an engine-named // assembly that references Unity.Pipeline can carry targets and must still be scanned — // otherwise its private state is stripped and reload fails on device only. var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(); var expected = typeof(EngineNamedAssemblyProbe).FullName.Replace('+', '/'); StringAssert.Contains($"", xml, "[CodeReload] targets in an engine-named assembly must still be preserved."); } [Test] public void GenerateLinkXml_WithPlayerAssemblies_DropsReloadTargetsNotStagedForTheBuild() { // The AppDomain scan sees editor-only and test assemblies that are not part of the // player build; their [CodeReload] entries are noise in a game build's link.xml. When // the staged set is known, only reload targets actually in the build are emitted — // which also covers user test asmdefs. A test-player build stages its test // assemblies, so fixtures keep their entries exactly where they matter. var probe = typeof(EngineNamedAssemblyProbe).FullName.Replace('+', '/'); var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(new[] { "Assembly-CSharp" }, out _); StringAssert.DoesNotContain(probe, xml, "an unstaged assembly must not contribute reload-target entries"); } [Test] public void GenerateLinkXml_PromotesStagedEngineNamedAssembliesCarryingCodeReloadTargets() { // A staged, engine-named assembly carrying [CodeReload] members has identified itself // as user code participating in code reload — a package-like name (e.g. "Unity.MyGame") // must not demote it. Preserving only its [CodeReload] types would leave the rest of the // assembly strippable, so demand-time auto-bind could hit stripped members on device // only; instead it is preserved wholesale like any other user assembly. This test // assembly (Unity.Pipeline.Tests.Editor, carrying the probe above) plays that role. var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml( new[] { "Assembly-CSharp", "Unity.Pipeline.Tests.Editor" }, out _); var doc = new XmlDocument(); doc.LoadXml(xml); var node = doc.SelectSingleNode("/linker/assembly[@fullname='Unity.Pipeline.Tests.Editor']"); Assert.IsNotNull(node, "the promoted assembly must appear in the link.xml"); Assert.AreEqual("all", node.Attributes["preserve"]?.Value, "the promoted assembly must carry assembly-level preserve=\"all\""); Assert.AreEqual(0, node.ChildNodes.Count, "wholesale preservation subsumes the per-type [CodeReload] entries"); } [Test] public void CollectPlayerAssemblyNames_WithEmptyInputDir_RecoversUnityEngineModulesFromTheEditor() { // The linker input directory is empty at IUnityLinkerProcessor time on some build backends // (the symptom that stripped UITK ctors on device: a live-added `new Slider()` couldn't // resolve because UnityEngine.UIElementsModule wasn't preserved wholesale). The collector // must fall back to the editor's own assembly view so wholesale preservation still fires. var names = CodeReloadLinkXmlGenerator.CollectPlayerAssemblyNames("/no/such/linker/input/dir"); Assert.IsNotNull(names, "an empty input dir must still yield the editor's player-assembly view"); CollectionAssert.Contains(names, "UnityEngine.UIElementsModule", "the UnityEngine module carrying Slider must be recovered so its ctors survive stripping"); CollectionAssert.Contains(names, "UnityEngine.CoreModule", "core UnityEngine modules must be recovered from the loaded editor assemblies"); } [Test] public void CollectPlayerAssemblyNames_FeedsWholesalePreservationOfUIElements() { // End-to-end for the Slider fix: the recovered assembly set, fed through the generator, // must preserve UnityEngine.UIElementsModule wholesale (assembly-level preserve="all"), // which is what keeps Slider's constructors callable from a reloaded body on device. var names = CodeReloadLinkXmlGenerator.CollectPlayerAssemblyNames(null); var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(names, out _); var doc = new XmlDocument(); doc.LoadXml(xml); var node = doc.SelectSingleNode("/linker/assembly[@fullname='UnityEngine.UIElementsModule']"); Assert.IsNotNull(node, "UnityEngine.UIElementsModule must be preserved so Slider's ctors survive."); Assert.AreEqual("all", node.Attributes["preserve"]?.Value, "UnityEngine.UIElementsModule must carry assembly-level preserve=\"all\"."); } [Test] public void GenerateLinkXml_EveryTypeIsPreservedAll() { var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(); var doc = new XmlDocument(); doc.LoadXml(xml); foreach (XmlNode type in doc.SelectNodes("/linker/assembly/type")) { Assert.AreEqual("all", type.Attributes["preserve"]?.Value, $"Type {type.Attributes["fullname"]?.Value} must use preserve=\"all\"."); } } } }