Module.Apply() enumerated _features.Values directly while calling feature.Apply(). A feature whose Apply() introduces another feature — Module.Configure<T>() directly, or via a helper such as AddActivity<T>() which configures WorkflowManagementFeature — mutated that collection mid-enumeration and threw "Collection was modified; enumeration operation may not execute", naming nothing about features. Whether it fired depended on whether the other feature happened to be installed already, so a module built or did not based on unrelated host config. The module already treats introduction-during-apply as supported: the ConfigureFeature loop iterates a snapshot for exactly this reason, and Configure<T>() has an _isApplying branch that creates, resolves and configures a feature introduced mid-Apply. Only the final apply loop missed the same treatment, so make it tolerant rather than diagnose a constraint the code does not hold. The apply loop now runs in rounds until no new features appear, each round topologically sorted so a late feature's dependencies apply before it. Hosted services are registered in a single pass after that loop, then moved back to the index the block previously occupied: registering late is needed so features contributed during Apply() are included and ordered by priority, while keeping the position matters because features register hosted services directly from Apply() — WorkflowRuntimeFeature adds DrainOrchestratorHostedService that way — and module-managed services must keep starting first, or a priority such as ActivateTenants at -1 would silently start ordering after them. Adds Elsa.Features.UnitTests, covering the introduced feature applying, a three-deep introduction chain, dependency ordering, hosted service registration and priority ordering for late arrivals, the installed- feature registry, and no double-apply, plus guards for pre-existing ordering behaviour. Closes #7944 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
7.6 KiB
C#
241 lines
7.6 KiB
C#
using Elsa.Features.Abstractions;
|
|
using Elsa.Features.Attributes;
|
|
using Elsa.Features.Contracts;
|
|
using Elsa.Features.Implementations;
|
|
using Elsa.Features.Services;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
|
|
namespace Elsa.Features.UnitTests;
|
|
|
|
/// <summary>
|
|
/// Tests <see cref="Module.Apply"/>, with an emphasis on features that introduce additional features from their own <see cref="IFeature.Apply"/> method.
|
|
/// </summary>
|
|
public class ModuleTests
|
|
{
|
|
private const string AppliedFeaturesKey = "AppliedFeatures";
|
|
|
|
private readonly ServiceCollection _services = new();
|
|
private readonly Module _module;
|
|
private readonly List<Type> _appliedFeatures = new();
|
|
|
|
public ModuleTests()
|
|
{
|
|
_module = new(_services);
|
|
_module.Properties[AppliedFeaturesKey] = _appliedFeatures;
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_AppliesFeatureIntroducedFromApply()
|
|
{
|
|
_module.Configure<IntroducingFeature>();
|
|
|
|
_module.Apply();
|
|
|
|
Assert.Contains(typeof(IntroducedFeature), _appliedFeatures);
|
|
Assert.Contains(_services, x => x.ServiceType == typeof(IntroducedMarker));
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_AppliesEntireChainOfFeaturesIntroducedFromApply()
|
|
{
|
|
_module.Configure<ChainIntroducingFeature>();
|
|
|
|
_module.Apply();
|
|
|
|
Assert.Contains(typeof(ChainMiddleFeature), _appliedFeatures);
|
|
Assert.Contains(typeof(ChainLeafFeature), _appliedFeatures);
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_AppliesDependenciesOfFeatureIntroducedFromApplyBeforeThatFeature()
|
|
{
|
|
_module.Configure<IntroducingDependentFeature>();
|
|
|
|
_module.Apply();
|
|
|
|
Assert.Contains(typeof(IntroducedDependencyFeature), _appliedFeatures);
|
|
Assert.True(_appliedFeatures.IndexOf(typeof(IntroducedDependencyFeature)) < _appliedFeatures.IndexOf(typeof(IntroducedDependentFeature)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_RegistersHostedServicesOfFeatureIntroducedFromApply()
|
|
{
|
|
_module.Configure<IntroducingFeature>();
|
|
|
|
_module.Apply();
|
|
|
|
Assert.Contains(_services, x => x.ServiceType == typeof(IHostedService) && x.ImplementationType == typeof(IntroducedHostedService));
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_ListsFeatureIntroducedFromApplyInTheInstalledFeatureRegistry()
|
|
{
|
|
_module.Configure<IntroducingFeature>();
|
|
|
|
_module.Apply();
|
|
|
|
Assert.NotNull(GetInstalledFeatureRegistry().Find("Elsa.Introduced"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_AppliesEachFeatureOnlyOnce()
|
|
{
|
|
_module.Configure<IntroducingFeature>();
|
|
|
|
_module.Apply();
|
|
|
|
Assert.Equal(_appliedFeatures.Distinct().Count(), _appliedFeatures.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_AppliesFeaturesInDependencyOrder()
|
|
{
|
|
_module.Configure<DependentFeature>();
|
|
|
|
_module.Apply();
|
|
|
|
Assert.Equal([typeof(DependencyFeature), typeof(DependentFeature)], _appliedFeatures);
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_RegistersHostedServicesInPriorityOrder()
|
|
{
|
|
_module.ConfigureHostedService<SecondHostedService>(2);
|
|
_module.ConfigureHostedService<FirstHostedService>(1);
|
|
_module.Configure<IntroducingFeature>();
|
|
|
|
_module.Apply();
|
|
|
|
Assert.Equal([typeof(FirstHostedService), typeof(SecondHostedService), typeof(IntroducedHostedService)], GetHostedServiceTypes());
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_OrdersHostedServiceOfFeatureIntroducedFromApplyByPriority()
|
|
{
|
|
_module.ConfigureHostedService<SecondHostedService>(10);
|
|
_module.Configure<IntroducingFeature>();
|
|
|
|
_module.Apply();
|
|
|
|
// The introduced feature configures its hosted service at priority 3, so it has to come first even though it shows up last.
|
|
Assert.Equal([typeof(IntroducedHostedService), typeof(SecondHostedService)], GetHostedServiceTypes());
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_RegistersConfiguredHostedServicesBeforeThoseRegisteredFromApply()
|
|
{
|
|
_module.ConfigureHostedService<FirstHostedService>();
|
|
_module.Configure<HostedServiceRegisteringFeature>();
|
|
|
|
_module.Apply();
|
|
|
|
Assert.Equal([typeof(FirstHostedService), typeof(SelfRegisteredHostedService)], GetHostedServiceTypes());
|
|
}
|
|
|
|
private List<Type?> GetHostedServiceTypes()
|
|
{
|
|
return _services.Where(x => x.ServiceType == typeof(IHostedService)).Select(x => x.ImplementationType).ToList();
|
|
}
|
|
|
|
private IInstalledFeatureRegistry GetInstalledFeatureRegistry()
|
|
{
|
|
return (IInstalledFeatureRegistry)_services.Single(x => x.ServiceType == typeof(IInstalledFeatureRegistry)).ImplementationInstance!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records the order in which features are applied so that tests can assert on it.
|
|
/// </summary>
|
|
public abstract class RecordingFeature(IModule module) : FeatureBase(module)
|
|
{
|
|
public override void Apply() => ((List<Type>)Module.Properties[AppliedFeaturesKey]).Add(GetType());
|
|
}
|
|
|
|
public class IntroducingFeature(IModule module) : RecordingFeature(module)
|
|
{
|
|
public override void Apply()
|
|
{
|
|
base.Apply();
|
|
Module.Configure<IntroducedFeature>();
|
|
}
|
|
}
|
|
|
|
public class IntroducedFeature(IModule module) : RecordingFeature(module)
|
|
{
|
|
public override void ConfigureHostedServices() => ConfigureHostedService<IntroducedHostedService>(3);
|
|
|
|
public override void Apply()
|
|
{
|
|
base.Apply();
|
|
Services.AddSingleton<IntroducedMarker>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers a hosted service straight with the service collection, the way <c>WorkflowRuntimeFeature</c> does, rather than through the module.
|
|
/// </summary>
|
|
public class HostedServiceRegisteringFeature(IModule module) : RecordingFeature(module)
|
|
{
|
|
public override void Apply()
|
|
{
|
|
base.Apply();
|
|
Services.AddHostedService<SelfRegisteredHostedService>();
|
|
}
|
|
}
|
|
|
|
public class ChainIntroducingFeature(IModule module) : RecordingFeature(module)
|
|
{
|
|
public override void Apply()
|
|
{
|
|
base.Apply();
|
|
Module.Configure<ChainMiddleFeature>();
|
|
}
|
|
}
|
|
|
|
public class ChainMiddleFeature(IModule module) : RecordingFeature(module)
|
|
{
|
|
public override void Apply()
|
|
{
|
|
base.Apply();
|
|
Module.Configure<ChainLeafFeature>();
|
|
}
|
|
}
|
|
|
|
public class ChainLeafFeature(IModule module) : RecordingFeature(module);
|
|
|
|
public class IntroducingDependentFeature(IModule module) : RecordingFeature(module)
|
|
{
|
|
public override void Apply()
|
|
{
|
|
base.Apply();
|
|
Module.Configure<IntroducedDependentFeature>();
|
|
}
|
|
}
|
|
|
|
[DependsOn(typeof(IntroducedDependencyFeature))]
|
|
public class IntroducedDependentFeature(IModule module) : RecordingFeature(module);
|
|
|
|
public class IntroducedDependencyFeature(IModule module) : RecordingFeature(module);
|
|
|
|
[DependsOn(typeof(DependencyFeature))]
|
|
public class DependentFeature(IModule module) : RecordingFeature(module);
|
|
|
|
public class DependencyFeature(IModule module) : RecordingFeature(module);
|
|
|
|
public class IntroducedMarker;
|
|
|
|
public abstract class NoopHostedService : IHostedService
|
|
{
|
|
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
}
|
|
|
|
public class IntroducedHostedService : NoopHostedService;
|
|
|
|
public class SelfRegisteredHostedService : NoopHostedService;
|
|
|
|
public class FirstHostedService : NoopHostedService;
|
|
|
|
public class SecondHostedService : NoopHostedService;
|
|
}
|