--- a +++ b/Interfaces/+APUnitTestFramework/Testbed.cs @@ -0,0 +1,510 @@ +using _3S.CoDeSys.Core.ComponentModel; +using _3S.CoDeSys.Core.Components; +using _3S.CoDeSys.Core.Licensing; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Serialization.Json; +using System.Threading; +using System.Xml; +using System.Xml.Linq; +using System.Xml.XPath; + +namespace _3S.APUnitTestFramework +{ + /// <summary> + /// Use this class to create a single unit test environment. It can be considered being a + /// lightweight Automation Platform process containing the testee plug-in and mocks for all + /// dependencies. + /// </summary> + /// <remarks> + /// Note that the testbed will only work properly if all involved dependencies are handled + /// using our Dependency Injection mechanisms, and if the (FxCop-enforced) + /// APEnvironment-DependencyBag implementation pattern is used. + /// </remarks> + public class Testbed : IDisposable + { + /// <summary> + /// Creates a new testbed. + /// </summary> + public Testbed() + { + } + + /// <summary> + /// Gets or sets the root folder of the Automation Platform installation. If this property + /// is not explicitly set, or explicitly set to <c>null</c>, then the root folder will be + /// automatically determined as described in the Remarks section. + /// </summary> + /// <exception cref="InvalidOperationException"> + /// The value of this property is set after <see cref="Initialize"/> has been called. + /// </exception> + /// <remarks> + /// There are two heuristics how to find out the root folder of the Automation Platform + /// installation, applied in that order given: + /// <list type="bullet"> + /// <item> + /// If the unit test assembly is within an Apaddon directory structure, then the default + /// target in the corresponding <c>AddOn.json</c> file will be used as root folder of the + /// test environment. + /// </item> + /// <item> + /// If the unit test assembly is not within an Apaddon directory structure, or the + /// <c>AddOn.json</c> file could not be found or evaluated, then the root folder as + /// specified in the <c>%CODESYS_OUTPUTDIR%</c> or <c>%CODESYS_64_OUTPUTDIR%</c> environment variable will be used + /// depending on <c>%CODESYS_PLATFORM%</c>. This + /// scenario should work in all scenarios where unit tests are developed for standard + /// CODESYS plug-ins. + /// </item> + /// </list> + /// </remarks> + public string RootFolder + { + get + { + if (_rootFolder == null) + { + _rootFolder = DeriveRootFolderFromApaddon(); + if (string.IsNullOrEmpty(_rootFolder) || !Directory.Exists(_rootFolder)) + _rootFolder = DeriveRootFolderFromStandard(); + if (string.IsNullOrEmpty(_rootFolder) || !Directory.Exists(_rootFolder)) + _rootFolder = string.Empty; + } + return _rootFolder; + } + + set + { + if (_initializeCalled) + throw new InvalidOperationException("Attempt to set the RootFolder property after calling Initialize()"); + + _rootFolder = value; + } + } + + private static string DeriveRootFolderFromApaddon() + { + try + { + var thisAssemblyDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + var targetsDirectory = Path.Combine(thisAssemblyDirectory, @"..\..\..\..\..\Targets"); + var targetsFile = Path.Combine(targetsDirectory, @".addon"); + + // NewtonSoft.Json would be a nice alternative, but unfortunately we are not + // allowed to reference it from an interface assembly. + // However, .NET has got built-in support. + var jsonData = File.ReadAllBytes(targetsFile); + var jsonReader = JsonReaderWriterFactory.CreateJsonReader(jsonData, new XmlDictionaryReaderQuotas()); + var jsonRoot = XElement.Load(jsonReader); + var nameElement = jsonRoot.XPathSelectElement("//active/name"); + var name = nameElement.Value; + + var targetDirectory = Path.Combine(targetsDirectory, name); + return targetDirectory; + } + catch + { + return null; + } + } + + private static string DeriveRootFolderFromStandard() + { + if (Environment.GetEnvironmentVariable("CODESYS_PLATFORM") != null && + (Environment.GetEnvironmentVariable("CODESYS_PLATFORM").Equals("x64", StringComparison.CurrentCultureIgnoreCase) || + Environment.GetEnvironmentVariable("CODESYS_PLATFORM").Equals("BOTH", StringComparison.CurrentCultureIgnoreCase))) + { + return Environment.ExpandEnvironmentVariables(Environment.GetEnvironmentVariable("CODESYS_64_OUTPUTDIR")); + } + else + { + return Environment.ExpandEnvironmentVariables(Environment.GetEnvironmentVariable("CODESYS_OUTPUTDIR")); + } + + } + + /// <summary> + /// Call this method in order to add a plug-in to the test environment. + /// </summary> + /// <param name="plugInGuid"> + /// The GUID of the plug-in to be added to the test environment. The newest version will be + /// used. + /// </param> + /// <exception cref="InvalidOperationException"> + /// This method is called after <see cref="Initialize"/> has been called. + /// </exception> + /// <remarks> + /// One should use this possibility with care. When adding "real" plug-ins to the test + /// environment, the idea of a "pure" unit test will be compromised, where every dependency + /// should be a mock with an exactly specified behavior. + /// Furthermore, please do not add the <c>Engine.plugin</c> to the environment. The reason + /// is that two Component Model implementations would then be part of the environment, + /// causing the Component Manager to throw an exception during initialization. + /// </remarks> + public void IncludeAdditionalPlugInGuid(Guid plugInGuid) + { + if (_initializeCalled) + throw new InvalidOperationException("Attempt to include additional plug-ins after calling Initialize()"); + + if (_additionalPlugInGuids == null) + _additionalPlugInGuids = new HashSet<Guid>(); + _additionalPlugInGuids.Add(plugInGuid); + } + + /// <summary> + /// Gets the plug-in GUIDs of the additional plug-ins that have been added via <see cref= + /// "IncludeAdditionalPlugInGuid(Guid)"/>. + /// </summary> + public IEnumerable<Guid> AdditionalPlugInGuids + { + get + { + if (_additionalPlugInGuids != null) + return _additionalPlugInGuids; + else + return new Guid[0]; + } + } + + /// <summary> + /// Adds a mock to the test environment. This is the preferred way to fulfill any + /// dependency of the testee. + /// <seealso cref="AddMock(Guid, Func{object}, bool)"/> + /// <seealso cref="AddMock(Guid, Func{object}, Func{object}, bool)"/> + /// </summary> + /// <param name="typeGuid"> + /// The type GUID of the mock type. If the testee's dependency is described by an <see + /// cref="InjectSpecificInstanceAttribute"/> or an <see cref= + /// "InjectSpecificTypeInformationAttribute"/>, then the specified GUID must match the + /// requested one, otherwise a new GUID can be created. + /// </param> + /// <param name="createFunc"> + /// A delegate which creates the mock instance on request. + /// </param> + /// <exception cref="InvalidOperationException"> + /// This method is called after <see cref="Initialize"/> has been called. + /// </exception> + /// <exception cref="ArgumentNullException"> + /// <paramref name="createFunc"/> is <c>null</c>. + /// </exception> + /// <exception cref="ArgumentException"> + /// This method is called multiple times with the same <paramref name="typeGuid"/> value. + /// </exception> + public void AddMock(Guid typeGuid, Func<object> createFunc) + { + AddMock(typeGuid, createFunc, createFunc, false); + } + + /// <summary> + /// Adds a mock to the test environment. This is the preferred way to fulfill any + /// dependency of the testee. + /// <seealso cref="AddMock(Guid, Func{object})"/> + /// <seealso cref="AddMock(Guid, Func{object}, Func{object}, bool)"/> + /// </summary> + /// <param name="typeGuid"> + /// The type GUID of the mock type. If the testee's dependency is described by an <see + /// cref="InjectSpecificInstanceAttribute"/> or an <see cref= + /// "InjectSpecificTypeInformationAttribute"/>, then the specified GUID must match the + /// requested one, otherwise a new GUID can be created. + /// </param> + /// <param name="createFunc"> + /// A delegate which creates the mock instance on request. + /// </param> + /// <param name="systemInstance"> + /// This mock is a system instance, i.e. it will be a global singleton. + /// </param> + /// <exception cref="InvalidOperationException"> + /// This method is called after <see cref="Initialize"/> has been called. + /// </exception> + /// <exception cref="ArgumentNullException"> + /// <paramref name="createFunc"/> is <c>null</c>. + /// </exception> + /// <exception cref="ArgumentException"> + /// This method is called multiple times with the same <paramref name="typeGuid"/> value. + /// </exception> + public void AddMock(Guid typeGuid, Func<object> createFunc, bool systemInstance) + { + AddMock(typeGuid, createFunc, createFunc, systemInstance); + } + + /// <summary> + /// Adds a mock to the test environment. This is the preferred way to fulfill any + /// dependency of the testee. + /// <seealso cref="AddMock(Guid, Func{object})"/> + /// <seealso cref="AddMock(Guid, Func{object}, bool)"/> + /// </summary> + /// <param name="typeGuid"> + /// The type GUID of the mock type. If the testee's dependency is described by an <see + /// cref="InjectSpecificInstanceAttribute"/> or an <see cref= + /// "InjectSpecificTypeInformationAttribute"/>, then the specified GUID must match the + /// requested one, otherwise a new GUID can be created. + /// </param> + /// <param name="createFunc"> + /// A delegate which creates the mock instance on request. + /// </param> + /// <param name="createPrototypeFunc"> + /// The testbed will create prototypes for the mock instance during initialization. If + /// there is a different logic for prototype instances compared to "real" instances, then + /// a dedicated creation function can be specified using this parameter. (For example, a + /// prototype instance should not attach itself to events, whereas a "real" instance might + /// do it.) The other overloads of this method use the same creation function for both + /// construction methods. + /// </param> + /// <param name="systemInstance"> + /// This mock is a system instance, i.e. it will be a global singleton. + /// </param> + /// <exception cref="InvalidOperationException"> + /// This method is called after <see cref="Initialize"/> has been called. + /// </exception> + /// <exception cref="ArgumentNullException"> + /// <paramref name="createFunc"/> is <c>null</c>. + /// </exception> + /// <exception cref="ArgumentException"> + /// This method is called multiple times with the same <paramref name="typeGuid"/> value. + /// </exception> + public void AddMock(Guid typeGuid, Func<object> createFunc, Func<object> createPrototypeFunc, bool systemInstance) + { + if (_initializeCalled) + throw new InvalidOperationException("Attempt to add mock after calling Initialize()"); + + if (_mocks == null) + _mocks = new Dictionary<Guid, MockConstructor>(); + + var mockConstructor = new MockConstructor(createFunc, createPrototypeFunc, systemInstance); + _mocks.Add(typeGuid, mockConstructor); + } + + /// <summary> + /// Gets a read-only dictionary of all mock constructors that have been added via <see + /// cref="AddMock(Guid, MockConstructor)"/>. + /// </summary> + public IDictionary<Guid, MockConstructor> Mocks + { + get + { + if (_mocks != null) + return new ReadOnlyDictionary<Guid, MockConstructor>(_mocks); + else + return new ReadOnlyDictionary<Guid, MockConstructor>(new Dictionary<Guid, MockConstructor>()); + } + } + + /// <summary> + /// Initializes the test environment, after the caller has performed all necessary + /// preparations using <see cref="RootFolder"/>, <see cref="IncludeAdditionalPlugInGuid + /// (Guid)"/>, and <see cref="AddMock(Guid, MockConstructor)"/>. + /// </summary> + /// <exception cref="InvalidOperationException"> + /// The <see cref="RootFolder"/> property returns an invalid or non-existing directory. + /// </exception> + /// <exception cref="PlugInDoesNotExistException"> + /// Either the <c>APUnitTestFramework.plugin</c> or one of the plug-ins denoted by <see + /// cref="AdditionalPlugInGuids"/> is not installed. + /// </exception> + /// <exception cref="Exception"> + /// This method rethrows all exceptions that are thrown by the standard <see cref= + /// "ComponentManager"/> implementation. + /// </exception> + /// <remarks> + /// This initialization routine comprises the following steps: + /// <list type="bullet"> + /// <item> + /// Resetting the <c>ComponentManager</c> singleton. + /// </item> + /// <item> + /// Resetting the <c>ComponentModel</c> singleton. + /// </item> + /// <item> + /// Scanning all loaded assemblies for <c>APEnvironment</c> classes and resetting their + /// lazily initialized bag field (type <c>Lazy<DependencyBag></c>). This is the + /// reason why the testbed only works reliably for plug-ins which conform to the + /// APEnvironment-DependencyBag implementation pattern, as enforced by our internal FxCop + /// coding rules. + /// </item> + /// <item> + /// Setting the root folder as specified by the <see cref="RootFolder"/> property. + /// </item> + /// <item> + /// Creating a temporary profile which only contains the <c>APUnitTestFramework.plugin</c> + /// and the plug-ins as specified via the <see cref="IncludeAdditionalPlugInGuid(Guid)"/> + /// method. + /// </item> + /// <item> + /// Creating the system instances. + /// </item> + /// </list> + /// </remarks> + public void Initialize() + { + _initializeCalled = true; + + // Reset all static things. + + // Unregister component Manager from AppDomain events + var fiAssemblyLoad = AppDomain.CurrentDomain.GetType().GetField("AssemblyLoad", BindingFlags.Instance | BindingFlags.NonPublic); + if (fiAssemblyLoad != null) + { + AssemblyLoadEventHandler eventHandler = fiAssemblyLoad.GetValue(AppDomain.CurrentDomain) as AssemblyLoadEventHandler; + if (eventHandler != null) + { + AppDomain.CurrentDomain.AssemblyLoad -= eventHandler; + } + } + var fiAssemblyResolve = AppDomain.CurrentDomain.GetType().GetField("_AssemblyResolve", BindingFlags.Instance | BindingFlags.NonPublic); + if (fiAssemblyResolve != null) + { + ResolveEventHandler eventHandler = fiAssemblyResolve.GetValue(AppDomain.CurrentDomain) as ResolveEventHandler; + if (eventHandler != null) + { + AppDomain.CurrentDomain.AssemblyResolve -= eventHandler; + } + } + + // The private s_singleton field is obfuscated! As a hack, discover all fields for + // that one which is of type ComponentManager. Ugh! + var fieldInfo = typeof(ComponentManager).GetFields(BindingFlags.Static | BindingFlags.NonPublic) + .Single(fi => typeof(ComponentManager).IsAssignableFrom(fi.FieldType)); + fieldInfo.SetValue(null, null); + + // The private s_singleton field is obfuscated! As a hack, discover all fields for + // that one which is of type ComponentModel. Doubled ugh! + fieldInfo = typeof(ComponentModel).GetFields(BindingFlags.Static | BindingFlags.NonPublic) + .Single(fi => typeof(ComponentModel).IsAssignableFrom(fi.FieldType)); + fieldInfo.SetValue(null, null); + + // Finally, we must reset all the dependency bags that are around. Obviously, this is + // only possible by reflection. + // Note: We are heavily tweaking the Lazy<T> object's internal members. A different + // idea would be to create new Lazy<T> objects with the original value factories. + // However, it turns out to be impossible because the value factory is internally + // replaced by something else in the Lazy<T> implementation + // ("ALREADY_INVOKED_SENTINEL") so that it becomes impossible for us to get hold of the + // original value factory as specified in the constructor. + var alreadyInitializedLazyDependencyBags = AppDomain.CurrentDomain.GetAssemblies() + .Where(asm => PlugInGuidAttribute.FromAssembly(asm) != null) + .SelectMany(asm => asm.GetTypes()) + .Where(type => type.Name == "APEnvironment") + .SelectMany(type => type.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)) + .Where(field => field.FieldType.IsGenericType && typeof(Lazy<>).IsAssignableFrom(field.FieldType.GetGenericTypeDefinition())) + .Select(field => field.GetValue(null)); + foreach (var dependencyBag in alreadyInitializedLazyDependencyBags) + { + var boxed = dependencyBag.GetType().GetField("m_boxed", BindingFlags.Instance | BindingFlags.NonPublic); + boxed.SetValue(dependencyBag, null); + + var valueFactory = dependencyBag.GetType().GetField("m_valueFactory", BindingFlags.Instance | BindingFlags.NonPublic); + valueFactory.SetValue(dependencyBag, null); + + var isValueCreated = (bool)dependencyBag.GetType().GetProperty("IsValueCreated").GetValue(dependencyBag); + Debug.Assert(!isValueCreated); + } + + // Initialize the component manager at the specified or derived root folder. + + var rootFolder = RootFolder; + if (string.IsNullOrEmpty(rootFolder) || !Directory.Exists(rootFolder)) + throw new InvalidOperationException("The RootFolder property has not been set correctly and could not be derived from your development environment."); + + ComponentManager.SetRootFolder(RootFolder); + new ComponentManager(); + bool foo; + ComponentManager.Singleton.InitializePlugInCache(null, new LicensingInitializationReporter(), out foo); + + // Create a temporary profile which contains + // - the APUnitTestFramework plug-in + // - the list of plugins as specified in the AdditionalPlugInGuids property. + // - (nothing else). + // Use a unique profile name so that concurrent independent unit test runs are possible + // without interference. + + var profileName = "APUnitTestFramework_" + DateTime.Now.Ticks; + var profile = ComponentManager.Singleton.ProfileList.CreateProfile(profileName); + profile.SetVersionConstraint(PLUGINGUID_APUNITTESTFRAMEWORK, new NewestVersionConstraint()); + foreach (var plugInGuid in AdditionalPlugInGuids) + profile.SetVersionConstraint(plugInGuid, new NewestVersionConstraint()); + ComponentManager.Singleton.SetActiveProfile(profileName, profile); + + // Check whether all plug-ins are really existing. Otherwise throw an exception. + foreach (var plugInGuid in profile.GetEntries()) + { + var versionConstraint = profile.GetVersionConstraint(plugInGuid); + var allVersions = ComponentManager.Singleton.PlugInCache.GetPlugInVersionsFast(plugInGuid); + var version = versionConstraint.FindVersionFast(allVersions); + if (version == null) + throw new PlugInDoesNotExistException(new PlugInName(plugInGuid, new Version("0.0.0.0"))); + } + + var implementationProperty = typeof(ComponentModel).GetProperty("Implementation", BindingFlags.Instance | BindingFlags.NonPublic); + var componentModelImpl = (IComponentModelImplementationForUnitTest)implementationProperty.GetValue(ComponentModel.Singleton); + componentModelImpl.SetupForUnitTest(this, profileName, profile); + ComponentManager.Singleton.CreateSystemInstances(null); + } + + public void Dispose() + { + if (!_initializeCalled) + return; // No temporary profile has been set yet, so there is nothing to clean up. + + for (var i = 0; i < 10; i++) + { + try + { + var profile = ComponentManager.Singleton.ActiveProfileName; + ComponentManager.Singleton.ProfileList.DeleteProfile(profile); + break; + } + catch + { + Thread.Sleep(500); + } + } + } + + /// <summary> + /// This event is triggered for all dependencies that could not be resolved. While + /// developing a unit test, subscribing this event can be useful to recognize all + /// dependencies that need a mock. + /// </summary> + public event EventHandler<DependencyErrorEventArgs> DependencyError; + + /// <summary> + /// For internal use. + /// </summary> + /// <param name="exception"></param> + /// <param name="requiredInterfaceType"></param> + /// <param name="specificTypeGuid"></param> + public void RaiseDependencyError(ComponentModelException exception, Type requiredInterfaceType, Guid? specificTypeGuid) + { + DependencyError?.Invoke(this, new DependencyErrorEventArgs(exception, requiredInterfaceType, specificTypeGuid)); + } + + private string _rootFolder; + private HashSet<Guid> _additionalPlugInGuids; + private Dictionary<Guid, MockConstructor> _mocks; + private bool _initializeCalled; + private static readonly Guid PLUGINGUID_APUNITTESTFRAMEWORK = new Guid("{DFAA33F9-68D4-4BFB-B9AF-2EAD069DD7CA}"); + + class LicensingInitializationReporter : ILicensingInitializationReporter2 + { + public ReporterUsage Usage { get; set; } = ReporterUsage.StartUp; + + public bool CheckLicense(PlugInInformation plugInInfo) + { + // Licensed plug-ins with valid license -> OK + // Licensed plug-ins without valid license -> No check. Probably the test will + // crash afterwards due to the missing decryption. + return true; + } + + public bool ReportMissingLicenses(IEnumerable<PlugInInformation> plugInInfos) + { + return false; + } + } + } +}