diff --git a/Directory.Packages.props b/Directory.Packages.props
index b65989fce0..1ad216e6e2 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -37,6 +37,7 @@
+
@@ -83,6 +84,7 @@
+
diff --git a/ModularPipelines.sln b/ModularPipelines.sln
index 2102ff7c26..4cf893ebe2 100644
--- a/ModularPipelines.sln
+++ b/ModularPipelines.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.6.33815.320
+# Visual Studio Version 18
+VisualStudioVersion = 18.5.11723.231 stable
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ModularPipelines", "src\ModularPipelines\ModularPipelines.csproj", "{A25FAFCF-E226-4263-B3D6-732668604BD9}"
EndProject
@@ -125,6 +125,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModularPipelines.Syft", "sr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModularPipelines.Grype", "src\ModularPipelines.Grype\ModularPipelines.Grype.csproj", "{60E4E82D-7BBF-4513-80ED-36A2273BB97D}"
EndProject
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ModularPipelines.UnitTests.FSharp", "test\ModularPipelines.UnitTests.FSharp\ModularPipelines.UnitTests.FSharp.fsproj", "{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -807,6 +809,18 @@ Global
{60E4E82D-7BBF-4513-80ED-36A2273BB97D}.Release|x64.Build.0 = Release|Any CPU
{60E4E82D-7BBF-4513-80ED-36A2273BB97D}.Release|x86.ActiveCfg = Release|Any CPU
{60E4E82D-7BBF-4513-80ED-36A2273BB97D}.Release|x86.Build.0 = Release|Any CPU
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Debug|x64.Build.0 = Debug|Any CPU
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Debug|x86.Build.0 = Debug|Any CPU
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Release|x64.ActiveCfg = Release|Any CPU
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Release|x64.Build.0 = Release|Any CPU
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Release|x86.ActiveCfg = Release|Any CPU
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -868,6 +882,7 @@ Global
{0FB125FE-5AB3-4667-8D1B-85A6284474ED} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{2E70AA19-0309-4C6F-83D2-8E3DD2A7EC89} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{60E4E82D-7BBF-4513-80ED-36A2273BB97D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {7383D287-8BC9-4214-90A2-9B7CA4C4CBE6} = {F213898F-1E32-48F1-AB8C-83D2BD01A93B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A5905A5D-B4E1-4A7A-9279-0283D86A9F7F}
diff --git a/src/ModularPipelines/ModularPipelines.csproj b/src/ModularPipelines/ModularPipelines.csproj
index 0b60f8cc34..880b95368d 100644
--- a/src/ModularPipelines/ModularPipelines.csproj
+++ b/src/ModularPipelines/ModularPipelines.csproj
@@ -40,6 +40,9 @@
<_Parameter1>ModularPipelines.UnitTests
+
+ <_Parameter1>ModularPipelines.UnitTests.FSharp
+
<_Parameter1>ModularPipelines.TestHelpers
diff --git a/test/ModularPipelines.UnitTests.FSharp/Api/FlexibleDependencyApiExportTests.fs b/test/ModularPipelines.UnitTests.FSharp/Api/FlexibleDependencyApiExportTests.fs
new file mode 100644
index 0000000000..071fce88d1
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Api/FlexibleDependencyApiExportTests.fs
@@ -0,0 +1,238 @@
+namespace ModularPipelines.UnitTests.FSharp.Api
+
+open System
+open ModularPipelines.Attributes
+open ModularPipelines.Context
+open ModularPipelines.DependencyInjection
+open ModularPipelines.Modules
+open TUnit.Core
+open TUnit.Assertions.FSharp.Operations
+open TUnit.Assertions.Extensions
+open TUnit.Assertions
+
+///
+/// Verifies that all public types from the flexible dependency API are accessible from their expected namespaces. This
+/// ensures the API surface is correctly exported and consumable by library users.
+///
+type FlexibleDependencyApiExportTests() =
+ []
+ member _.IDependencyContext_IsAccessibleFromContextNamespace() =
+ async {
+ let dependencyContextType = typeof
+
+ do! check(
+ StringEqualsAssertionExtensions.IsEqualTo(
+ Assert.That(dependencyContextType.Namespace),
+ "ModularPipelines.Context"
+ )
+ )
+
+ do! check(Assert.That(dependencyContextType.IsPublic).IsTrue())
+ do! check(Assert.That(dependencyContextType.IsInterface).IsTrue())
+ }
+ []
+ member _.DependsOnBaseAttribute_IsAccessibleFromAttributesNamespace() = async {
+ // Verify DependsOnBaseAttribute is in ModularPipelines.Attributes namespace
+ let dependsOnBaseAttributeType = typeof
+ do! check(
+ StringEqualsAssertionExtensions.IsEqualTo(
+ Assert.That(dependsOnBaseAttributeType.Namespace),
+ "ModularPipelines.Attributes"
+ )
+ )
+ do! check(Assert.That(dependsOnBaseAttributeType.IsPublic).IsTrue())
+ do! check(Assert.That(dependsOnBaseAttributeType.IsAbstract).IsTrue())
+ do! check(Assert.That(dependsOnBaseAttributeType.IsSubclassOf(typeof)).IsTrue())
+ }
+
+ []
+ member _.ModuleTagAttribute_IsAccessibleFromAttributesNamespace() = async {
+ // Verify ModuleTagAttribute is in ModularPipelines.Attributes namespace
+ let moduleTagAttributeType = typeof
+ do! check(
+ StringEqualsAssertionExtensions.IsEqualTo(
+ Assert.That(moduleTagAttributeType.Namespace),
+ "ModularPipelines.Attributes"
+ )
+ )
+ do! check(Assert.That(moduleTagAttributeType.IsPublic).IsTrue())
+ do! check(Assert.That(moduleTagAttributeType.IsSealed).IsTrue())
+ do! check(Assert.That(moduleTagAttributeType.IsSubclassOf(typeof)).IsTrue())
+ }
+
+ []
+ member _.ModuleCategoryAttribute_IsAccessibleFromAttributesNamespace() = async {
+ // Verify ModuleCategoryAttribute is in ModularPipelines.Attributes namespace
+ let moduleCategoryAttributeType = typeof
+ do! check(
+ StringEqualsAssertionExtensions.IsEqualTo(
+ Assert.That(moduleCategoryAttributeType.Namespace),
+ "ModularPipelines.Attributes"
+ )
+ )
+ do! check(Assert.That(moduleCategoryAttributeType.IsPublic).IsTrue())
+ do! check(Assert.That(moduleCategoryAttributeType.IsSealed).IsTrue())
+ do! check(Assert.That(moduleCategoryAttributeType.IsSubclassOf(typeof)).IsTrue())
+ }
+
+ []
+ member _.DependsOnModulesWithTagAttribute_IsAccessibleFromAttributesNamespace() = async {
+ // Verify DependsOnModulesWithTagAttribute is in ModularPipelines.Attributes namespace
+ let dependsOnModulesWithTagAttributeType = typeof
+
+ do! check(
+ StringEqualsAssertionExtensions.IsEqualTo(
+ Assert.That(dependsOnModulesWithTagAttributeType.Namespace),
+ "ModularPipelines.Attributes"
+ )
+ )
+ do! check(Assert.That(dependsOnModulesWithTagAttributeType.IsPublic).IsTrue())
+ do! check(Assert.That(dependsOnModulesWithTagAttributeType.IsSealed).IsTrue())
+ do! check(Assert.That(dependsOnModulesWithTagAttributeType.IsSubclassOf(typeof)).IsTrue())
+ }
+
+ []
+ member _.DependsOnModulesInCategoryAttribute_IsAccessibleFromAttributesNamespace() = async {
+ // Verify DependsOnModulesInCategoryAttribute is in ModularPipelines.Attributes namespace
+ let dependsOnModulesInCategoryAttributeType = typeof
+
+ do! check(
+ StringEqualsAssertionExtensions.IsEqualTo(
+ Assert.That(dependsOnModulesInCategoryAttributeType.Namespace),
+ "ModularPipelines.Attributes"
+ )
+ )
+ do! check(Assert.That(dependsOnModulesInCategoryAttributeType.IsPublic).IsTrue())
+ do! check(Assert.That(dependsOnModulesInCategoryAttributeType.IsSealed).IsTrue())
+ do! check(Assert.That(dependsOnModulesInCategoryAttributeType.IsSubclassOf(typeof)).IsTrue())
+ }
+
+ []
+ member _.DependsOnModulesWithAttributeAttribute_IsAccessibleFromAttributesNamespace() = async {
+ // Verify DependsOnModulesWithAttributeAttribute is in ModularPipelines.Attributes namespace
+ let dependsOnModulesWithAttributeAttributeType = typedefof>
+
+ do! check(
+ StringEqualsAssertionExtensions.IsEqualTo(
+ Assert.That(dependsOnModulesWithAttributeAttributeType.Namespace),
+ "ModularPipelines.Attributes"
+ )
+ )
+ do! check(Assert.That(dependsOnModulesWithAttributeAttributeType.IsPublic).IsTrue())
+ do! check(Assert.That(dependsOnModulesWithAttributeAttributeType.IsGenericTypeDefinition).IsTrue())
+
+ let closedType = typeof>
+ do! check(Assert.That(closedType.IsSubclassOf(typeof)).IsTrue())
+ }
+
+ []
+ member _.ITaggedModule_IsAccessibleFromModulesNamespace() = async {
+ // Verify ITaggedModule is in ModularPipelines.Modules namespace
+ let taggedModuleType = typeof
+
+ do! check(
+ StringEqualsAssertionExtensions.IsEqualTo(
+ Assert.That(taggedModuleType.Namespace),
+ "ModularPipelines.Modules"
+ )
+ )
+ do! check(Assert.That(taggedModuleType.IsPublic).IsTrue())
+ do! check(Assert.That(taggedModuleType.IsInterface).IsTrue())
+ }
+
+ []
+ member _.IModuleRegistrationBuilder_IsAccessibleFromDependencyInjectionNamespace() = async {
+ // Verify IModuleRegistrationBuilder is in ModularPipelines.DependencyInjection namespace
+ let moduleRegistrationBuilderType = typeof
+
+ do! check(
+ StringEqualsAssertionExtensions.IsEqualTo(
+ Assert.That(moduleRegistrationBuilderType.Namespace),
+ "ModularPipelines.DependencyInjection"
+ )
+ )
+ do! check(Assert.That(moduleRegistrationBuilderType.IsPublic).IsTrue())
+ do! check(Assert.That(moduleRegistrationBuilderType.IsInterface).IsTrue())
+ }
+
+ []
+ member _.AllFlexibleDependencyAttributes_HaveCorrectAttributeUsage() = async {
+ // Verify all dependency attributes allow multiple usage and inheritance
+ let dependencyAttributes =
+ [|
+ typeof
+ typeof
+ typeof
+ typeof>
+ |]
+
+ for attrType in dependencyAttributes do
+ let usage =
+ attrType.GetCustomAttributes(typeof, false)
+ |> Array.choose (function
+ | :? AttributeUsageAttribute as attributeUsage -> Some attributeUsage
+ | _ -> None)
+ |> Array.tryHead
+
+ do! check(Assert.That(usage.IsSome).IsTrue())
+
+ match usage with
+ | Some attributeUsage ->
+ do! check(Assert.That(attributeUsage.AllowMultiple).IsTrue())
+ do! check(Assert.That(attributeUsage.Inherited).IsTrue())
+ | None -> ()
+ }
+
+ []
+ member _.ModuleCategoryAttribute_DoesNotAllowMultiple() = async {
+ // Verify ModuleCategoryAttribute does NOT allow multiple (only one category per module)
+ let usage =
+ typeof.GetCustomAttributes(typeof, false)
+ |> Array.choose (function
+ | :? AttributeUsageAttribute as attributeUsage -> Some attributeUsage
+ | _ -> None)
+ |> Array.tryHead
+
+ do! check(Assert.That(usage.IsSome).IsTrue())
+
+ match usage with
+ | Some attributeUsage ->
+ do! check(Assert.That(attributeUsage.AllowMultiple).IsFalse())
+ do! check(Assert.That(attributeUsage.Inherited).IsTrue())
+ | None -> ()
+ }
+
+ []
+ member _.IDependencyContext_HasExpectedMethods() = async {
+ // Verify IDependencyContext has all required methods for dependency resolution
+ let dependencyContextType = typeof
+ let methods = dependencyContextType.GetMethods()
+
+ do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "GetTags")).IsTrue())
+ do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "GetCategory")).IsTrue())
+ do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "HasAttribute")).IsTrue())
+ do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "GetAttribute")).IsTrue())
+ do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "GetAttributes")).IsTrue())
+ }
+
+ []
+ member _.ITaggedModule_HasExpectedProperties() = async {
+ // Verify ITaggedModule has all required properties
+ let taggedModuleType = typeof
+ let properties = taggedModuleType.GetProperties()
+
+ do! check(Assert.That(properties |> Array.exists (fun p -> p.Name = "Tags")).IsTrue())
+ do! check(Assert.That(properties |> Array.exists (fun p -> p.Name = "Category")).IsTrue())
+ }
+
+ []
+ member _.IModuleRegistrationBuilder_HasExpectedMembers() = async {
+ // Verify IModuleRegistrationBuilder has all required members
+ let moduleRegistrationBuilderType = typeof
+ let properties = moduleRegistrationBuilderType.GetProperties()
+ let methods = moduleRegistrationBuilderType.GetMethods()
+
+ do! check(Assert.That(properties |> Array.exists (fun p -> p.Name = "Services")).IsTrue())
+ do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "WithTags")).IsTrue())
+ do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "WithCategory")).IsTrue())
+ }
\ No newline at end of file
diff --git a/test/ModularPipelines.UnitTests.FSharp/Attributes/AttributeEventInvokerTests.fs b/test/ModularPipelines.UnitTests.FSharp/Attributes/AttributeEventInvokerTests.fs
new file mode 100644
index 0000000000..6d5cfbf44d
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Attributes/AttributeEventInvokerTests.fs
@@ -0,0 +1,84 @@
+namespace ModularPipelines.UnitTests.FSharp.Attributes
+
+open Microsoft.Extensions.Logging
+open ModularPipelines.Attributes.Events
+open ModularPipelines.Context
+open ModularPipelines.Engine.Attributes
+open Moq
+open System
+open TUnit.Assertions
+open TUnit.Assertions.Extensions
+open TUnit.Assertions.FSharp.Operations
+open TUnit.Core
+
+module AttributeEventInvokerTests =
+ type private SuccessfulHandler() =
+ member val WasCalled = false with get, private set
+ interface IModuleStartHandler with
+ member _.ContinueOnError = false
+ member this.OnModuleStartAsync(_: ModularPipelines.Context.IModuleHookContext): System.Threading.Tasks.Task =
+ task {
+ this.WasCalled <- true
+ }
+
+ type private FailingHandler() =
+ interface IModuleStartHandler with
+ member _.ContinueOnError = false
+ member _.OnModuleStartAsync(_: ModularPipelines.Context.IModuleHookContext): System.Threading.Tasks.Task =
+ raise (InvalidOperationException("Test exception"))
+
+ type private FailingHandlerWithContinue() =
+ interface IModuleStartHandler with
+ member _.ContinueOnError = true
+ member _.OnModuleStartAsync(_: ModularPipelines.Context.IModuleHookContext): System.Threading.Tasks.Task =
+ raise (InvalidOperationException("Test exception"))
+
+ type AttributeEventInvokerTests() =
+ []
+ member _.InvokeAsync_CallsAllHandlers() = async {
+ let handler1 = SuccessfulHandler()
+ let handler2 = SuccessfulHandler()
+ let handlers = [ handler1 :> IModuleStartHandler; handler2 :> IModuleStartHandler ]
+ let invoker = AttributeEventInvoker(Mock.Of>())
+ let context = Mock.Of()
+
+ do! invoker.InvokeStartHandlersAsync(handlers, context) |> Async.AwaitTask
+
+ do! check(Assert.That(handler1.WasCalled).IsTrue())
+ do! check(Assert.That(handler2.WasCalled).IsTrue())
+ }
+
+ []
+ member _.InvokeAsync_HandlerThrows_ContinueOnErrorFalse_Propagates() = async {
+ let handler = FailingHandler()
+ let handlers = [ handler :> IModuleStartHandler ]
+ let invoker = AttributeEventInvoker(Mock.Of>())
+ let context = Mock.Of()
+
+ let mutable thrownException = None
+
+ try
+ do! invoker.InvokeStartHandlersAsync(handlers, context) |> Async.AwaitTask
+ with ex ->
+ thrownException <- Some ex
+
+ do! check(Assert.That(thrownException.IsSome).IsTrue())
+
+ match thrownException with
+ | Some ex ->
+ do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(ex.GetBaseException().Message), "Test exception"))
+ | None -> ()
+ }
+
+ []
+ member _.InvokeAsync_HandlerThrows_ContinueOnErrorTrue_Continues() = async {
+ let failingHandler = FailingHandlerWithContinue()
+ let successHandler = SuccessfulHandler()
+ let handlers = [ failingHandler :> IModuleStartHandler; successHandler :> IModuleStartHandler ]
+ let invoker = AttributeEventInvoker(Mock.Of>())
+ let context = Mock.Of()
+
+ do! invoker.InvokeStartHandlersAsync(handlers, context) |> Async.AwaitTask
+
+ do! check(Assert.That(successHandler.WasCalled).IsTrue())
+ }
\ No newline at end of file
diff --git a/test/ModularPipelines.UnitTests.FSharp/Attributes/CliAttributeTests.fs b/test/ModularPipelines.UnitTests.FSharp/Attributes/CliAttributeTests.fs
new file mode 100644
index 0000000000..ead88e1824
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Attributes/CliAttributeTests.fs
@@ -0,0 +1,288 @@
+namespace ModularPipelines.UnitTests.FSharp.Attributes
+
+open ModularPipelines.Helpers.Internal
+open TUnit.Core
+open ModularPipelines.Attributes
+open TUnit.Assertions
+open TUnit.Assertions.Extensions
+open TUnit.Assertions.FSharp.Operations
+
+[]
+type private TestCliOptionsWithFlag =
+ {
+ []
+ Debug: System.Nullable
+ }
+
+[]
+type private TestCliOptionsWithOption =
+ {
+ []
+ Namespace: string
+ }
+
+[]
+type private TestCliOptionsWithEqualsSeparator =
+ {
+ []
+ Set: string
+ }
+
+[]
+type private TestCliOptionsWithMultipleValues =
+ {
+ []
+ Values: string array
+ }
+
+[]
+type private TestCliOptionsWithArgumentAfterOptions =
+ {
+ []
+ ReleaseName: string
+
+ []
+ Debug: System.Nullable
+ }
+
+[]
+type private TestCliOptionsWithArgumentBeforeOptions =
+ {
+ []
+ Path: string
+
+ []
+ Debug: System.Nullable
+ }
+
+[]
+type private TestCliOptionsWithOptionalArgument =
+ {
+ []
+ ReleaseName: string
+
+ []
+ Debug: System.Nullable
+ }
+
+[]
+type private TestCliOptionsWithMultipleArguments =
+ {
+ []
+ ReleaseName: string
+
+ []
+ ChartReference: string
+ }
+
+[]
+type private TestCliOptionsComplete =
+ {
+ []
+ ReleaseName: string
+
+ []
+ ChartReference: string
+
+ []
+ Debug: System.Nullable
+
+ []
+ Namespace: string
+
+ []
+ Set: string array
+ }
+
+type CliAttributeTests() =
+ member private this.ModelProvider = CommandModelProvider()
+ member private this.ArgumentBuilder = CommandArgumentBuilder()
+ member private this.BuildArguments(optionsObject: obj) =
+ let model = this.ModelProvider.GetCommandModel(optionsObject.GetType())
+ this.ArgumentBuilder.BuildArguments(model, optionsObject)
+
+ []
+ member _.CliCommand_Returns_Tool_And_SubCommands() = async {
+ let attribute = new CliCommandAttribute("helm", "install");
+ let parts: string array = attribute.GetAllParts();
+ do! check(Assert.That(parts).IsEquivalentTo([| "helm"; "install" |]))
+ }
+
+ []
+ member _.CliCommand_Returns_Only_Tool_When_No_SubCommands() = async {
+ let attribute = new CliCommandAttribute("helm");
+ let parts: string array = attribute.GetAllParts();
+ do! check(Assert.That(parts).IsEquivalentTo([| "helm" |]))
+ }
+
+ []
+ member _.CliCommand_Returns_Multiple_SubCommands() = async {
+ let attribute = new CliCommandAttribute("kubectl", "get", "pods");
+ let parts: string array = attribute.GetAllParts();
+ do! check(Assert.That(parts).IsEquivalentTo([| "kubectl"; "get"; "pods" |]))
+ }
+
+ []
+ member _.CliFlag_Returns_Name_When_ShortForm_Not_Preferred() = async {
+ let attribute = CliFlagAttribute("--debug")
+ attribute.ShortForm <- "-d"
+ do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(attribute.GetEffectiveName()), "--debug"))
+ }
+
+ []
+ member _.CliFlag_Returns_ShortForm_When_Preferred() = async {
+ let attribute = CliFlagAttribute("--debug")
+ attribute.ShortForm <- "-d"
+ attribute.PreferShortForm <- true
+ do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(attribute.GetEffectiveName()), "-d"))
+ }
+
+ []
+ member _.CliFlag_Returns_Name_When_ShortForm_Null_And_Preferred() = async {
+ let attribute = CliFlagAttribute("--debug")
+ attribute.PreferShortForm <- true
+ do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(attribute.GetEffectiveName()), "--debug"))
+ }
+
+ []
+ []
+ []
+ []
+ []
+ member _.CliOption_GetSeparator_Returns_Correct_Separator(format: OptionFormat, expected: string) = async {
+ let attribute = CliOptionAttribute("--namespace")
+ attribute.Format <- format
+ do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(attribute.GetSeparator()), expected))
+ }
+
+ []
+ member _.CliOption_CustomSeparator_Overrides_Format() = async {
+ let attribute = CliOptionAttribute("--namespace")
+ attribute.Format <- OptionFormat.SpaceSeparated
+ attribute.CustomSeparator <- "::"
+ do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(attribute.GetSeparator()), "::"))
+ }
+
+ []
+ member _.CliOption_Returns_Name_When_ShortForm_Not_Preferred() = async {
+ let attribute = CliOptionAttribute("--namespace")
+ attribute.ShortForm <- "-n"
+ do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(attribute.GetEffectiveName()), "--namespace"))
+ }
+
+ []
+ member _.CliOption_Returns_ShortForm_When_Preferred() = async {
+ let attribute = CliOptionAttribute("--namespace")
+ attribute.ShortForm <- "-n"
+ attribute.PreferShortForm <- true
+ do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(attribute.GetEffectiveName()), "-n"))
+ }
+
+ []
+ member _.CliArgument_Defaults_To_AfterOptions_Placement() = async {
+ let attribute = CliArgumentAttribute(0)
+ do! check(Assert.That(attribute.Placement = ArgumentPlacement.AfterOptions).IsTrue())
+ }
+
+ []
+ member _.CliArgument_Position_Is_Set_Correctly() = async {
+ let attribute = CliArgumentAttribute(2)
+ do! check(Assert.That(attribute.Position = 2).IsTrue())
+ }
+
+ []
+ member this.Parser_Handles_CliFlag() = async {
+ let options = { Debug = System.Nullable true }
+ let list = this.BuildArguments(options)
+ do! check(Assert.That(list |> Seq.toArray).IsEquivalentTo([| "--debug" |]))
+ }
+
+ []
+ member this.Parser_Omits_CliFlag_When_False() = async {
+ let options = { Debug = System.Nullable false }
+ let list = this.BuildArguments(options)
+ do! check(Assert.That((Seq.length list) = 0).IsTrue())
+ }
+
+ []
+ member this.Parser_Omits_CliFlag_When_Null() = async {
+ let options = { Debug = System.Nullable() }
+ let list = this.BuildArguments(options)
+ do! check(Assert.That((Seq.length list) = 0).IsTrue())
+ }
+
+ []
+ member this.Parser_Handles_CliOption_With_Space_Separator() = async {
+ let options = { Namespace = "default" }
+ let list = this.BuildArguments(options)
+ do! check(Assert.That(list |> Seq.toArray).IsEquivalentTo([| "--namespace"; "default" |]))
+ }
+
+ []
+ member this.Parser_Handles_CliOption_With_Equals_Separator() = async {
+ let options = { Set = "key=value" }
+ let list = this.BuildArguments(options)
+ do! check(Assert.That(list |> Seq.toArray).IsEquivalentTo([| "--set=key=value" |]))
+ }
+
+ []
+ member this.Parser_Handles_CliOption_With_Multiple_Values() = async {
+ let options = { Values = [| "file1.yaml"; "file2.yaml" |] }
+ let list = this.BuildArguments(options)
+ do! check(Assert.That(list |> Seq.toArray).IsEquivalentTo([| "--values"; "file1.yaml"; "--values"; "file2.yaml" |]))
+ }
+
+ []
+ member this.Parser_Handles_CliArgument_After_Options() = async {
+ let options = { ReleaseName = "myrelease"; Debug = System.Nullable true }
+ let list = this.BuildArguments(options)
+ do! check(Assert.That(list |> Seq.toArray).IsEquivalentTo([| "--debug"; "myrelease" |]))
+ }
+
+ []
+ member this.Parser_Handles_CliArgument_Before_Options() = async {
+ let options = { Path = "/some/path"; Debug = System.Nullable true }
+ let list = this.BuildArguments(options)
+ do! check(Assert.That(list |> Seq.toArray).IsEquivalentTo([| "/some/path"; "--debug" |]))
+ }
+
+ []
+ member this.Parser_Omits_Null_CliArgument() = async {
+ let options = { ReleaseName = null; Debug = System.Nullable true }
+ let list = this.BuildArguments(options)
+ do! check(Assert.That(list |> Seq.toArray).IsEquivalentTo([| "--debug" |]))
+ }
+
+ []
+ member this.Parser_Orders_Multiple_Arguments_By_Position() = async {
+ let options = { ReleaseName = "myrelease"; ChartReference = "bitnami/nginx" }
+ let list = this.BuildArguments(options)
+ do! check(Assert.That(list |> Seq.toArray).IsEquivalentTo([| "myrelease"; "bitnami/nginx" |]))
+ }
+
+ []
+ member this.Parser_Handles_Mixed_Flags_Options_And_Arguments() = async {
+ let options =
+ {
+ ReleaseName = "myrelease"
+ ChartReference = "bitnami/nginx"
+ Namespace = "production"
+ Debug = System.Nullable true
+ Set = [| "key1=val1"; "key2=val2" |]
+ }
+
+ let list = this.BuildArguments(options)
+
+ let firstItem = list |> Seq.tryHead
+
+ do! check(Assert.That(firstItem.IsSome).IsTrue())
+ do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(firstItem.Value), "--debug"))
+ do! check(Assert.That(list |> Seq.contains "--namespace").IsTrue())
+ do! check(Assert.That(list |> Seq.contains "production").IsTrue())
+ do! check(Assert.That(list |> Seq.contains "--set=key1=val1").IsTrue())
+ do! check(Assert.That(list |> Seq.contains "--set=key2=val2").IsTrue())
+ do! check(Assert.That(list |> Seq.contains "myrelease").IsTrue())
+ do! check(Assert.That(list |> Seq.contains "bitnami/nginx").IsTrue())
+ }
+
+
diff --git a/test/ModularPipelines.UnitTests.FSharp/Attributes/CliToolAttributeTests.fs b/test/ModularPipelines.UnitTests.FSharp/Attributes/CliToolAttributeTests.fs
new file mode 100644
index 0000000000..5223c645c8
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Attributes/CliToolAttributeTests.fs
@@ -0,0 +1,69 @@
+namespace ModularPipelines.UnitTests.FSharp.Attributes
+
+open System.Reflection
+open ModularPipelines.Attributes
+open ModularPipelines.Options
+open TUnit.Assertions
+open TUnit.Assertions.Extensions
+open TUnit.Assertions.FSharp.Operations
+open TUnit.Core
+
+[]
+[]
+type private TestGitOptions() =
+ inherit CommandLineToolOptions()
+
+[]
+type private TestGitCommitOptions() =
+ inherit TestGitOptions()
+
+[]
+[]
+type private TestOptionsWithAttribute() =
+ inherit CommandLineToolOptions()
+
+type CliToolAttributeTests() =
+ []
+ member _.CliToolAttribute_StoresToolName() = async {
+ let attribute = CliToolAttribute("git")
+ do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(attribute.Tool), "git"))
+ }
+
+ []
+ member _.CliToolAttribute_CanBeAppliedToClass() = async {
+ let attribute = typeof.GetCustomAttribute()
+ do! check(Assert.That(attribute).IsNotNull())
+ do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(attribute.Tool), "git"))
+ }
+
+ []
+ member _.CliToolAttribute_ThrowsOnNullOrWhitespace() = async {
+ let mutable threw1 = false
+ try
+ CliToolAttribute(null) |> ignore
+ with :? System.ArgumentException ->
+ threw1 <- true
+
+ let mutable threw2 = false
+ try
+ CliToolAttribute("") |> ignore
+ with :? System.ArgumentException ->
+ threw2 <- true
+
+ let mutable threw3 = false
+ try
+ CliToolAttribute(" ") |> ignore
+ with :? System.ArgumentException ->
+ threw3 <- true
+
+ do! check(Assert.That(threw1).IsTrue())
+ do! check(Assert.That(threw2).IsTrue())
+ do! check(Assert.That(threw3).IsTrue())
+ }
+
+ []
+ member _.CliToolAttribute_IsInheritedByDerivedClasses() = async {
+ let attribute = typeof.GetCustomAttribute(true)
+ do! check(Assert.That(attribute).IsNotNull())
+ do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(attribute.Tool), "git"))
+ }
diff --git a/test/ModularPipelines.UnitTests.FSharp/Attributes/DotNetFormatOptionsTests.fs b/test/ModularPipelines.UnitTests.FSharp/Attributes/DotNetFormatOptionsTests.fs
new file mode 100644
index 0000000000..25fb806f5a
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Attributes/DotNetFormatOptionsTests.fs
@@ -0,0 +1,28 @@
+namespace ModularPipelines.UnitTests.FSharp.Attributes
+
+open ModularPipelines.DotNet.Options
+open ModularPipelines.Helpers.Internal
+open TUnit.Assertions
+open TUnit.Assertions.Extensions
+open TUnit.Assertions.FSharp.Operations
+open TUnit.Core
+
+type DotNetFormatOptionsTests() =
+ member private _.ModelProvider = CommandModelProvider()
+ member private _.ArgumentBuilder = CommandArgumentBuilder()
+ member private this.BuildArguments(optionsObject: obj) =
+ let model = this.ModelProvider.GetCommandModel(optionsObject.GetType())
+ this.ArgumentBuilder.BuildArguments(model, optionsObject)
+
+ []
+ member this.ExcludeDiagnostics_Passes_Each_Id_Separately() = async {
+ let options = DotNetFormatOptions()
+ options.ExcludeDiagnostics <- [| "CS0246"; "CS1503" |]
+
+ let args = this.BuildArguments(options)
+
+ do! check(Assert.That((args |> Seq.toArray) = [|
+ "--exclude-diagnostics"; "CS0246"
+ "--exclude-diagnostics"; "CS1503"
+ |]).IsTrue())
+ }
diff --git a/test/ModularPipelines.UnitTests.FSharp/Attributes/DynamicDependencyIntegrationTests.fs b/test/ModularPipelines.UnitTests.FSharp/Attributes/DynamicDependencyIntegrationTests.fs
new file mode 100644
index 0000000000..a8ab5b549f
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Attributes/DynamicDependencyIntegrationTests.fs
@@ -0,0 +1,65 @@
+namespace ModularPipelines.UnitTests.FSharp.Attributes
+
+open System
+open System.Threading
+open System.Threading.Tasks
+open ModularPipelines.Attributes.Events
+open ModularPipelines.Context
+open ModularPipelines.Enums
+open ModularPipelines.Extensions
+open ModularPipelines.Modules
+open ModularPipelines.TestHelpers
+open TUnit.Assertions
+open TUnit.Assertions.Extensions
+open TUnit.Assertions.FSharp.Operations
+open TUnit.Core
+
+module DynamicDependencyIntegrationTests =
+ let executionOrder = ResizeArray()
+
+ type AddDependencyAttribute(dependencyType: Type) =
+ inherit Attribute()
+ interface IModuleRegistrationEventReceiver with
+ member _.OnRegistrationAsync(context: IModuleRegistrationContext) =
+ context.AddDependency(dependencyType)
+ Task.CompletedTask
+
+ type ModuleA() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ task {
+ executionOrder.Add("A")
+ do! Task.Yield()
+ return "A"
+ }
+
+ [)>]
+ type ModuleB() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ task {
+ executionOrder.Add("B")
+ do! Task.Yield()
+ return "B"
+ }
+
+[]
+type DynamicDependencyIntegrationTests() =
+ inherit TestBase()
+
+ []
+ member _.ClearExecutionOrder() =
+ DynamicDependencyIntegrationTests.executionOrder.Clear()
+
+ []
+ member _.DynamicDependency_ModuleBWaitsForModuleA() = async {
+ let! result =
+ TestPipelineHostBuilder.Create()
+ .AddModule()
+ .AddModule()
+ .ExecutePipelineAsync()
+ |> Async.AwaitTask
+
+ do! check(Assert.That(result.Status = Status.Successful).IsTrue())
+ do! check(Assert.That((DynamicDependencyIntegrationTests.executionOrder |> Seq.toArray) = [| "A"; "B" |]).IsTrue())
+ }
diff --git a/test/ModularPipelines.UnitTests.FSharp/Attributes/EnumValueAttributeTests.fs b/test/ModularPipelines.UnitTests.FSharp/Attributes/EnumValueAttributeTests.fs
new file mode 100644
index 0000000000..773a653554
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Attributes/EnumValueAttributeTests.fs
@@ -0,0 +1,40 @@
+namespace ModularPipelines.UnitTests.FSharp.Attributes
+
+open ModularPipelines.Attributes
+open ModularPipelines.Helpers.Internal
+open TUnit.Assertions
+open TUnit.Assertions.Extensions
+open TUnit.Assertions.FSharp.Operations
+open TUnit.Core
+
+type Number =
+ | [] One = 0
+ | [] Two = 1
+ | [] Three = 2
+
+[]
+type private NumberWrapper =
+ {
+ []
+ Number: Number
+ }
+
+type EnumValueAttributeTests() =
+ member private _.ModelProvider = CommandModelProvider()
+ member private _.ArgumentBuilder = CommandArgumentBuilder()
+ member private this.BuildArguments(optionsObject: obj) =
+ let model = this.ModelProvider.GetCommandModel(optionsObject.GetType())
+ this.ArgumentBuilder.BuildArguments(model, optionsObject)
+
+ []
+ []
+ []
+ []
+ member this.Can_Parse_EnumValueAttribute(number: Number, expected: string) = async {
+ let options = { Number = number }
+
+ let list = this.BuildArguments(options)
+ do! check(Assert.That(list |> Seq.contains "--number").IsTrue())
+ do! check(Assert.That(list |> Seq.contains expected).IsTrue())
+ do! check(Assert.That((list |> Seq.toArray) = [| "--number"; expected |]).IsTrue())
+ }
diff --git a/test/ModularPipelines.UnitTests.FSharp/Attributes/LifecycleEventIntegrationTests.fs b/test/ModularPipelines.UnitTests.FSharp/Attributes/LifecycleEventIntegrationTests.fs
new file mode 100644
index 0000000000..c41dbd526f
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Attributes/LifecycleEventIntegrationTests.fs
@@ -0,0 +1,132 @@
+namespace ModularPipelines.UnitTests.FSharp.Attributes
+
+open System
+open System.Threading
+open System.Threading.Tasks
+open ModularPipelines.Attributes.Events
+open ModularPipelines.Configuration
+open ModularPipelines.Context
+open ModularPipelines.Enums
+open ModularPipelines.Extensions
+open ModularPipelines.Models
+open ModularPipelines.Modules
+open ModularPipelines.Options
+open ModularPipelines.TestHelpers
+open TUnit.Assertions
+open TUnit.Assertions.Extensions
+open TUnit.Assertions.FSharp.Operations
+open TUnit.Core
+
+module LifecycleEventIntegrationTests =
+ let eventLog = ResizeArray()
+
+ type LogStartAttribute() =
+ inherit Attribute()
+ interface IModuleStartHandler with
+ member _.ContinueOnError = false
+ member _.OnModuleStartAsync(context: IModuleHookContext) =
+ eventLog.Add($"Start:{context.ModuleName}")
+ Task.CompletedTask
+
+ type LogEndAttribute() =
+ inherit Attribute()
+ interface IModuleEndHandler with
+ member _.ContinueOnError = false
+ member _.OnModuleEndAsync(context: IModuleHookContext, _: IModuleResult) =
+ eventLog.Add($"End:{context.ModuleName}")
+ Task.CompletedTask
+
+ type LogFailedAttribute() =
+ inherit Attribute()
+ interface IModuleFailureHandler with
+ member _.ContinueOnError = true
+ member _.OnModuleFailureAsync(context: IModuleHookContext, ex: Exception) =
+ eventLog.Add($"Failed:{context.ModuleName}:{ex.Message}")
+ Task.CompletedTask
+
+ type LogSkippedAttribute() =
+ inherit Attribute()
+ interface IModuleSkippedHandler with
+ member _.ContinueOnError = false
+ member _.OnModuleSkippedAsync(context: IModuleHookContext, reason: SkipDecision) =
+ eventLog.Add($"Skipped:{context.ModuleName}:{reason.Reason}")
+ Task.CompletedTask
+
+ []
+ []
+ type SuccessfulModule() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ task {
+ do! Task.Yield()
+ return "Success"
+ }
+
+ []
+ []
+ type FailingModule() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) : Task =
+ raise (InvalidOperationException("Intentional failure"))
+
+ []
+ []
+ type SkippingModule() =
+ inherit Module()
+ override _.Configure() =
+ ModuleConfiguration.Create()
+ .WithSkipWhen(fun () -> SkipDecision.Skip("Test skip reason"))
+ .Build()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ Task.FromResult("Should not execute")
+
+[]
+type LifecycleEventIntegrationTests() =
+ inherit TestBase()
+
+ []
+ member _.ClearEventLog() =
+ LifecycleEventIntegrationTests.eventLog.Clear()
+
+ []
+ member _.SuccessfulModule_InvokesStartAndEndEvents() = async {
+ let! result =
+ TestPipelineHostBuilder.Create()
+ .AddModule()
+ .ExecutePipelineAsync()
+ |> Async.AwaitTask
+
+ do! check(Assert.That(result.Status = Status.Successful).IsTrue())
+ do! check(Assert.That(LifecycleEventIntegrationTests.eventLog |> Seq.contains "Start:SuccessfulModule").IsTrue())
+ do! check(Assert.That(LifecycleEventIntegrationTests.eventLog |> Seq.contains "End:SuccessfulModule").IsTrue())
+ }
+
+ []
+ member _.FailingModule_InvokesStartAndFailedEvents() = async {
+ try
+ do!
+ TestPipelineHostBuilder.Create()
+ .AddModule()
+ .ConfigurePipelineOptions(fun _ options ->
+ options.ExecutionMode <- ExecutionMode.WaitForAllModules)
+ .ExecutePipelineAsync()
+ |> Async.AwaitTask
+ |> Async.Ignore
+ with _ -> ()
+
+ do! check(Assert.That(LifecycleEventIntegrationTests.eventLog |> Seq.contains "Start:FailingModule").IsTrue())
+ do! check(Assert.That(LifecycleEventIntegrationTests.eventLog |> Seq.exists (fun e -> e.StartsWith("Failed:FailingModule:"))).IsTrue())
+ }
+
+ []
+ member _.SkippingModule_InvokesStartAndSkippedEvents() = async {
+ let! result =
+ TestPipelineHostBuilder.Create()
+ .AddModule()
+ .ExecutePipelineAsync()
+ |> Async.AwaitTask
+
+ do! check(Assert.That(result.Status = Status.Successful).IsTrue())
+ do! check(Assert.That(LifecycleEventIntegrationTests.eventLog |> Seq.contains "Start:SkippingModule").IsTrue())
+ do! check(Assert.That(LifecycleEventIntegrationTests.eventLog |> Seq.exists (fun e -> e.Contains("Skipped:SkippingModule:Test skip reason"))).IsTrue())
+ }
diff --git a/test/ModularPipelines.UnitTests.FSharp/Attributes/LinuxOnlyTestAttribute.fs b/test/ModularPipelines.UnitTests.FSharp/Attributes/LinuxOnlyTestAttribute.fs
new file mode 100644
index 0000000000..0283999d7d
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Attributes/LinuxOnlyTestAttribute.fs
@@ -0,0 +1,10 @@
+namespace ModularPipelines.UnitTests.FSharp.Attributes
+
+open System
+open System.Threading.Tasks
+open TUnit.Core
+
+type LinuxOnlyTestAttribute() =
+ inherit SkipAttribute("Linux only test")
+ override _.ShouldSkip(_: TestRegisteredContext) =
+ Task.FromResult(not (OperatingSystem.IsLinux()))
diff --git a/test/ModularPipelines.UnitTests.FSharp/Attributes/MetadataCrossPhaseIntegrationTests.fs b/test/ModularPipelines.UnitTests.FSharp/Attributes/MetadataCrossPhaseIntegrationTests.fs
new file mode 100644
index 0000000000..bcb033e6be
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Attributes/MetadataCrossPhaseIntegrationTests.fs
@@ -0,0 +1,80 @@
+namespace ModularPipelines.UnitTests.FSharp.Attributes
+
+open System
+open System.Threading
+open System.Threading.Tasks
+open ModularPipelines.Attributes.Events
+open ModularPipelines.Context
+open ModularPipelines.Enums
+open ModularPipelines.Extensions
+open ModularPipelines.Models
+open ModularPipelines.Modules
+open ModularPipelines.TestHelpers
+open TUnit.Assertions
+open TUnit.Assertions.Extensions
+open TUnit.Assertions.FSharp.Operations
+open TUnit.Core
+
+module MetadataCrossPhaseIntegrationTests =
+ let eventLog = ResizeArray()
+
+ type SetMetadataOnRegistrationAttribute(key: string, value: string) =
+ inherit Attribute()
+ interface IModuleRegistrationEventReceiver with
+ member _.OnRegistrationAsync(context: IModuleRegistrationContext) =
+ context.SetMetadata(key, value)
+ eventLog.Add($"Registration:SetMetadata:{key}={value}")
+ Task.CompletedTask
+
+ type ReadMetadataOnStartAttribute(key: string) =
+ inherit Attribute()
+ interface IModuleStartHandler with
+ member _.ContinueOnError = false
+ member _.OnModuleStartAsync(context: IModuleHookContext) =
+ let value = context.GetMetadata(key)
+ let valueText = if value = null then "null" else value
+ eventLog.Add(sprintf "Start:ReadMetadata:%s=%s" key valueText)
+ Task.CompletedTask
+
+ type ReadMetadataOnEndAttribute(key: string) =
+ inherit Attribute()
+ interface IModuleEndHandler with
+ member _.ContinueOnError = false
+ member _.OnModuleEndAsync(context: IModuleHookContext, _: IModuleResult) =
+ let value = context.GetMetadata(key)
+ let valueText = if value = null then "null" else value
+ eventLog.Add(sprintf "End:ReadMetadata:%s=%s" key valueText)
+ Task.CompletedTask
+
+ []
+ []
+ []
+ type MetadataModule() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ task {
+ do! Task.Yield()
+ return "Done"
+ }
+
+[]
+type MetadataCrossPhaseIntegrationTests() =
+ inherit TestBase()
+
+ []
+ member _.ClearEventLog() =
+ MetadataCrossPhaseIntegrationTests.eventLog.Clear()
+
+ []
+ member _.Metadata_SetDuringRegistration_AvailableDuringLifecycleEvents() = async {
+ let! result =
+ TestPipelineHostBuilder.Create()
+ .AddModule()
+ .ExecutePipelineAsync()
+ |> Async.AwaitTask
+
+ do! check(Assert.That(result.Status = Status.Successful).IsTrue())
+ do! check(Assert.That(MetadataCrossPhaseIntegrationTests.eventLog |> Seq.contains "Registration:SetMetadata:config=value-from-registration").IsTrue())
+ do! check(Assert.That(MetadataCrossPhaseIntegrationTests.eventLog |> Seq.contains "Start:ReadMetadata:config=value-from-registration").IsTrue())
+ do! check(Assert.That(MetadataCrossPhaseIntegrationTests.eventLog |> Seq.contains "End:ReadMetadata:config=value-from-registration").IsTrue())
+ }
diff --git a/test/ModularPipelines.UnitTests.FSharp/Attributes/ModuleAttributeEventServiceTests.fs b/test/ModularPipelines.UnitTests.FSharp/Attributes/ModuleAttributeEventServiceTests.fs
new file mode 100644
index 0000000000..18aa128b60
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Attributes/ModuleAttributeEventServiceTests.fs
@@ -0,0 +1,141 @@
+namespace ModularPipelines.UnitTests.FSharp.Attributes
+
+open System
+open System.Threading
+open System.Threading.Tasks
+open ModularPipelines.Attributes.Events
+open ModularPipelines.Context
+open ModularPipelines.Engine.Attributes
+open ModularPipelines.Modules
+open TUnit.Assertions
+open TUnit.Assertions.Extensions
+open TUnit.Assertions.FSharp.Operations
+open TUnit.Core
+
+module ModuleAttributeEventServiceTests =
+ type TestStartAttribute() =
+ inherit Attribute()
+ interface IModuleStartHandler with
+ member _.ContinueOnError = false
+ member _.OnModuleStartAsync(_: IModuleHookContext) = Task.CompletedTask
+
+ type TestFailureAttribute() =
+ inherit Attribute()
+ interface IModuleFailureHandler with
+ member _.ContinueOnError = false
+ member _.OnModuleFailureAsync(_: IModuleHookContext, _: Exception) = Task.CompletedTask
+
+ []
+ type LowPriorityStartAttribute() =
+ inherit Attribute()
+ interface IModuleStartHandler with
+ member _.ContinueOnError = false
+ member _.OnModuleStartAsync(_: IModuleHookContext) = Task.CompletedTask
+ interface IEventHandlerPriority with
+ member _.Priority = 100
+
+ []
+ type MediumPriorityStartAttribute() =
+ inherit Attribute()
+ interface IModuleStartHandler with
+ member _.ContinueOnError = false
+ member _.OnModuleStartAsync(_: IModuleHookContext) = Task.CompletedTask
+ interface IEventHandlerPriority with
+ member _.Priority = 10
+
+ []
+ type HighPriorityStartAttribute() =
+ inherit Attribute()
+ interface IModuleStartHandler with
+ member _.ContinueOnError = false
+ member _.OnModuleStartAsync(_: IModuleHookContext) = Task.CompletedTask
+ interface IEventHandlerPriority with
+ member _.Priority = 1
+
+ []
+ []
+ type ModuleWithAttributes() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ Task.FromResult("test")
+
+ type ModuleWithoutAttributes() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ Task.FromResult("test")
+
+ []
+ []
+ []
+ type ModuleWithPrioritizedHandlers() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ Task.FromResult("test")
+
+ []
+ []
+ []
+ type ModuleWithMixedPriorityHandlers() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ Task.FromResult("test")
+
+type ModuleAttributeEventServiceTests() =
+ []
+ member _.GetStartHandlers_ModuleWithAttribute_ReturnsHandler() = async {
+ let service = ModuleAttributeEventService()
+ let handlers = service.GetStartHandlers(typeof)
+ do! check(Assert.That(handlers.Count = 1).IsTrue())
+ do! check(Assert.That(handlers.[0]).IsTypeOf())
+ }
+
+ []
+ member _.GetFailureHandlers_ModuleWithAttribute_ReturnsHandler() = async {
+ let service = ModuleAttributeEventService()
+ let handlers = service.GetFailureHandlers(typeof)
+ do! check(Assert.That(handlers.Count = 1).IsTrue())
+ do! check(Assert.That(handlers.[0]).IsTypeOf())
+ }
+
+ []
+ member _.GetStartHandlers_ModuleWithoutAttributes_ReturnsEmpty() = async {
+ let service = ModuleAttributeEventService()
+ let handlers = service.GetStartHandlers(typeof)
+ do! check(Assert.That(handlers.Count = 0).IsTrue())
+ }
+
+ []
+ member _.GetHandlers_CachesResults() = async {
+ let service = ModuleAttributeEventService()
+ let handlers1 = service.GetStartHandlers(typeof)
+ let handlers2 = service.GetStartHandlers(typeof)
+ do! check(Assert.That(System.Object.ReferenceEquals(handlers1, handlers2)).IsTrue())
+ }
+
+ []
+ member _.GetStartHandlers_WithPriority_ReturnsSortedByPriority() = async {
+ let service = ModuleAttributeEventService()
+ let handlers = service.GetStartHandlers(typeof)
+ do! check(Assert.That(handlers.Count = 3).IsTrue())
+ do! check(Assert.That(handlers.[0]).IsTypeOf())
+ do! check(Assert.That(handlers.[1]).IsTypeOf())
+ do! check(Assert.That(handlers.[2]).IsTypeOf())
+ }
+
+ []
+ member _.GetStartHandlers_WithMixedPriority_DefaultsToZero() = async {
+ let service = ModuleAttributeEventService()
+ let handlers = service.GetStartHandlers(typeof)
+ do! check(Assert.That(handlers.Count = 3).IsTrue())
+ do! check(Assert.That(handlers.[0]).IsTypeOf())
+ do! check(Assert.That(handlers.[1]).IsTypeOf())
+ do! check(Assert.That(handlers.[2]).IsTypeOf())
+ }
+
+ []
+ member _.GetStartHandlers_SingleHandler_ReturnsWithoutSorting() = async {
+ let service = ModuleAttributeEventService()
+ let handlers = service.GetStartHandlers(typeof)
+ do! check(Assert.That(handlers.Count = 1).IsTrue())
+ do! check(Assert.That(handlers.[0]).IsTypeOf())
+ }
diff --git a/test/ModularPipelines.UnitTests.FSharp/Attributes/ModuleDependencyRegistryTests.fs b/test/ModularPipelines.UnitTests.FSharp/Attributes/ModuleDependencyRegistryTests.fs
new file mode 100644
index 0000000000..db8b0afb91
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Attributes/ModuleDependencyRegistryTests.fs
@@ -0,0 +1,63 @@
+namespace ModularPipelines.UnitTests.FSharp.Attributes
+
+open System.Threading
+open System.Threading.Tasks
+open ModularPipelines.Context
+open ModularPipelines.Engine.Dependencies
+open ModularPipelines.Modules
+open TUnit.Assertions
+open TUnit.Assertions.Extensions
+open TUnit.Assertions.FSharp.Operations
+open TUnit.Core
+
+module ModuleDependencyRegistryTests =
+ type ModuleA() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ Task.FromResult("A")
+
+ type ModuleB() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ Task.FromResult("B")
+
+ type ModuleC() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ Task.FromResult("C")
+
+type ModuleDependencyRegistryTests() =
+ []
+ member _.AddDynamicDependency_AddsDependency() = async {
+ let registry = ModuleDependencyRegistry()
+ registry.AddDynamicDependency(typeof, typeof)
+ let dependencies = registry.GetDynamicDependencies(typeof)
+ do! check(Assert.That(dependencies |> Seq.contains typeof).IsTrue())
+ }
+
+ []
+ member _.AddDynamicDependency_MultipleDependencies_AddsAll() = async {
+ let registry = ModuleDependencyRegistry()
+ registry.AddDynamicDependency(typeof, typeof)
+ registry.AddDynamicDependency(typeof, typeof)
+ let dependencies = registry.GetDynamicDependencies(typeof)
+ do! check(Assert.That((dependencies |> Seq.length) = 2).IsTrue())
+ do! check(Assert.That(dependencies |> Seq.contains typeof).IsTrue())
+ do! check(Assert.That(dependencies |> Seq.contains typeof).IsTrue())
+ }
+
+ []
+ member _.RemoveDependency_RemovesDependency() = async {
+ let registry = ModuleDependencyRegistry()
+ registry.AddDynamicDependency(typeof, typeof)
+ registry.RemoveDependency(typeof, typeof)
+ let dependencies = registry.GetDynamicDependencies(typeof)
+ do! check(Assert.That(Seq.isEmpty dependencies).IsTrue())
+ }
+
+ []
+ member _.GetDynamicDependencies_NoDependencies_ReturnsEmpty() = async {
+ let registry = ModuleDependencyRegistry()
+ let dependencies = registry.GetDynamicDependencies(typeof)
+ do! check(Assert.That(Seq.isEmpty dependencies).IsTrue())
+ }
diff --git a/test/ModularPipelines.UnitTests.FSharp/Attributes/ModuleMetadataRegistryTests.fs b/test/ModularPipelines.UnitTests.FSharp/Attributes/ModuleMetadataRegistryTests.fs
new file mode 100644
index 0000000000..ec220814a3
--- /dev/null
+++ b/test/ModularPipelines.UnitTests.FSharp/Attributes/ModuleMetadataRegistryTests.fs
@@ -0,0 +1,47 @@
+namespace ModularPipelines.UnitTests.FSharp.Attributes
+
+open System.Threading
+open System.Threading.Tasks
+open Microsoft.Extensions.Options
+open ModularPipelines.Context
+open ModularPipelines.Engine.Dependencies
+open ModularPipelines.Modules
+open ModularPipelines.Options
+open TUnit.Assertions
+open TUnit.Assertions.Extensions
+open TUnit.Assertions.FSharp.Operations
+open TUnit.Core
+
+module ModuleMetadataRegistryTests =
+ type ModuleA() =
+ inherit Module()
+ override _.ExecuteAsync(_: IModuleContext, _: CancellationToken) =
+ Task.FromResult("A")
+
+ let createRegistry () =
+ ModuleMetadataRegistry(Options.Create(ModuleRegistrationOptions()))
+
+type ModuleMetadataRegistryTests() =
+ [