Diff of /Interfaces/+APUnitTestFramework/Testbed.cs [000000] .. [5a4bff]  Maximize  Restore

Switch to unified view

a b/Interfaces/+APUnitTestFramework/Testbed.cs
1
using _3S.CoDeSys.Core.ComponentModel;
2
using _3S.CoDeSys.Core.Components;
3
using _3S.CoDeSys.Core.Licensing;
4
using System;
5
using System.Collections.Generic;
6
using System.Collections.ObjectModel;
7
using System.ComponentModel;
8
using System.Diagnostics;
9
using System.IO;
10
using System.Linq;
11
using System.Reflection;
12
using System.Runtime.Serialization.Json;
13
using System.Threading;
14
using System.Xml;
15
using System.Xml.Linq;
16
using System.Xml.XPath;
17
18
namespace _3S.APUnitTestFramework
19
{
20
    /// <summary>
21
    /// Use this class to create a single unit test environment. It can be considered being a
22
    /// lightweight Automation Platform process containing the testee plug-in and mocks for all
23
    /// dependencies.
24
    /// </summary>
25
    /// <remarks>
26
    /// Note that the testbed will only work properly if all involved dependencies are handled
27
    /// using our Dependency Injection mechanisms, and if the (FxCop-enforced)
28
    /// APEnvironment-DependencyBag implementation pattern is used.
29
    /// </remarks>
30
    public class Testbed : IDisposable
31
    {
32
        /// <summary>
33
        /// Creates a new testbed.
34
        /// </summary>
35
        public Testbed()
36
        {
37
        }
38
39
        /// <summary>
40
        /// Gets or sets the root folder of the Automation Platform installation. If this property
41
        /// is not explicitly set, or explicitly set to <c>null</c>, then the root folder will be
42
        /// automatically determined as described in the Remarks section.
43
        /// </summary>
44
        /// <exception cref="InvalidOperationException">
45
        /// The value of this property is set after <see cref="Initialize"/> has been called.
46
        /// </exception>
47
        /// <remarks>
48
        /// There are two heuristics how to find out the root folder of the Automation Platform
49
        /// installation, applied in that order given:
50
        /// <list type="bullet">
51
        /// <item>
52
        /// If the unit test assembly is within an Apaddon directory structure, then the default
53
        /// target in the corresponding <c>AddOn.json</c> file will be used as root folder of the
54
        /// test environment.
55
        /// </item>
56
        /// <item>
57
        /// If the unit test assembly is not within an Apaddon directory structure, or the
58
        /// <c>AddOn.json</c> file could not be found or evaluated, then the root folder as
59
        /// specified in the <c>%CODESYS_OUTPUTDIR%</c> or <c>%CODESYS_64_OUTPUTDIR%</c> environment variable will be used
60
        /// depending on <c>%CODESYS_PLATFORM%</c>. This
61
        /// scenario should work in all scenarios where unit tests are developed for standard
62
        /// CODESYS plug-ins.
63
        /// </item>
64
        /// </list>
65
        /// </remarks>
66
        public string RootFolder
67
        {
68
            get
69
            {
70
                if (_rootFolder == null)
71
                {
72
                    _rootFolder = DeriveRootFolderFromApaddon();
73
                    if (string.IsNullOrEmpty(_rootFolder) || !Directory.Exists(_rootFolder))
74
                        _rootFolder = DeriveRootFolderFromStandard();
75
                    if (string.IsNullOrEmpty(_rootFolder) || !Directory.Exists(_rootFolder))
76
                        _rootFolder = string.Empty;
77
                }
78
                return _rootFolder;
79
            }
80
81
            set
82
            {
83
                if (_initializeCalled)
84
                    throw new InvalidOperationException("Attempt to set the RootFolder property after calling Initialize()");
85
86
                _rootFolder = value;
87
            }
88
        }
89
90
        private static string DeriveRootFolderFromApaddon()
91
        {
92
            try
93
            {
94
                var thisAssemblyDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
95
                var targetsDirectory = Path.Combine(thisAssemblyDirectory, @"..\..\..\..\..\Targets");
96
                var targetsFile = Path.Combine(targetsDirectory, @".addon");
97
98
                // NewtonSoft.Json would be a nice alternative, but unfortunately we are not
99
                // allowed to reference it from an interface assembly.
100
                // However, .NET has got built-in support.
101
                var jsonData = File.ReadAllBytes(targetsFile);
102
                var jsonReader = JsonReaderWriterFactory.CreateJsonReader(jsonData, new XmlDictionaryReaderQuotas());
103
                var jsonRoot = XElement.Load(jsonReader);
104
                var nameElement = jsonRoot.XPathSelectElement("//active/name");
105
                var name = nameElement.Value;
106
107
                var targetDirectory = Path.Combine(targetsDirectory, name);
108
                return targetDirectory;
109
            }
110
            catch
111
            {
112
                return null;
113
            }
114
        }
115
 
116
        private static string DeriveRootFolderFromStandard()
117
        {
118
            if (Environment.GetEnvironmentVariable("CODESYS_PLATFORM") != null && 
119
                (Environment.GetEnvironmentVariable("CODESYS_PLATFORM").Equals("x64", StringComparison.CurrentCultureIgnoreCase) ||
120
                Environment.GetEnvironmentVariable("CODESYS_PLATFORM").Equals("BOTH", StringComparison.CurrentCultureIgnoreCase)))
121
            {
122
                return Environment.ExpandEnvironmentVariables(Environment.GetEnvironmentVariable("CODESYS_64_OUTPUTDIR"));
123
            }
124
            else
125
            {
126
                return Environment.ExpandEnvironmentVariables(Environment.GetEnvironmentVariable("CODESYS_OUTPUTDIR"));
127
            }
128
129
        }
130
131
        /// <summary>
132
        /// Call this method in order to add a plug-in to the test environment.
133
        /// </summary>
134
        /// <param name="plugInGuid">
135
        /// The GUID of the plug-in to be added to the test environment. The newest version will be
136
        /// used.
137
        /// </param>
138
        /// <exception cref="InvalidOperationException">
139
        /// This method is called after <see cref="Initialize"/> has been called.
140
        /// </exception>
141
        /// <remarks>
142
        /// One should use this possibility with care. When adding "real" plug-ins to the test
143
        /// environment, the idea of a "pure" unit test will be compromised, where every dependency
144
        /// should be a mock with an exactly specified behavior.
145
        /// Furthermore, please do not add the <c>Engine.plugin</c> to the environment. The reason
146
        /// is that two Component Model implementations would then be part of the environment,
147
        /// causing the Component Manager to throw an exception during initialization.
148
        /// </remarks>
149
        public void IncludeAdditionalPlugInGuid(Guid plugInGuid)
150
        {
151
            if (_initializeCalled)
152
                throw new InvalidOperationException("Attempt to include additional plug-ins after calling Initialize()");
153
154
            if (_additionalPlugInGuids == null)
155
                _additionalPlugInGuids = new HashSet<Guid>();
156
            _additionalPlugInGuids.Add(plugInGuid);
157
        }
158
159
        /// <summary>
160
        /// Gets the plug-in GUIDs of the additional plug-ins that have been added via <see cref=
161
        /// "IncludeAdditionalPlugInGuid(Guid)"/>.
162
        /// </summary>
163
        public IEnumerable<Guid> AdditionalPlugInGuids
164
        {
165
            get
166
            {
167
                if (_additionalPlugInGuids != null)
168
                    return _additionalPlugInGuids;
169
                else
170
                    return new Guid[0];
171
            }
172
        }
173
174
        /// <summary>
175
        /// Adds a mock to the test environment. This is the preferred way to fulfill any
176
        /// dependency of the testee.
177
        /// <seealso cref="AddMock(Guid, Func{object}, bool)"/>
178
        /// <seealso cref="AddMock(Guid, Func{object}, Func{object}, bool)"/>
179
        /// </summary>
180
        /// <param name="typeGuid">
181
        /// The type GUID of the mock type. If the testee's dependency is described by an <see
182
        /// cref="InjectSpecificInstanceAttribute"/> or an <see cref=
183
        /// "InjectSpecificTypeInformationAttribute"/>, then the specified GUID must match the
184
        /// requested one, otherwise a new GUID can be created.
185
        /// </param>
186
        /// <param name="createFunc">
187
        /// A delegate which creates the mock instance on request.
188
        /// </param>
189
        /// <exception cref="InvalidOperationException">
190
        /// This method is called after <see cref="Initialize"/> has been called.
191
        /// </exception>
192
        /// <exception cref="ArgumentNullException">
193
        /// <paramref name="createFunc"/> is <c>null</c>.
194
        /// </exception>
195
        /// <exception cref="ArgumentException">
196
        /// This method is called multiple times with the same <paramref name="typeGuid"/> value.
197
        /// </exception>
198
        public void AddMock(Guid typeGuid, Func<object> createFunc)
199
        {
200
            AddMock(typeGuid, createFunc, createFunc, false);
201
        }
202
203
        /// <summary>
204
        /// Adds a mock to the test environment. This is the preferred way to fulfill any
205
        /// dependency of the testee.
206
        /// <seealso cref="AddMock(Guid, Func{object})"/>
207
        /// <seealso cref="AddMock(Guid, Func{object}, Func{object}, bool)"/>
208
        /// </summary>
209
        /// <param name="typeGuid">
210
        /// The type GUID of the mock type. If the testee's dependency is described by an <see
211
        /// cref="InjectSpecificInstanceAttribute"/> or an <see cref=
212
        /// "InjectSpecificTypeInformationAttribute"/>, then the specified GUID must match the
213
        /// requested one, otherwise a new GUID can be created.
214
        /// </param>
215
        /// <param name="createFunc">
216
        /// A delegate which creates the mock instance on request.
217
        /// </param>
218
        /// <param name="systemInstance">
219
        /// This mock is a system instance, i.e. it will be a global singleton.
220
        /// </param>
221
        /// <exception cref="InvalidOperationException">
222
        /// This method is called after <see cref="Initialize"/> has been called.
223
        /// </exception>
224
        /// <exception cref="ArgumentNullException">
225
        /// <paramref name="createFunc"/> is <c>null</c>.
226
        /// </exception>
227
        /// <exception cref="ArgumentException">
228
        /// This method is called multiple times with the same <paramref name="typeGuid"/> value.
229
        /// </exception>
230
        public void AddMock(Guid typeGuid, Func<object> createFunc, bool systemInstance)
231
        {
232
            AddMock(typeGuid, createFunc, createFunc, systemInstance);
233
        }
234
235
        /// <summary>
236
        /// Adds a mock to the test environment. This is the preferred way to fulfill any
237
        /// dependency of the testee.
238
        /// <seealso cref="AddMock(Guid, Func{object})"/>
239
        /// <seealso cref="AddMock(Guid, Func{object}, bool)"/>
240
        /// </summary>
241
        /// <param name="typeGuid">
242
        /// The type GUID of the mock type. If the testee's dependency is described by an <see
243
        /// cref="InjectSpecificInstanceAttribute"/> or an <see cref=
244
        /// "InjectSpecificTypeInformationAttribute"/>, then the specified GUID must match the
245
        /// requested one, otherwise a new GUID can be created.
246
        /// </param>
247
        /// <param name="createFunc">
248
        /// A delegate which creates the mock instance on request.
249
        /// </param>
250
        /// <param name="createPrototypeFunc">
251
        /// The testbed will create prototypes for the mock instance during initialization. If
252
        /// there is a different logic for prototype instances compared to "real" instances, then
253
        /// a dedicated creation function can be specified using this parameter. (For example, a
254
        /// prototype instance should not attach itself to events, whereas a "real" instance might
255
        /// do it.) The other overloads of this method use the same creation function for both
256
        /// construction methods.
257
        /// </param>
258
        /// <param name="systemInstance">
259
        /// This mock is a system instance, i.e. it will be a global singleton.
260
        /// </param>
261
        /// <exception cref="InvalidOperationException">
262
        /// This method is called after <see cref="Initialize"/> has been called.
263
        /// </exception>
264
        /// <exception cref="ArgumentNullException">
265
        /// <paramref name="createFunc"/> is <c>null</c>.
266
        /// </exception>
267
        /// <exception cref="ArgumentException">
268
        /// This method is called multiple times with the same <paramref name="typeGuid"/> value.
269
        /// </exception>
270
        public void AddMock(Guid typeGuid, Func<object> createFunc, Func<object> createPrototypeFunc, bool systemInstance)
271
        {
272
            if (_initializeCalled)
273
                throw new InvalidOperationException("Attempt to add mock after calling Initialize()");
274
275
            if (_mocks == null)
276
                _mocks = new Dictionary<Guid, MockConstructor>();
277
278
            var mockConstructor = new MockConstructor(createFunc, createPrototypeFunc, systemInstance);
279
            _mocks.Add(typeGuid, mockConstructor);
280
        }
281
282
        /// <summary>
283
        /// Gets a read-only dictionary of all mock constructors that have been added via <see
284
        /// cref="AddMock(Guid, MockConstructor)"/>.
285
        /// </summary>
286
        public IDictionary<Guid, MockConstructor> Mocks
287
        {
288
            get
289
            {
290
                if (_mocks != null)
291
                    return new ReadOnlyDictionary<Guid, MockConstructor>(_mocks);
292
                else
293
                    return new ReadOnlyDictionary<Guid, MockConstructor>(new Dictionary<Guid, MockConstructor>());
294
            }
295
        }
296
297
        /// <summary>
298
        /// Initializes the test environment, after the caller has performed all necessary
299
        /// preparations using <see cref="RootFolder"/>, <see cref="IncludeAdditionalPlugInGuid
300
        /// (Guid)"/>, and <see cref="AddMock(Guid, MockConstructor)"/>.
301
        /// </summary>
302
        /// <exception cref="InvalidOperationException">
303
        /// The <see cref="RootFolder"/> property returns an invalid or non-existing directory.
304
        /// </exception>
305
        /// <exception cref="PlugInDoesNotExistException">
306
        /// Either the <c>APUnitTestFramework.plugin</c> or one of the plug-ins denoted by <see
307
        /// cref="AdditionalPlugInGuids"/> is not installed.
308
        /// </exception>
309
        /// <exception cref="Exception">
310
        /// This method rethrows all exceptions that are thrown by the standard <see cref=
311
        /// "ComponentManager"/> implementation.
312
        /// </exception>
313
        /// <remarks>
314
        /// This initialization routine comprises the following steps:
315
        /// <list type="bullet">
316
        /// <item>
317
        /// Resetting the <c>ComponentManager</c> singleton.
318
        /// </item>
319
        /// <item>
320
        /// Resetting the <c>ComponentModel</c> singleton.
321
        /// </item>
322
        /// <item>
323
        /// Scanning all loaded assemblies for <c>APEnvironment</c> classes and resetting their
324
        /// lazily initialized bag field (type <c>Lazy&lt;DependencyBag&gt;</c>). This is the
325
        /// reason why the testbed only works reliably for plug-ins which conform to the
326
        /// APEnvironment-DependencyBag implementation pattern, as enforced by our internal FxCop
327
        /// coding rules.
328
        /// </item>
329
        /// <item>
330
        /// Setting the root folder as specified by the <see cref="RootFolder"/> property.
331
        /// </item>
332
        /// <item>
333
        /// Creating a temporary profile which only contains the <c>APUnitTestFramework.plugin</c>
334
        /// and the plug-ins as specified via the <see cref="IncludeAdditionalPlugInGuid(Guid)"/>
335
        /// method.
336
        /// </item>
337
        /// <item>
338
        /// Creating the system instances.
339
        /// </item>
340
        /// </list>
341
        /// </remarks>
342
        public void Initialize()
343
        {
344
            _initializeCalled = true;
345
346
            // Reset all static things.
347
348
            // Unregister component Manager from AppDomain events
349
           var fiAssemblyLoad = AppDomain.CurrentDomain.GetType().GetField("AssemblyLoad", BindingFlags.Instance | BindingFlags.NonPublic);
350
            if (fiAssemblyLoad != null)
351
            {
352
                AssemblyLoadEventHandler eventHandler = fiAssemblyLoad.GetValue(AppDomain.CurrentDomain) as AssemblyLoadEventHandler;
353
                if (eventHandler != null)
354
                {
355
                    AppDomain.CurrentDomain.AssemblyLoad -= eventHandler;
356
                }
357
            }
358
            var fiAssemblyResolve = AppDomain.CurrentDomain.GetType().GetField("_AssemblyResolve", BindingFlags.Instance | BindingFlags.NonPublic);
359
            if (fiAssemblyResolve != null)
360
            {
361
                ResolveEventHandler eventHandler = fiAssemblyResolve.GetValue(AppDomain.CurrentDomain) as ResolveEventHandler;
362
                if (eventHandler != null)
363
                {
364
                    AppDomain.CurrentDomain.AssemblyResolve -= eventHandler;
365
                }
366
            }
367
368
            // The private s_singleton field is obfuscated! As a hack, discover all fields for
369
            // that one which is of type ComponentManager. Ugh!
370
            var fieldInfo = typeof(ComponentManager).GetFields(BindingFlags.Static | BindingFlags.NonPublic)
371
                .Single(fi => typeof(ComponentManager).IsAssignableFrom(fi.FieldType));
372
            fieldInfo.SetValue(null, null);
373
374
            // The private s_singleton field is obfuscated! As a hack, discover all fields for
375
            // that one which is of type ComponentModel. Doubled ugh!
376
            fieldInfo = typeof(ComponentModel).GetFields(BindingFlags.Static | BindingFlags.NonPublic)
377
                .Single(fi => typeof(ComponentModel).IsAssignableFrom(fi.FieldType));
378
            fieldInfo.SetValue(null, null);
379
380
            // Finally, we must reset all the dependency bags that are around. Obviously, this is
381
            // only possible by reflection.
382
            // Note: We are heavily tweaking the Lazy<T> object's internal members. A different
383
            // idea would be to create new Lazy<T> objects with the original value factories.
384
            // However, it turns out to be impossible because the value factory is internally
385
            // replaced by something else in the Lazy<T> implementation
386
            // ("ALREADY_INVOKED_SENTINEL") so that it becomes impossible for us to get hold of the
387
            // original value factory as specified in the constructor.
388
            var alreadyInitializedLazyDependencyBags = AppDomain.CurrentDomain.GetAssemblies()
389
                .Where(asm => PlugInGuidAttribute.FromAssembly(asm) != null)
390
                .SelectMany(asm => asm.GetTypes())
391
                .Where(type => type.Name == "APEnvironment")
392
                .SelectMany(type => type.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
393
                .Where(field => field.FieldType.IsGenericType && typeof(Lazy<>).IsAssignableFrom(field.FieldType.GetGenericTypeDefinition()))
394
                .Select(field => field.GetValue(null));
395
            foreach (var dependencyBag in alreadyInitializedLazyDependencyBags)
396
            {
397
                var boxed = dependencyBag.GetType().GetField("m_boxed", BindingFlags.Instance | BindingFlags.NonPublic);
398
                boxed.SetValue(dependencyBag, null);
399
400
                var valueFactory = dependencyBag.GetType().GetField("m_valueFactory", BindingFlags.Instance | BindingFlags.NonPublic);
401
                valueFactory.SetValue(dependencyBag, null);
402
403
                var isValueCreated = (bool)dependencyBag.GetType().GetProperty("IsValueCreated").GetValue(dependencyBag);
404
                Debug.Assert(!isValueCreated);
405
            }
406
407
            // Initialize the component manager at the specified or derived root folder.
408
409
            var rootFolder = RootFolder;
410
            if (string.IsNullOrEmpty(rootFolder) || !Directory.Exists(rootFolder))
411
                throw new InvalidOperationException("The RootFolder property has not been set correctly and could not be derived from your development environment.");
412
413
            ComponentManager.SetRootFolder(RootFolder);
414
            new ComponentManager();
415
            bool foo;
416
            ComponentManager.Singleton.InitializePlugInCache(null, new LicensingInitializationReporter(), out foo);
417
418
            // Create a temporary profile which contains
419
            // - the APUnitTestFramework plug-in
420
            // - the list of plugins as specified in the AdditionalPlugInGuids property.
421
            // - (nothing else).
422
            // Use a unique profile name so that concurrent independent unit test runs are possible
423
            // without interference.
424
425
            var profileName = "APUnitTestFramework_" + DateTime.Now.Ticks;
426
            var profile = ComponentManager.Singleton.ProfileList.CreateProfile(profileName);
427
            profile.SetVersionConstraint(PLUGINGUID_APUNITTESTFRAMEWORK, new NewestVersionConstraint());
428
            foreach (var plugInGuid in AdditionalPlugInGuids)
429
                profile.SetVersionConstraint(plugInGuid, new NewestVersionConstraint());
430
            ComponentManager.Singleton.SetActiveProfile(profileName, profile);
431
432
            // Check whether all plug-ins are really existing. Otherwise throw an exception.
433
            foreach (var plugInGuid in profile.GetEntries())
434
            {
435
                var versionConstraint = profile.GetVersionConstraint(plugInGuid);
436
                var allVersions = ComponentManager.Singleton.PlugInCache.GetPlugInVersionsFast(plugInGuid);
437
                var version = versionConstraint.FindVersionFast(allVersions);
438
                if (version == null)
439
                    throw new PlugInDoesNotExistException(new PlugInName(plugInGuid, new Version("0.0.0.0")));
440
            }
441
442
            var implementationProperty = typeof(ComponentModel).GetProperty("Implementation", BindingFlags.Instance | BindingFlags.NonPublic);
443
            var componentModelImpl = (IComponentModelImplementationForUnitTest)implementationProperty.GetValue(ComponentModel.Singleton);
444
            componentModelImpl.SetupForUnitTest(this, profileName, profile);
445
            ComponentManager.Singleton.CreateSystemInstances(null);
446
        }
447
448
        public void Dispose()
449
        {
450
            if (!_initializeCalled)
451
                return; // No temporary profile has been set yet, so there is nothing to clean up.
452
453
            for (var i = 0; i < 10; i++)
454
            {
455
                try
456
                {
457
                    var profile = ComponentManager.Singleton.ActiveProfileName;
458
                    ComponentManager.Singleton.ProfileList.DeleteProfile(profile);
459
                    break;
460
                }
461
                catch
462
                {
463
                    Thread.Sleep(500);
464
                }
465
            }
466
        }
467
468
        /// <summary>
469
        /// This event is triggered for all dependencies that could not be resolved. While
470
        /// developing a unit test, subscribing this event can be useful to recognize all
471
        /// dependencies that need a mock.
472
        /// </summary>
473
        public event EventHandler<DependencyErrorEventArgs> DependencyError;
474
475
        /// <summary>
476
        /// For internal use.
477
        /// </summary>
478
        /// <param name="exception"></param>
479
        /// <param name="requiredInterfaceType"></param>
480
        /// <param name="specificTypeGuid"></param>
481
        public void RaiseDependencyError(ComponentModelException exception, Type requiredInterfaceType, Guid? specificTypeGuid)
482
        {
483
            DependencyError?.Invoke(this, new DependencyErrorEventArgs(exception, requiredInterfaceType, specificTypeGuid));
484
        }
485
486
        private string _rootFolder;
487
        private HashSet<Guid> _additionalPlugInGuids;
488
        private Dictionary<Guid, MockConstructor> _mocks;
489
        private bool _initializeCalled;
490
        private static readonly Guid PLUGINGUID_APUNITTESTFRAMEWORK = new Guid("{DFAA33F9-68D4-4BFB-B9AF-2EAD069DD7CA}");
491
492
        class LicensingInitializationReporter : ILicensingInitializationReporter2
493
        {
494
            public ReporterUsage Usage { get; set; } = ReporterUsage.StartUp;
495
496
            public bool CheckLicense(PlugInInformation plugInInfo)
497
            {
498
                // Licensed plug-ins with valid license -> OK
499
                // Licensed plug-ins without valid license -> No check. Probably the test will
500
                // crash afterwards due to the missing decryption.
501
                return true;
502
            }
503
504
            public bool ReportMissingLicenses(IEnumerable<PlugInInformation> plugInInfos)
505
            {
506
                return false;
507
            }
508
        }
509
    }
510
}