diff --git a/Directory.Packages.props b/Directory.Packages.props index 6d46db5..f68c60d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,19 +7,23 @@ + + + + @@ -33,6 +37,5 @@ - \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/ActivityNotifier.cs b/tests/Aspire.CommunityToolkit.Testing/ActivityNotifier.cs new file mode 100644 index 0000000..9a459c1 --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/ActivityNotifier.cs @@ -0,0 +1,58 @@ +// Copied from https://github.com/dotnet/aspire/blob/b51d08a617a60ae30f8305d98f1e34e1ed90da1a/tests/Aspire.Components.Common.Tests/ActivityNotifier.cs + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using OpenTelemetry; + +namespace Aspire.Components.Common.Tests; + +/// +/// An OpenTelemetry processor that can notify callers when it has processed an Activity. +/// +public sealed class ActivityNotifier : BaseProcessor +{ + private readonly Channel _activityChannel = Channel.CreateUnbounded(); + + public async Task> TakeAsync(int count, TimeSpan timeout) + { + var activityList = new List(); + using var cts = new CancellationTokenSource(timeout); + await foreach (var activity in WaitAsync(cts.Token)) + { + activityList.Add(activity); + if (activityList.Count == count) + { + break; + } + } + + return activityList; + } + + public override void OnEnd(Activity data) + { + _activityChannel.Writer.TryWrite(data); + } + + private async IAsyncEnumerable WaitAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var activity in _activityChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return activity; + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _activityChannel.Writer.TryComplete(); + } + + base.Dispose(disposing); + } +} \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/Aspire.CommunityToolkit.Testing.csproj b/tests/Aspire.CommunityToolkit.Testing/Aspire.CommunityToolkit.Testing.csproj index 54f9e48..b1e3bc3 100644 --- a/tests/Aspire.CommunityToolkit.Testing/Aspire.CommunityToolkit.Testing.csproj +++ b/tests/Aspire.CommunityToolkit.Testing/Aspire.CommunityToolkit.Testing.csproj @@ -15,6 +15,11 @@ and avoids problems with `dotnet pack`. See https://xunit.github.io/docs/nuget-packages and the special note in https://xunit.github.io/releases/2.3. --> + + + + + diff --git a/tests/Aspire.CommunityToolkit.Testing/ConformanceTests.cs b/tests/Aspire.CommunityToolkit.Testing/ConformanceTests.cs new file mode 100644 index 0000000..d3a0985 --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/ConformanceTests.cs @@ -0,0 +1,560 @@ +// Copied from https://github.com/dotnet/aspire/blob/b51d08a617a60ae30f8305d98f1e34e1ed90da1a/tests/Aspire.Components.Common.Tests/ConformanceTests.cs +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Json.Schema; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using Microsoft.TestUtilities; +using Xunit.Sdk; + +namespace Aspire.Components.ConformanceTests; + +public abstract class ConformanceTests + where TService : class + where TOptions : class, new() +{ + protected static readonly EvaluationOptions DefaultEvaluationOptions = new() { RequireFormatValidation = true, OutputFormat = OutputFormat.List }; + + protected abstract ServiceLifetime ServiceLifetime { get; } + + protected abstract string ActivitySourceName { get; } + + protected string JsonSchemaPath => Path.Combine(AppContext.BaseDirectory, "ConfigurationSchema.json"); + + protected virtual string ValidJsonConfig { get; } = string.Empty; + + protected virtual (string json, string error)[] InvalidJsonToErrorMessage => Array.Empty<(string json, string error)>(); + + protected abstract string[] RequiredLogCategories { get; } + + protected virtual string[] NotAcceptableLogCategories => Array.Empty(); + + protected virtual bool CanCreateClientWithoutConnectingToServer => true; + + protected virtual bool CanConnectToServer => false; + + protected virtual bool SupportsNamedConfig => true; + protected virtual string? ConfigurationSectionName => null; + + protected virtual bool SupportsKeyedRegistrations => false; + + protected bool MetricsAreSupported => CheckIfImplemented(SetMetrics); + + // every Component has to support health checks, this property is a temporary workaround + protected bool HealthChecksAreSupported => CheckIfImplemented(SetHealthCheck); + + protected virtual void DisableRetries(TOptions options) { } + + protected bool TracingIsSupported => CheckIfImplemented(SetTracing); + + /// + /// Calls the actual Component + /// + protected abstract void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null); + + /// + /// Populates the Configuration with everything that is required by the Component + /// + /// + protected abstract void PopulateConfiguration(ConfigurationManager configuration, string? key = null); + + /// + /// Do anything that is going to trigger the and creation. Example: try to create a DB. + /// + protected abstract void TriggerActivity(TService service); + + /// + /// Sets the health checks to given value + /// + protected abstract void SetHealthCheck(TOptions options, bool enabled); + + /// + /// Sets the tracing to given value + /// + protected abstract void SetTracing(TOptions options, bool enabled); + + /// + /// Sets the metrics to given value + /// + protected abstract void SetMetrics(TOptions options, bool enabled); + + [ConditionalFact] + public void OptionsTypeIsSealed() + { + if (typeof(TOptions) == typeof(object)) + { + throw SkipException.ForSkip("Not implemented yet"); + } + + Assert.True(typeof(TOptions).IsSealed); + } + + [ConditionalTheory] + [InlineData(true)] + [InlineData(false)] + public void HealthChecksRegistersHealthCheckService(bool enabled) + { + SkipIfHealthChecksAreNotSupported(); + + using IHost host = CreateHostWithComponent(options => SetHealthCheck(options, enabled)); + + HealthCheckService? healthCheckService = host.Services.GetService(); + + Assert.Equal(enabled, healthCheckService is not null); + } + + [ConditionalFact] + public async Task EachKeyedComponentRegistersItsOwnHealthCheck() + { + SkipIfHealthChecksAreNotSupported(); + SkipIfKeyedRegistrationIsNotSupported(); + + const string key1 = "key1", key2 = "key2"; + + using IHost host = CreateHostWithMultipleKeyedComponents(key1, key2); + + HealthCheckService healthCheckService = host.Services.GetRequiredService(); + + List registeredNames = new(); + await healthCheckService.CheckHealthAsync(healthCheckRegistration => + { + registeredNames.Add(healthCheckRegistration.Name); + return false; + }).ConfigureAwait(false); + + Assert.Equal(2, registeredNames.Count); + Assert.All(registeredNames, name => Assert.True(name.Contains(key1) || name.Contains(key2), $"{name} did not contain the key.")); + } + + [ConditionalTheory] + [InlineData(true)] + [InlineData(false)] + public void TracingRegistersTraceProvider(bool enabled) + { + SkipIfTracingIsNotSupported(); + SkipIfRequiredServerConnectionCanNotBeEstablished(); + + using IHost host = CreateHostWithComponent(options => SetTracing(options, enabled)); + + TracerProvider? tracer = host.Services.GetService(); + + Assert.Equal(enabled, tracer is not null); + } + + [ConditionalTheory] + [InlineData(true)] + [InlineData(false)] + public void MetricsRegistersMeterProvider(bool enabled) + { + SkipIfMetricsAreNotSupported(); + + using IHost host = CreateHostWithComponent(options => SetMetrics(options, enabled)); + + MeterProvider? meter = host.Services.GetService(); + + Assert.Equal(enabled, meter is not null); + } + + [ConditionalTheory] + [InlineData(true)] + [InlineData(false)] + public void ServiceLifetimeIsAsExpected(bool useKey) + { + SkipIfRequiredServerConnectionCanNotBeEstablished(); + SkipIfKeyedRegistrationIsNotSupported(useKey); + + TService? serviceFromFirstScope, serviceFromSecondScope, secondServiceFromSecondScope; + string? key = useKey ? "key" : null; + + using IHost host = CreateHostWithComponent(key: key); + + using (IServiceScope scope1 = host.Services.CreateScope()) + { + serviceFromFirstScope = Resolve(scope1.ServiceProvider, key); + } + + using (IServiceScope scope2 = host.Services.CreateScope()) + { + serviceFromSecondScope = Resolve(scope2.ServiceProvider, key); + + secondServiceFromSecondScope = Resolve(scope2.ServiceProvider, key); + } + + Assert.NotNull(serviceFromFirstScope); + Assert.NotNull(serviceFromSecondScope); + Assert.NotNull(secondServiceFromSecondScope); + + switch (ServiceLifetime) + { + case ServiceLifetime.Singleton: + Assert.Same(serviceFromFirstScope, serviceFromSecondScope); + Assert.Same(serviceFromSecondScope, secondServiceFromSecondScope); + break; + case ServiceLifetime.Scoped: + Assert.NotSame(serviceFromFirstScope, serviceFromSecondScope); + Assert.Same(serviceFromSecondScope, secondServiceFromSecondScope); + break; + case ServiceLifetime.Transient: + Assert.NotSame(serviceFromFirstScope, serviceFromSecondScope); + Assert.NotSame(serviceFromSecondScope, secondServiceFromSecondScope); + break; + } + + static TService? Resolve(IServiceProvider serviceProvider, string? key) + => string.IsNullOrEmpty(key) + ? serviceProvider.GetService() + : serviceProvider.GetKeyedService(key); + } + + [ConditionalFact] + public void CanRegisterMultipleInstancesUsingDifferentKeys() + { + SkipIfKeyedRegistrationIsNotSupported(); + SkipIfRequiredServerConnectionCanNotBeEstablished(); + + const string key1 = "key1", key2 = "key2"; + + using IHost host = CreateHostWithMultipleKeyedComponents(key1, key2); + + TService serviceForKey1 = host.Services.GetRequiredKeyedService(key1); + TService serviceForKey2 = host.Services.GetRequiredKeyedService(key2); + + Assert.NotSame(serviceForKey1, serviceForKey2); + } + + [ConditionalFact] + public void WhenKeyedRegistrationIsUsedThenItsImpossibleToResolveWithoutKey() + { + SkipIfKeyedRegistrationIsNotSupported(); + SkipIfRequiredServerConnectionCanNotBeEstablished(); + + const string key = "key"; + + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(settings: null); + + PopulateConfiguration(builder.Configuration, key); + RegisterComponent(builder, key: key); + + using IHost host = builder.Build(); + + Assert.NotNull(host.Services.GetKeyedService(key)); + Assert.Null(host.Services.GetService()); + Assert.Throws(host.Services.GetRequiredService); + } + + [ConditionalTheory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void LoggerFactoryIsUsedByRegisteredClient(bool registerAfterLoggerFactory, bool useKey) + { + SkipIfRequiredServerConnectionCanNotBeEstablished(); + SkipIfKeyedRegistrationIsNotSupported(useKey); + + string? key = useKey ? "key" : null; + HostApplicationBuilder builder = CreateHostBuilder(key: key); + + if (registerAfterLoggerFactory) + { + builder.Services.AddSingleton(); + RegisterComponent(builder, key: key); + } + else + { + // the Component should be lazily created when it's requested for the first time! + RegisterComponent(builder, key: key); + builder.Services.AddSingleton(); + } + + using IHost host = builder.Build(); + + TService service = key is null + ? host.Services.GetRequiredService() + : host.Services.GetRequiredKeyedService(key); + TestLoggerFactory loggerFactory = (TestLoggerFactory)host.Services.GetRequiredService(); + + try + { + TriggerActivity(service); + } + catch (Exception) { } + + foreach (string logCategory in RequiredLogCategories) + { + Assert.Contains(logCategory, loggerFactory.Categories); + } + + foreach (string logCategory in NotAcceptableLogCategories) + { + Assert.DoesNotContain(logCategory, loggerFactory.Categories); + } + } + + [ConditionalTheory] + [InlineData(null)] + [InlineData("key")] + public async Task HealthCheckReportsExpectedStatus(string? key) + { + SkipIfHealthChecksAreNotSupported(); + + // DisableRetries so the test doesn't take so long retrying when the server isn't available. + using IHost host = CreateHostWithComponent(configureComponent: DisableRetries, key: key); + + HealthCheckService healthCheckService = host.Services.GetRequiredService(); + + HealthReport healthReport = await healthCheckService.CheckHealthAsync().ConfigureAwait(false); + + HealthStatus expected = CanConnectToServer ? HealthStatus.Healthy : HealthStatus.Unhealthy; + + Assert.Equal(expected, healthReport.Status); + Assert.NotEmpty(healthReport.Entries); + Assert.Contains(healthReport.Entries, entry => entry.Value.Status == expected); + } + + [Fact] + public void ConfigurationSchemaValidJsonConfigTest() + { + var schema = JsonSchema.FromFile(JsonSchemaPath); + var config = JsonNode.Parse(ValidJsonConfig); + + var results = schema.Evaluate(config); + + Assert.True(results.IsValid); + } + + [Fact] + public void ConfigurationSchemaInvalidJsonConfigTest() + { + var schema = JsonSchema.FromFile(JsonSchemaPath); + + foreach ((string json, string error) in InvalidJsonToErrorMessage) + { + var config = JsonNode.Parse(json); + var results = schema.Evaluate(config, DefaultEvaluationOptions); + var detail = results.Details.FirstOrDefault(x => x.HasErrors); + + Assert.NotNull(detail); + Assert.Equal(error, detail.Errors!.First().Value); + } + } + + /// + /// Ensures that when the connection information is missing, an exception isn't thrown before the host + /// is built, so any exception can be logged with ILogger. + /// + [ConditionalTheory] + [InlineData(true)] + [InlineData(false)] + public void ConnectionInformationIsDelayValidated(bool useKey) + { + SetupConnectionInformationIsDelayValidated(); + + var builder = Host.CreateEmptyApplicationBuilder(null); + + string? key = useKey ? "key" : null; + RegisterComponent(builder, key: key); + + using var host = builder.Build(); + + Assert.Throws(() => + key is null + ? host.Services.GetRequiredService() + : host.Services.GetRequiredKeyedService(key)); + } + + [ConditionalFact] + public void FavorsNamedConfigurationOverTopLevelConfigurationWhenBothProvided_DisableTracing() + { + SkipIfNamedConfigNotSupported(); + SkipIfTracingIsNotSupported(); + + var key = "target-service"; + var builder = Host.CreateEmptyApplicationBuilder(null); + + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair($"{ConfigurationSectionName}:DisableTracing", "false"), + new KeyValuePair($"{ConfigurationSectionName}:{key}:DisableTracing", "true"), + ]); + + RegisterComponent(builder, key: key); + + using var host = builder.Build(); + + // Trace provider is not configured because DisableTracing is set to true in the named configuration + Assert.Null(host.Services.GetService()); + } + + [ConditionalFact] + public void FavorsNamedConfigurationOverTopLevelConfigurationWhenBothProvided_DisableHealthChecks() + { + SkipIfNamedConfigNotSupported(); + SkipIfHealthChecksAreNotSupported(); + + var key = "target-service"; + var builder = Host.CreateEmptyApplicationBuilder(null); + + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair($"{ConfigurationSectionName}:DisableHealthChecks", "false"), + new KeyValuePair($"{ConfigurationSectionName}:{key}:DisableHealthChecks", "true"), + ]); + + RegisterComponent(builder, key: key); + + using var host = builder.Build(); + + // HealthChecksService is not configured because DisableHealthChecks is set to true in the named configuration + Assert.Null(host.Services.GetService()); + } + + protected virtual void SetupConnectionInformationIsDelayValidated() { } + + // This method can have side effects (setting AppContext switch, enabling activity source by name). + // That is why it needs to be executed in a standalone process. + // We use RemoteExecutor for that, but it does not support abstract classes + // (it can not determine the type to instantiate), so that is why this "test" + // is here and derived types call it + protected void ActivitySourceTest(string? key) + { + HostApplicationBuilder builder = CreateHostBuilder(key: key); + RegisterComponent(builder, options => SetTracing(options, true), key); + + List exportedActivities = new(); + builder.Services.AddOpenTelemetry().WithTracing(builder => builder.AddInMemoryExporter(exportedActivities)); + + using (IHost host = builder.Build()) + { + // We start the host to make it build TracerProvider. + // If we don't, nothing gets reported! + host.Start(); + + TService service = key is null + ? host.Services.GetRequiredService() + : host.Services.GetRequiredKeyedService(key); + + Assert.Empty(exportedActivities); + + try + { + TriggerActivity(service); + } + catch (Exception) when (!CanConnectToServer) + { + } + + Assert.NotEmpty(exportedActivities); + Assert.Contains(exportedActivities, activity => activity.Source.Name == ActivitySourceName); + } + } + + protected IHost CreateHostWithComponent(Action? configureComponent = null, HostApplicationBuilderSettings? hostSettings = null, string? key = null) + { + HostApplicationBuilder builder = CreateHostBuilder(hostSettings, key); + + RegisterComponent(builder, configureComponent, key); + + return builder.Build(); + } + + protected IHost CreateHostWithMultipleKeyedComponents(params string[] keys) + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(settings: null); + + foreach (var key in keys) + { + PopulateConfiguration(builder.Configuration, key); + RegisterComponent(builder, key: key); + } + + return builder.Build(); + } + + protected void SkipIfHealthChecksAreNotSupported() + { + if (!HealthChecksAreSupported) + { + throw SkipException.ForSkip("Health checks aren't supported."); + } + } + + protected void SkipIfKeyedRegistrationIsNotSupported(bool useKey = true) + { + if (useKey && !SupportsKeyedRegistrations) + { + throw SkipException.ForSkip("Does not support Keyed Services"); + } + } + + protected void SkipIfTracingIsNotSupported() + { + if (!TracingIsSupported) + { + throw SkipException.ForSkip("Tracing is not supported."); + } + } + + protected void SkipIfMetricsAreNotSupported() + { + if (!MetricsAreSupported) + { + throw SkipException.ForSkip("Metrics are not supported."); + } + } + + protected void SkipIfRequiredServerConnectionCanNotBeEstablished() + { + if (!CanCreateClientWithoutConnectingToServer && !CanConnectToServer) + { + throw SkipException.ForSkip("Unable to connect to the server."); + } + } + + protected void SkipIfCanNotConnectToServer() + { + if (!CanConnectToServer) + { + throw SkipException.ForSkip("Unable to connect to the server."); + } + } + + protected void SkipIfNamedConfigNotSupported() + { + if (!SupportsNamedConfig || ConfigurationSectionName is null) + { + throw SkipException.ForSkip("Named configuration is not supported."); + } + } + + public static string CreateConfigKey(string prefix, string? key, string suffix) + => string.IsNullOrEmpty(key) ? $"{prefix}:{suffix}" : $"{prefix}:{key}:{suffix}"; + + protected HostApplicationBuilder CreateHostBuilder(HostApplicationBuilderSettings? hostSettings = null, string? key = null) + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(hostSettings); + + PopulateConfiguration(builder.Configuration, key); + + return builder; + } + + private static bool CheckIfImplemented(Action action) + { + try + { + action(new TOptions(), true); + + return true; + } + catch (NotImplementedException) + { + return false; + } + } +} \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/TestLoggerFactory.cs b/tests/Aspire.CommunityToolkit.Testing/TestLoggerFactory.cs new file mode 100644 index 0000000..287d2b7 --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/TestLoggerFactory.cs @@ -0,0 +1,26 @@ +// Copied from https://github.com/dotnet/aspire/blob/b51d08a617a60ae30f8305d98f1e34e1ed90da1a/tests/Aspire.Components.Common.Tests/TestLoggerFactory.cs +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Components.ConformanceTests; + +public sealed class TestLoggerFactory : ILoggerFactory +{ + public List LoggerProviders { get; } = new(); + public ConcurrentBag Categories { get; } = new(); + + public void AddProvider(ILoggerProvider provider) => LoggerProviders.Add(provider); + + public ILogger CreateLogger(string categoryName) + { + Categories.Add(categoryName); + return NullLogger.Instance; + } + + public void Dispose() { } +} \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/Xunit/ConditionalFactAttribute.cs b/tests/Aspire.CommunityToolkit.Testing/Xunit/ConditionalFactAttribute.cs new file mode 100644 index 0000000..a3beb25 --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/Xunit/ConditionalFactAttribute.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Borrowed from https://github.com/dotnet/aspnetcore/blob/95ed45c67/src/Testing/src/xunit/ + +using System; +using Xunit; +using Xunit.Sdk; + +namespace Microsoft.TestUtilities; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +[XunitTestCaseDiscoverer("Microsoft.TestUtilities." + nameof(ConditionalFactDiscoverer), "Aspire.CommunityToolkit.Testing")] +public class ConditionalFactAttribute : FactAttribute +{ +} \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/Xunit/ConditionalFactDiscoverer.cs b/tests/Aspire.CommunityToolkit.Testing/Xunit/ConditionalFactDiscoverer.cs new file mode 100644 index 0000000..bbfdbe3 --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/Xunit/ConditionalFactDiscoverer.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Borrowed from https://github.com/dotnet/aspnetcore/blob/95ed45c67/src/Testing/src/xunit/ + +using Xunit.Abstractions; +using Xunit.Sdk; + +// Do not change this namespace without changing the usage in ConditionalFactAttribute +namespace Microsoft.TestUtilities; + +internal sealed class ConditionalFactDiscoverer : FactDiscoverer +{ + private readonly IMessageSink _diagnosticMessageSink; + + public ConditionalFactDiscoverer(IMessageSink diagnosticMessageSink) + : base(diagnosticMessageSink) + { + _diagnosticMessageSink = diagnosticMessageSink; + } + + protected override IXunitTestCase CreateTestCase(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) + { + var skipReason = testMethod.EvaluateSkipConditions(); + return skipReason != null + ? new SkippedTestCase(skipReason, _diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), TestMethodDisplayOptions.None, testMethod) + : base.CreateTestCase(discoveryOptions, testMethod, factAttribute); + } +} \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/Xunit/ConditionalTheoryAttribute.cs b/tests/Aspire.CommunityToolkit.Testing/Xunit/ConditionalTheoryAttribute.cs new file mode 100644 index 0000000..1f77da8 --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/Xunit/ConditionalTheoryAttribute.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Borrowed from https://github.com/dotnet/aspnetcore/blob/95ed45c67/src/Testing/src/xunit/ + +using System; +using Xunit; +using Xunit.Sdk; + +namespace Microsoft.TestUtilities; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +[XunitTestCaseDiscoverer("Microsoft.TestUtilities." + nameof(ConditionalTheoryDiscoverer), "Aspire.CommunityToolkit.Testing")] +public class ConditionalTheoryAttribute : TheoryAttribute +{ +} \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/Xunit/ConditionalTheoryDiscoverer.cs b/tests/Aspire.CommunityToolkit.Testing/Xunit/ConditionalTheoryDiscoverer.cs new file mode 100644 index 0000000..d7a3c10 --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/Xunit/ConditionalTheoryDiscoverer.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Borrowed from https://github.com/dotnet/aspnetcore/blob/95ed45c67/src/Testing/src/xunit/ + +using System; +using System.Collections.Generic; +using Xunit.Abstractions; +using Xunit.Sdk; + +// Do not change this namespace without changing the usage in ConditionalTheoryAttribute +namespace Microsoft.TestUtilities; + +internal sealed class ConditionalTheoryDiscoverer : TheoryDiscoverer +{ + public ConditionalTheoryDiscoverer(IMessageSink diagnosticMessageSink) + : base(diagnosticMessageSink) + { + } + + private sealed class OptionsWithPreEnumerationEnabled : ITestFrameworkDiscoveryOptions + { + private const string PreEnumerateTheories = "xunit.discovery.PreEnumerateTheories"; + + private readonly ITestFrameworkDiscoveryOptions _original; + + public OptionsWithPreEnumerationEnabled(ITestFrameworkDiscoveryOptions original) + { + _original = original; + } + + public TValue GetValue(string name) + => (name == PreEnumerateTheories) ? (TValue)(object)true : _original.GetValue(name); + + public void SetValue(string name, TValue value) + => _original.SetValue(name, value); + } + + public override IEnumerable Discover(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute) + => base.Discover(new OptionsWithPreEnumerationEnabled(discoveryOptions), testMethod, theoryAttribute); + + protected override IEnumerable CreateTestCasesForTheory(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute) + { + var skipReason = testMethod.EvaluateSkipConditions(); + return skipReason != null + ? new[] { new SkippedTestCase(skipReason, DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), TestMethodDisplayOptions.None, testMethod) } + : base.CreateTestCasesForTheory(discoveryOptions, testMethod, theoryAttribute); + } + + protected override IEnumerable CreateTestCasesForDataRow(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute, object[]? dataRow) + { + var skipReason = testMethod.EvaluateSkipConditions(); + if (skipReason == null && dataRow?.Length > 0) + { + var obj = dataRow[0]; + if (obj != null) + { + var type = obj.GetType(); + var property = type.GetProperty("Skip"); + if (property != null && property.PropertyType.Equals(typeof(string))) + { + skipReason = property.GetValue(obj) as string; + } + } + } + + return skipReason != null ? + base.CreateTestCasesForSkippedDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow, skipReason) + : base.CreateTestCasesForDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow); + } + + protected override IEnumerable CreateTestCasesForSkippedDataRow( + ITestFrameworkDiscoveryOptions discoveryOptions, + ITestMethod testMethod, + IAttributeInfo theoryAttribute, + object[] dataRow, + string skipReason) + { + return new[] + { + new WORKAROUND_SkippedDataRowTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod, skipReason, dataRow), + }; + } +} \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/Xunit/ITestCondition.cs b/tests/Aspire.CommunityToolkit.Testing/Xunit/ITestCondition.cs new file mode 100644 index 0000000..8c5ac40 --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/Xunit/ITestCondition.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Borrowed from https://github.com/dotnet/aspnetcore/blob/95ed45c67/src/Testing/src/xunit/ + +namespace Microsoft.TestUtilities; + +public interface ITestCondition +{ + bool IsMet { get; } + + string SkipReason { get; } +} \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/Xunit/OSSkipConditionAttribute.cs b/tests/Aspire.CommunityToolkit.Testing/Xunit/OSSkipConditionAttribute.cs new file mode 100644 index 0000000..425469a --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/Xunit/OSSkipConditionAttribute.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Borrowed from https://github.com/dotnet/aspnetcore/blob/95ed45c67/src/Testing/src/xunit/ + +using System; +#if NETCOREAPP || NET471_OR_GREATER +using System.Runtime.InteropServices; +#endif + +namespace Microsoft.TestUtilities; + +#pragma warning disable CA1019 // Define accessors for attribute arguments +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] +public class OSSkipConditionAttribute : Attribute, ITestCondition +{ + private readonly OperatingSystems _excludedOperatingSystem; + private readonly OperatingSystems _osPlatform; + + public OSSkipConditionAttribute(OperatingSystems operatingSystem) + : this(operatingSystem, GetCurrentOS()) + { + } + + // to enable unit testing + internal OSSkipConditionAttribute(OperatingSystems operatingSystem, OperatingSystems osPlatform) + { + _excludedOperatingSystem = operatingSystem; + _osPlatform = osPlatform; + } + + public bool IsMet + { + get + { + var skip = (_excludedOperatingSystem & _osPlatform) == _osPlatform; + + // Since a test would be executed only if 'IsMet' is true, return false if we want to skip + return !skip; + } + } + + public string SkipReason { get; set; } = "Test cannot run on this operating system."; + + private static OperatingSystems GetCurrentOS() + { +#if NETCOREAPP || NET471_OR_GREATER + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return OperatingSystems.Windows; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return OperatingSystems.Linux; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return OperatingSystems.MacOSX; + } + + throw new PlatformNotSupportedException(); +#else + // RuntimeInformation API is only avaialble in .NET Framework 4.7.1+ + // .NET Framework 4.7 and below can only run on Windows. + return OperatingSystems.Windows; +#endif + } +} +#pragma warning restore CA1019 // Define accessors for attribute arguments \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/Xunit/OperatingSystems.cs b/tests/Aspire.CommunityToolkit.Testing/Xunit/OperatingSystems.cs new file mode 100644 index 0000000..2326022 --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/Xunit/OperatingSystems.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Borrowed from https://github.com/dotnet/aspnetcore/blob/95ed45c67/src/Testing/src/xunit/ + +using System; + +namespace Microsoft.TestUtilities; + +[Flags] +public enum OperatingSystems +{ + Linux = 1, + MacOSX = 2, + Windows = 4, +} \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/Xunit/SkippedTestCase.cs b/tests/Aspire.CommunityToolkit.Testing/Xunit/SkippedTestCase.cs new file mode 100644 index 0000000..3d9ab0a --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/Xunit/SkippedTestCase.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Borrowed from https://github.com/dotnet/aspnetcore/blob/95ed45c67/src/Testing/src/xunit/ + +#nullable disable + +using System; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.TestUtilities; + +public class SkippedTestCase : XunitTestCase +{ + private string _skipReason; + + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public SkippedTestCase() + { + } + + public SkippedTestCase( + string skipReason, + IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + TestMethodDisplayOptions defaultMethodDisplayOptions, + ITestMethod testMethod, + object[] testMethodArguments = null) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments) + { + _skipReason = skipReason; + } + + protected override string GetSkipReason(IAttributeInfo factAttribute) + => _skipReason ?? base.GetSkipReason(factAttribute); + + public override void Deserialize(IXunitSerializationInfo data) + { + _skipReason = data.GetValue(nameof(_skipReason)); + + // We need to call base after reading our value, because Deserialize will call + // into GetSkipReason. + base.Deserialize(data); + } + + public override void Serialize(IXunitSerializationInfo data) + { + base.Serialize(data); + data.AddValue(nameof(_skipReason), _skipReason); + } +} \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/Xunit/TestMethodExtensions.cs b/tests/Aspire.CommunityToolkit.Testing/Xunit/TestMethodExtensions.cs new file mode 100644 index 0000000..4c5466b --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/Xunit/TestMethodExtensions.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Borrowed from https://github.com/dotnet/aspnetcore/blob/95ed45c67/src/Testing/src/xunit/ + +using System.Linq; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.TestUtilities; + +public static class TestMethodExtensions +{ + public static string? EvaluateSkipConditions(this ITestMethod testMethod) + { + var testClass = testMethod.TestClass.Class; + var assembly = testMethod.TestClass.TestCollection.TestAssembly.Assembly; + var conditionAttributes = testMethod.Method + .GetCustomAttributes(typeof(ITestCondition)) + .Concat(testClass.GetCustomAttributes(typeof(ITestCondition))) + .Concat(assembly.GetCustomAttributes(typeof(ITestCondition))) + .OfType() + .Select(attributeInfo => attributeInfo.Attribute); + + foreach (ITestCondition condition in conditionAttributes.OfType()) + { + if (!condition.IsMet) + { + return condition.SkipReason; + } + } + + return null; + } +} \ No newline at end of file diff --git a/tests/Aspire.CommunityToolkit.Testing/Xunit/WORKAROUND_SkippedDataRowTestCase.cs b/tests/Aspire.CommunityToolkit.Testing/Xunit/WORKAROUND_SkippedDataRowTestCase.cs new file mode 100644 index 0000000..ccbfa36 --- /dev/null +++ b/tests/Aspire.CommunityToolkit.Testing/Xunit/WORKAROUND_SkippedDataRowTestCase.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Borrowed from https://github.com/dotnet/aspnetcore/blob/95ed45c67/src/Testing/src/xunit/ + +using System; +using System.ComponentModel; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.TestUtilities; + +// This is a workaround for https://github.com/xunit/xunit/issues/1782 - as such, this code is a copy-paste +// from xUnit with the exception of fixing the bug. +// +// This will only work with [ConditionalTheory]. +internal sealed class WORKAROUND_SkippedDataRowTestCase : XunitTestCase +{ + private string? _skipReason; + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public WORKAROUND_SkippedDataRowTestCase() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message sink used to send diagnostic messages. + /// Default method display to use (when not customized). + /// The test method this test case belongs to. + /// The reason that this test case will be skipped. + /// The arguments for the test method. + [Obsolete("Please call the constructor which takes TestMethodDisplayOptions")] + public WORKAROUND_SkippedDataRowTestCase(IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + ITestMethod testMethod, + string skipReason, + object[]? testMethodArguments = null) + : this(diagnosticMessageSink, defaultMethodDisplay, TestMethodDisplayOptions.None, testMethod, skipReason, testMethodArguments) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message sink used to send diagnostic messages. + /// Default method display to use (when not customized). + /// Default method display options to use (when not customized). + /// The test method this test case belongs to. + /// The reason that this test case will be skipped. + /// The arguments for the test method. + public WORKAROUND_SkippedDataRowTestCase(IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + TestMethodDisplayOptions defaultMethodDisplayOptions, + ITestMethod testMethod, + string skipReason, + object[]? testMethodArguments = null) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments) + { + _skipReason = skipReason; + } + + /// + public override void Deserialize(IXunitSerializationInfo data) + { + // SkipReason has to be read before we call base.Deserialize, this is the workaround. + _skipReason = data.GetValue("SkipReason"); + + base.Deserialize(data); + } + + /// + protected override string? GetSkipReason(IAttributeInfo factAttribute) + { + return _skipReason; + } + + /// + public override void Serialize(IXunitSerializationInfo data) + { + base.Serialize(data); + + data.AddValue("SkipReason", _skipReason); + } +} \ No newline at end of file