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