commit 57bd920101b00308072f781ff075ffd56b6725e5 Author: Travis Jensen Date: Wed Sep 4 13:02:25 2019 -0700 First commit - Adding all files. diff --git a/Forge/Forge.DataContracts/Forge.DataContracts.csproj b/Forge/Forge.DataContracts/Forge.DataContracts.csproj new file mode 100644 index 0000000..3870877 --- /dev/null +++ b/Forge/Forge.DataContracts/Forge.DataContracts.csproj @@ -0,0 +1,50 @@ + + + + + Debug + AnyCPU + {C49A8494-13E5-4214-8434-708BA280C5B1} + Library + Properties + Forge.DataContracts + Forge.DataContracts + v4.6.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Forge/Forge.DataContracts/ForgeSchemaValidationRules.json b/Forge/Forge.DataContracts/ForgeSchemaValidationRules.json new file mode 100644 index 0000000..8c763a4 --- /dev/null +++ b/Forge/Forge.DataContracts/ForgeSchemaValidationRules.json @@ -0,0 +1,193 @@ +{ + "type": "object", + "definitions": { + "TreeDefinition": { + "type": "object", + "patternProperties": { + ".*?": { + "$ref": "#/definitions/TreeNodeDefinition" + } + } + }, + "TreeNodeDefinition": { + "oneOf": [ + { + "$ref": "#/definitions/SelectionTypeNodeDefinition" + }, + { + "$ref": "#/definitions/ActionTypeNodeDefinition" + }, + { + "$ref": "#/definitions/LeafTypeNodeDefinition" + } + ] + }, + "SelectionTypeNodeDefinition": { + "type": "object", + "properties": { + "Type": { + "type": "string", + "enum": [ "Selection" ] + }, + "ChildSelector": { + "type": "array", + "items": { + "$ref": "#/definitions/ChildSelectorDefinition" + }, + "minItems": 1 + }, + "Properties": { + "type": "object" + } + }, + "additionalProperties": false, + "required": [ "Type" ] + }, + "ActionTypeNodeDefinition": { + "type": "object", + "properties": { + "Type": { + "type": "string", + "enum": [ "Action" ] + }, + "Timeout": { + "type": [ "number", "string" ] + }, + "Actions": { + "patternProperties": { + ".*?": { + "$ref": "#/definitions/ActionDefinition" + } + }, + "minProperties": 1 + }, + "ChildSelector": { + "type": "array", + "items": { + "$ref": "#/definitions/ChildSelectorDefinition" + }, + "minItems": 1 + }, + "Properties": { + "type": "object" + } + }, + "additionalProperties": false, + "required": [ "Type", "Actions" ] + }, + "LeafTypeNodeDefinition": { + "type": "object", + "properties": { + "Type": { + "type": "string", + "enum": [ "Leaf" ] + }, + "Actions": { + "patternProperties": { + ".*?": { + "$ref": "#/definitions/LeafNodeSummaryActionDefinition" + } + }, + "minProperties": 1, + "maxProperties": 1 + }, + "Properties": { + "type": "object" + } + }, + "additionalProperties": false, + "required": [ "Type" ] + }, + "ChildSelectorDefinition": { + "type": "object", + "properties": { + "Label": { + "type": "string" + }, + "ShouldSelect": { + "type": "string" + }, + "Child": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ "Label", "Child" ] + }, + "RetryPolicy": { + "type": "object", + "properties": { + "Type": { + "type": "string", + "enum": [ "None", "FixedInterval", "ExponentialBackoff" ] + }, + "MinBackoffMs": { + "type": "number" + }, + "MaxBackoffMs": { + "type": "number" + } + }, + "required": [ "Type" ] + }, + "ActionDefinition": { + "type": "object", + "properties": { + "Action": { + "type": "string" + }, + "Input": { + "type": "object" + }, + "Properties": { + "type": "object" + }, + "Timeout": { + "type": [ "number", "string" ] + }, + "ContinuationOnTimeout": { + "type": "boolean" + }, + "RetryPolicy": { + "$ref": "#/definitions/RetryPolicy" + }, + "ContinuationOnRetryExhaustion": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ "Action" ] + }, + "LeafNodeSummaryActionDefinition": { + "type": "object", + "properties": { + "Action": { + "enum": [ "LeafNodeSummaryAction" ] + }, + "Input": { + "type": [ "object", "string" ], + "properties": { + "Status": { + "type": "string" + }, + "StatusCode": { + "type": [ "number", "string" ] + }, + "Output": { + "type": [ "object", "string" ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ "Action", "Input" ] + } + }, + "properties": { + "Tree": { + "$ref": "#/definitions/TreeDefinition" + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/Forge/Forge.DataContracts/ForgeTree.cs b/Forge/Forge.DataContracts/ForgeTree.cs new file mode 100644 index 0000000..07e96fb --- /dev/null +++ b/Forge/Forge.DataContracts/ForgeTree.cs @@ -0,0 +1,252 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The Forge schema data contracts. +// +//----------------------------------------------------------------------- + +namespace Forge.DataContracts +{ + using System; + using System.Collections.Generic; + using System.Runtime.Serialization; + + /// + /// The Forge tree. + /// This outermost data structure holds the Forge schema. + /// + [DataContract] + public class ForgeTree + { + /// + /// Dictionary mapping unique TreeNodeKeys to TreeNodes. + /// + [DataMember] + public Dictionary Tree { get; set; } + } + + /// + /// The tree node. + /// Holds information to navigate the tree and perform actions. + /// + [DataContract] + public class TreeNode + { + /// + /// The tree node type. + /// + [DataMember(IsRequired = true)] + public TreeNodeType Type { get; private set; } + + /// + /// Additional properties passed to wrapper class. + /// String properties starting with represent a code-snippet that will be evaluated. + /// + [DataMember] + public dynamic Properties { get; set; } + + /// + /// The child selectors. + /// + [DataMember] + public ChildSelector[] ChildSelector { get; private set; } + + #region Properties used only by TreeNodeType.Action nodes + + /// + /// The actions to execute when the TreeNodeType is Action. + /// Dictionary mapping unique TreeActionKeys to TreeActions. + /// + [DataMember] + public Dictionary Actions { get; set; } + + /// + /// Timeout in milliseconds for executing the TreeActions. Default to -1 (infinite) if not specified. + /// String properties starting with represent a code-snippet that will be evaluated. + /// + [DataMember] + public dynamic Timeout { get; set; } + + #endregion + } + + /// + /// The child selector for the TreeNode. + /// Used to navigate the tree by referencing child TreeNodes. + /// + [DataContract] + public class ChildSelector + { + /// + /// String code-snippet that can be parsed and evaluated to a boolean value. + /// If the expression is true, visit the attached child TreeNode. + /// If the expression is empty, evaluate to true by default. + /// + [DataMember] + public string ShouldSelect { get; set; } + + /// + /// Reader-friendly label that describes the intention of the ShouldSelect expression. + /// Used in ForgeEditor for display purposes. + /// + [DataMember] + public string Label { get; set; } + + /// + /// String key pointer to a child TreeNode. + /// Visit this child if the attached ShouldSelect expression evaluates to true. + /// + [DataMember(IsRequired = true)] + public string Child { get; private set; } + } + + /// + /// The tree action for the TreeNode. + /// Holds instructions and policies for executing an action. + /// + [DataContract] + public class TreeAction + { + /// + /// String name of the action that maps to an action-task. + /// These actions may be predefined Forge actions or action-tasks passed by a Wrapper class. + /// + [DataMember(IsRequired = true)] + public string Action { get; set; } + + /// + /// Dynamic input parameters passed to the action-task. + /// Wrapper class is responsible for making sure the action-task input matches the input defined in the schema. + /// String properties starting with represent a code-snippet that will be evaluated. + /// + [DataMember] + public dynamic Input { get; private set; } + + /// + /// Additional properties passed to wrapper class. + /// String properties starting with represent a code-snippet that will be evaluated. + /// + [DataMember] + public dynamic Properties { get; set; } + + /// + /// Timeout in milliseconds for executing the action. Default to -1 (infinite) if not specified. + /// String properties starting with represent a code-snippet that will be evaluated. + /// + [DataMember] + public dynamic Timeout { get; set; } + + /// + /// A flag that represents how to handle the exit of the action due to timeout. If false (default), then the session will end on the + /// timeout. If true and a timeout is hit, the action will continue on as if it were successful after committing a "TimeoutOnAction" response. + /// + [DataMember] + public bool ContinuationOnTimeout { get; set; } + + /// + /// Retry policy of the action. + /// + [DataMember] + public RetryPolicy RetryPolicy { get; private set; } + + /// + /// A flag that represents how to handle the exit of the action due to retry exhaustion. If false (default), then the session will end once + /// retries are exhausted or no retries are specified. If true and retries are exhausted, the action will continue on as if it were successful + /// after committing a "RetryExhaustedOnAction" response. + /// + [DataMember] + public bool ContinuationOnRetryExhaustion { get; set; } + } + + /// + /// The retry policy for the TreeAction. + /// + [DataContract] + public class RetryPolicy + { + /// + /// The retry policy type. + /// + [DataMember(IsRequired = true)] + public RetryPolicyType Type { get; private set; } + + /// + /// Minimum backoff time in milliseconds. + /// When retrying an action, wait at least this long before your next attempt. + /// This is useful to ensure actions are not retried too quickly. + /// + [DataMember] + public long MinBackoffMs { get; private set; } + + /// + /// Maximum backoff time in milliseconds. + /// When retrying an action, wait at most this long before your next attempt. + /// This is useful to ensure exponential backoff doesn't wait too long. + /// + [DataMember] + public long MaxBackoffMs { get; private set; } + } + + /// + /// The retry policy types. + /// + [DataContract] + public enum RetryPolicyType + { + /// + /// Do not retry. + /// + [EnumMember] + None = 0, + + /// + /// Retry at a fixed interval every MinBackoffMs. + /// + [EnumMember] + FixedInterval = 1, + + /// + /// Retry with an exponential backoff. + /// Start with MinBackoffMs, then wait Math.Min(MinBackoffMs * 2^(retryCount), MaxBackoffMs). + /// + [EnumMember] + ExponentialBackoff = 2 + + // TODO: Add a FixedCount type that will give the full timeout duration for the set number of retries. + } + + /// + /// The tree node types. + /// + [DataContract] + public enum TreeNodeType + { + /// + /// Undefined. + /// + [EnumMember] + Unknown = 0, + + /// + /// Selection type node. + /// + [EnumMember] + Selection = 1, + + /// + /// Action type node. + /// This node includes TreeAction(s). + /// + [EnumMember] + Action = 2, + + /// + /// Leaf type node. + /// This represents an end state in tree. + /// + [EnumMember] + Leaf = 3 + } +} \ No newline at end of file diff --git a/Forge/Forge.DataContracts/Properties/AssemblyInfo.cs b/Forge/Forge.DataContracts/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..f7975a3 --- /dev/null +++ b/Forge/Forge.DataContracts/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Forge.DataContracts")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Forge.DataContracts")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("c49a8494-13e5-4214-8434-708ba280c5b1")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Forge/Forge.TreeWalker.UnitTests/Forge.TreeWalker.UnitTests.csproj b/Forge/Forge.TreeWalker.UnitTests/Forge.TreeWalker.UnitTests.csproj new file mode 100644 index 0000000..5dd596f --- /dev/null +++ b/Forge/Forge.TreeWalker.UnitTests/Forge.TreeWalker.UnitTests.csproj @@ -0,0 +1,86 @@ + + + + + + Debug + AnyCPU + {A33AF1FF-1291-4CB1-A733-3243E1FE967E} + Library + Properties + Forge.TreeWalker.UnitTests + Forge.TreeWalker.UnitTests + v4.6.2 + 512 + {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + 15.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages + False + UnitTest + + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + ..\packages\MSTest.TestFramework.1.3.2\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.dll + + + ..\packages\MSTest.TestFramework.1.3.2\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll + + + ..\packages\Newtonsoft.Json.12.0.2\lib\net45\Newtonsoft.Json.dll + + + + + + + + + + + + + + + + + + {c49a8494-13e5-4214-8434-708ba280c5b1} + Forge.DataContracts + + + {00fc1c22-6ae9-4f60-8a3e-05885ba34c9c} + Forge.TreeWalker + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + \ No newline at end of file diff --git a/Forge/Forge.TreeWalker.UnitTests/Properties/AssemblyInfo.cs b/Forge/Forge.TreeWalker.UnitTests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..f2a003d --- /dev/null +++ b/Forge/Forge.TreeWalker.UnitTests/Properties/AssemblyInfo.cs @@ -0,0 +1,20 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("Forge.TreeWalker.UnitTests")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Forge.TreeWalker.UnitTests")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +[assembly: ComVisible(false)] + +[assembly: Guid("a33af1ff-1291-4cb1-a733-3243e1fe967e")] + +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Forge/Forge.TreeWalker.UnitTests/app.config b/Forge/Forge.TreeWalker.UnitTests/app.config new file mode 100644 index 0000000..e4fbd2a --- /dev/null +++ b/Forge/Forge.TreeWalker.UnitTests/app.config @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Forge/Forge.TreeWalker.UnitTests/packages.config b/Forge/Forge.TreeWalker.UnitTests/packages.config new file mode 100644 index 0000000..3eed4a9 --- /dev/null +++ b/Forge/Forge.TreeWalker.UnitTests/packages.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Forge/Forge.TreeWalker.UnitTests/test/ExecutorUnitTests.cs b/Forge/Forge.TreeWalker.UnitTests/test/ExecutorUnitTests.cs new file mode 100644 index 0000000..f094a32 --- /dev/null +++ b/Forge/Forge.TreeWalker.UnitTests/test/ExecutorUnitTests.cs @@ -0,0 +1,311 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// Tests the ExpressionExecutor class. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker.UnitTests +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + using Forge.ExternalTypes; + using Forge.TreeWalker; + + [TestClass] + public class ExecutorUnitTests + { + private dynamic UserContext; + + [TestInitialize] + public void TestInitialize() + { + this.UserContext = new System.Dynamic.ExpandoObject(); + this.UserContext.GetTopic = (Func)((topicName) => + { + dynamic result = new System.Dynamic.ExpandoObject(); + result.ResourceType = "Node"; + return result; + }); + + this.UserContext.GetCount = (Func)(() => + { + return 1; + }); + } + + [TestMethod] + public void TestExecutor_Success_bool() + { + this.UserContext.Foo = "Bar"; + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + string expression = "UserContext.Foo == \"Bar\""; + + Assert.IsTrue(ex.Execute(expression).GetAwaiter().GetResult(), "Expected ExpressionExecutor to successfully evaluate a true expression."); + } + + [TestMethod] + public void TestExecutor_Success_long() + { + // Note that long requires casting where int does not. + this.UserContext.Foo = (long)1000; + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + string expression = "UserContext.Foo"; + + Assert.AreEqual((long)1000, ex.Execute(expression).GetAwaiter().GetResult(), "Expected ExpressionExecutor to successfully evaluate the expression."); + } + + [TestMethod] + public void TestExecutor_Success_int() + { + this.UserContext.Foo = 1000; + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + string expression = "UserContext.Foo"; + + Assert.AreEqual(1000, ex.Execute(expression).GetAwaiter().GetResult(), "Expected ExpressionExecutor to successfully evaluate the expression."); + } + + [TestMethod] + public void TestExecutor_Success_ExecuteTwice() + { + this.UserContext.Foo = "Bar"; + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + string expression = "UserContext.Foo == \"Bar\" && UserContext.GetTopic(\"TopicName\").ResourceType == \"Node\""; + + // Test - confirm Execute can compile and execute the same code twice without crashing. + Assert.IsTrue(ex.Execute(expression).GetAwaiter().GetResult(), "Expected ExpressionExecutor to successfully evaluate a true expression."); + Assert.IsTrue(ex.Execute(expression).GetAwaiter().GetResult(), "Expected ExpressionExecutor to successfully evaluate a true expression."); + } + + [TestMethod] + public void TestExecutor_Fail_MissingDefinitions() + { + this.UserContext.Foo = "Bar"; + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + string expression = "UserContext.Bar == \"Bar\""; + + try + { + ex.Execute(expression).GetAwaiter().GetResult(); + Assert.Fail("Expected ExpressionExecutor to fail evaluating an expression when UserContext does not contain a necessary definitions."); + } + catch (Exception) + { + } + } + + [TestMethod] + public void TestExecutor_Fail_BadExpression() + { + this.UserContext.Foo = "Bar"; + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + string expression = "UserContext.Foo"; + + Assert.ThrowsException(() => + { + ex.Execute(expression).GetAwaiter().GetResult(); + }, "Expected ExpressionExecutor to fail evaluating an expression that cannot be evaluated."); + } + + [TestMethod] + public void TestExecutor_Success_CompileWithExternalDependencies() + { + this.UserContext.Foo = ExternalTestType.TestEnum; + List dependencies = new List(); + dependencies.Add(typeof(ExternalTestType)); + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, dependencies); + string expression = "UserContext.Foo == ExternalTestType.TestEnum"; + Assert.IsTrue(ex.Execute(expression).GetAwaiter().GetResult(), "Expected ExpressionExecutor to successfully evaluate a true expression."); + } + + [TestMethod] + public void TestExecutor_Fail_CompileExpressionWithMissingDependencies() + { + this.UserContext.Foo = ExternalTestType.ExampleEnum; + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + string expression = "UserContext.Foo == ExternalTestType.ExampleEnum"; + + try + { + ex.Execute(expression).GetAwaiter().GetResult(); + Assert.Fail("Expected ExpressionExecutor to fail evaluating an expression because runtime assembly is missing required dependencies."); + } + catch (Exception) + { + } + } + + [TestMethod] + public void TestExecutor_Fail_CompileExpressionWithExternalDependenciesAndMissingDependencies() + { + this.UserContext.Foo = ExternalTestType.TestEnum; + this.UserContext.Bar = DiffNamespaceType.TestOne; + List dependencies = new List(); + dependencies.Add(typeof(ExternalTestType)); + + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, dependencies); + string expression = "UserContext.Foo == ExternalTestType.TestEnum && UserContext.Bar == DiffNamespaceType.TestOne"; + + try + { + ex.Execute(expression).GetAwaiter().GetResult(); + Assert.Fail("Expected ExpressionExecutor to fail evaluating an expression because runtime assembly is missing required dependencies."); + } + catch (Exception) + { + } + } + + [TestMethod] + public void TestExecutor_Success_CompileExpressionWithMissingDependenciesButOtherExternalTypesInSameNamespace() + { + this.UserContext.Foo = ExternalTestType.TestEnum; + this.UserContext.Bar = TestType.Test; + List dependencies = new List(); + dependencies.Add(typeof(ExternalTestType)); + + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, dependencies); + string expression = "UserContext.Foo == ExternalTestType.TestEnum && UserContext.Bar == TestType.Test"; + Assert.IsTrue(ex.Execute(expression).GetAwaiter().GetResult(), "Expected ExpressionExecutor to successfully evaluate a true expression."); + } + + [TestMethod] + public void TestExecutor_Success_CompileExpressionWithForgeDefaultDependenciesBeingPassedInExternally() + { + this.UserContext.Foo = "Foo"; + List dependencies = new List(); + // Tasks dependency needed by Forge by default. + dependencies.Add(typeof(Task)); + // Reflection dependency needed by Forge for runtime compilation. + dependencies.Add(typeof(Type)); + + // Default dependencies are expected to be tossed away internally in ExpressionExecutor. + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + string expression = "UserContext.Foo == \"Foo\""; + Assert.IsTrue(ex.Execute(expression).GetAwaiter().GetResult(), "Expected ExpressionExecutor to successfully evaluate a true expression."); + } + + [TestMethod] + public void TestExecutor_Success_ChangingFunctionDefinition() + { + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + + // Rewritting GetCount to return 2 + string expression="UserContext.GetCount = new Func(() => 2)"; + Assert.AreEqual(this.UserContext.GetCount(), 1); + ex.Execute>(expression).GetAwaiter().GetResult(); + Assert.AreEqual(this.UserContext.GetCount(), 2); + } + + [TestMethod] + public void TestExecutor_Fail_ChangingFunctionReturnType() + { + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + + // Changing return type of GetCount + string expression = "UserContext.GetCount = new Func(() => \"Test\")"; + Assert.ThrowsException(() => + { + // Since expected return type matches the original Func type, this should throw an error + ex.Execute>(expression).GetAwaiter().GetResult(); + }, "Expected ExpressionExecutor to fail evaluating can not change return type"); + } + + [TestMethod] + public void TestExecutor_Success_ChangingFunctionReturnType() + { + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + + // Changing return type of GetCount + string expression = "UserContext.GetCount = new Func(() => \"Test\")"; + Assert.AreEqual(this.UserContext.GetCount(), 1); + // Since expected return type has been updated, this should pass + ex.Execute>(expression).GetAwaiter().GetResult(); + Assert.AreEqual(this.UserContext.GetCount(), "Test"); + } + + [TestMethod] + public void TestExecutor_Fail_ExecutingMultipleStatements() + { + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + + // Changing return type of GetCount + string expression = "int x = UserContext.GetCount() + 5; UserContext.GetCount = new Func(() => x); return UserContext.GetCount()"; + + try + { + ex.Execute>(expression).GetAwaiter().GetResult(); + Assert.Fail("Expected ExpressionExecutor to fail evaluating can not pass multiple statements to expression."); + } + catch (Exception) + { + } + } + + [TestMethod] + public void TestExecutor_Success_WaitForDelegate() + { + this.UserContext.Foo = "Bar"; + string expression = "(Func)(() => {return UserContext.Foo == \"Bar\";})"; + + // Casting the expression to Func since the executor will return a delegate of type Func + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + dynamic expressionResult = ex.Execute(expression).GetAwaiter().GetResult(); + + if (expressionResult.GetType() == typeof(Func)) + { + Assert.IsTrue(expressionResult()); + this.UserContext.Foo = "Far"; + Assert.IsFalse(expressionResult()); + } + else + { + Assert.Fail(string.Format("Expected expression to be of type bool but was {0}", expressionResult.GetType())); + } + } + + + [TestMethod] + public void TestExecutor_Success_WaitForDelegateAsync() + { + this.UserContext.Foo = "Bar"; + string expression = "(Func>)(() => {return Task.FromResult(UserContext.Foo == \"Bar\");})"; + + // Casting the expression to Func since the executor will return a delegate of type Func + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + dynamic expressionResult = ex.Execute(expression).GetAwaiter().GetResult(); + + if (expressionResult.GetType() == typeof(Func>)) + { + // expressionResult() return Task, doing .GetAwaiter().GetResult() again returns bool + Assert.IsTrue(expressionResult().GetAwaiter().GetResult()); + this.UserContext.Foo = "Far"; + Assert.IsFalse(expressionResult().GetAwaiter().GetResult()); + } + else + { + Assert.Fail(string.Format("Expected expression to be of type Task but was {0}", expressionResult.GetType())); + } + } + + [TestMethod] + public void TestExecutor_ScriptCacheContainsKey() + { + this.UserContext.Foo = "Bar"; + string expression = "UserContext.Foo == \"Bar\" && UserContext.GetTopic(\"TopicName\").ResourceType == \"Node\""; + + // Test - confirm ExpressionExecutor script cache does not contain script before executing. + ExpressionExecutor ex = new ExpressionExecutor(null, this.UserContext, null); + Assert.IsFalse(ex.ScriptCacheContainsKey(expression)); + + // Test - confirm ExpressionExecutor script cache does contain script after executing. + Assert.IsTrue(ex.Execute(expression).GetAwaiter().GetResult()); + Assert.IsTrue(ex.ScriptCacheContainsKey(expression)); + } + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker.UnitTests/test/ExternalTestType.cs b/Forge/Forge.TreeWalker.UnitTests/test/ExternalTestType.cs new file mode 100644 index 0000000..fa92ba3 --- /dev/null +++ b/Forge/Forge.TreeWalker.UnitTests/test/ExternalTestType.cs @@ -0,0 +1,36 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// Test type used to test external Forge dependency injection. +// +//----------------------------------------------------------------------- + +namespace Forge.ExternalTypes +{ + + public enum ExternalTestType + { + ExampleEnum = 0, + + TestEnum = 1, + } + + public enum TestType + { + Example = 0, + + Test = 1 + } +} + +namespace Forge.TreeWalker.UnitTests +{ + public enum DiffNamespaceType + { + TestOne, + + TestTwo + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker.UnitTests/test/ForgeSchemaHelper.cs b/Forge/Forge.TreeWalker.UnitTests/test/ForgeSchemaHelper.cs new file mode 100644 index 0000000..faf8768 --- /dev/null +++ b/Forge/Forge.TreeWalker.UnitTests/test/ForgeSchemaHelper.cs @@ -0,0 +1,425 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// Helper class for TreeWalkerUnitTests that holds ForgeSchema examples. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker.UnitTests +{ + public static class ForgeSchemaHelper + { + public const string TardigradeScenario = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Selection"", + ""ChildSelector"": + [ + { + ""ShouldSelect"": ""C#|UserContext.ResourceType == \""Node\"""", + ""Child"": ""Node"" + }, + { + ""ShouldSelect"": ""C#|UserContext.ResourceType == \""Container\"""", + ""Child"": ""Container"" + } + ] + }, + ""Container"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Container_CollectDiagnosticsAction"": { + ""Action"": ""CollectDiagnosticsAction"", + ""Input"": + { + ""Command"": ""RunCollectDiagnostics.exe"" + } + } + }, + ""ChildSelector"": + [ + { + ""ShouldSelect"": ""C#|Session.GetLastActionResponse().Status == \""Success\"""", + ""Child"": ""Tardigrade"" + } + ] + }, + ""Tardigrade"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Tardigrade_TardigradeAction"": { + ""Action"": ""TardigradeAction"" + } + }, + ""ChildSelector"": + [ + { + ""ShouldSelect"": ""C#|Session.GetLastActionResponse().Status == \""Success\"""", + ""Child"": ""Tardigrade_Success"" + } + ] + }, + ""Tardigrade_Success"": { + ""Type"": ""Leaf"" + } + } + }"; + + public const string ActionException_Fail = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Root_TestDelayExceptionAction"": { + ""Action"": ""TestDelayExceptionAction"", + ""Input"": + { + ""ThrowException"": true + } + } + } + } + } + }"; + + public const string ActionException_ContinuationOnRetryExhaustion = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Root_TestDelayExceptionAction"": { + ""Action"": ""TestDelayExceptionAction"", + ""Input"": + { + ""ThrowException"": true + }, + ""ContinuationOnRetryExhaustion"": true + } + } + } + } + }"; + + public const string ActionDelay_Fail = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Root_TestDelayExceptionAction"": { + ""Action"": ""TestDelayExceptionAction"", + ""Input"": + { + ""DelayMilliseconds"": 50 + }, + ""Timeout"": 10, + } + } + } + } + }"; + + public const string ActionDelay_ContinuationOnTimeout = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Root_TestDelayExceptionAction"": { + ""Action"": ""TestDelayExceptionAction"", + ""Input"": + { + ""DelayMilliseconds"": 50 + }, + ""Timeout"": 10, + ""ContinuationOnTimeout"": true + } + } + } + } + }"; + + public const string ActionDelay_ContinuationOnTimeout_RetryPolicy_TimeoutInAction = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Root_TestDelayExceptionAction"": { + ""Action"": ""TestDelayExceptionAction"", + ""Input"": + { + ""DelayMilliseconds"": 50, + ""ThrowException"": true + }, + ""Timeout"": 100, + ""RetryPolicy"": + { + ""Type"": ""FixedInterval"", + ""MinBackoffMs"": 25 + }, + ""ContinuationOnTimeout"": true + } + } + } + } + }"; + + public const string ActionDelay_ContinuationOnTimeout_RetryPolicy_TimeoutBetweenRetries = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Root_TestDelayExceptionAction"": { + ""Action"": ""TestDelayExceptionAction"", + ""Input"": + { + ""DelayMilliseconds"": 25, + ""ThrowException"": true + }, + ""Timeout"": 50, + ""RetryPolicy"": + { + ""Type"": ""FixedInterval"", + ""MinBackoffMs"": 100 + }, + ""ContinuationOnTimeout"": true + } + } + } + } + }"; + + public const string NoChildMatch = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Selection"", + ""ChildSelector"": + [ + { + ""ShouldSelect"": ""C#|false"", + ""Child"": ""LeafNode"" + } + ] + }, + ""LeafNode"": { + ""Type"": ""Leaf"" + } + } + } + "; + + public const string TestEvaluateInputTypeAction = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Root_TestEvaluateInputTypeAction"": { + ""Action"": ""TestEvaluateInputTypeAction"", + ""Input"": + { + ""Command"": ""tasklist"", + ""IntExpression"": ""C#|UserContext.GetCount()"", + ""BoolExpression"": ""C#|true"", + ""NestedObject"": + { + ""Name"": ""C#|string.Format(\""{0}_{1}\"", \""MyName\"", UserContext.Name)"", + ""Value"": ""MyValue"" + }, + ""ObjectArray"": + [ + { + ""Name"": ""C#|UserContext.Name"", + ""Value"": ""FirstValue"" + }, + { + ""Name"": ""SecondName"", + ""Value"": ""SecondValue"" + } + ], + ""StringArray"": [""value1"", ""value2""], + ""LongArray"": [1, 3, 2], + ""BoolDelegate"": ""C#|(Func)(() => {return UserContext.GetCount() == 1;})"", + ""BoolDelegateAsync"": ""C#|(Func>)(async() => { return await UserContext.GetCountAsync() == 2; })"" + } + } + } + } + } + } + "; + + public const string TestEvaluateInputType_FailOnField_Action = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Root_TestEvaluateInputType_FailOnField_Action"": { + ""Action"": ""TestEvaluateInputType_FailOnField_Action"", + ""Input"": + { + ""UnexpectedField"": true + }, + ""ContinuationOnRetryExhaustion"": true + } + } + } + } + } + "; + + public const string TestEvaluateInputTypeAction_UnexpectedPropertyFail = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Root_TestEvaluateInputTypeAction"": { + ""Action"": ""TestEvaluateInputTypeAction"", + ""Input"": + { + ""UnexpectedProperty"": true + }, + ""ContinuationOnRetryExhaustion"": true + } + } + } + } + } + "; + + public const string TestEvaluateInputType_FailOnNonEmptyCtor_Action = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Root_TestEvaluateInputType_FailOnNonEmptyCtor_Action"": { + ""Action"": ""TestEvaluateInputType_FailOnNonEmptyCtor_Action"", + ""Input"": + { + ""BoolProperty"": true + }, + ""ContinuationOnRetryExhaustion"": true + } + } + } + } + } + "; + + public const string LeafNodeSummaryAction = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Leaf"", + ""Actions"": + { + ""Root_LeafNodeSummaryAction"": { + ""Action"": ""LeafNodeSummaryAction"", + ""Input"": + { + ""Status"": ""Success"", + ""StatusCode"": 1, + ""Output"": ""TheResult"" + } + } + } + } + } + } + "; + + public const string LeafNodeSummaryAction_InputIsActionResponse = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": + { + ""Root_CollectDiagnosticsAction"": { + ""Action"": ""CollectDiagnosticsAction"", + ""Input"": + { + ""Command"": ""TheCommand"" + } + } + }, + ""ChildSelector"": + [ + { + ""Child"": ""LeafNodeSummaryTest"" + } + ] + }, + ""LeafNodeSummaryTest"": { + ""Type"": ""Leaf"", + ""Actions"": + { + ""LeafNodeSummaryTest_LeafNodeSummaryAction"": { + ""Action"": ""LeafNodeSummaryAction"", + ""Input"": ""C#|Session.GetLastActionResponse()"" + } + } + } + } + } + "; + + public const string ExternalExecutors = @" + { + ""Tree"": + { + ""Root"": { + ""Type"": ""Leaf"", + ""Actions"": + { + ""Root_LeafNodeSummaryAction"": { + ""Action"": ""LeafNodeSummaryAction"", + ""Input"": + { + ""Status"": ""External|StatusResult"" + } + } + } + } + } + } + "; + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker.UnitTests/test/TreeWalkerUnitTests.cs b/Forge/Forge.TreeWalker.UnitTests/test/TreeWalkerUnitTests.cs new file mode 100644 index 0000000..7ff589d --- /dev/null +++ b/Forge/Forge.TreeWalker.UnitTests/test/TreeWalkerUnitTests.cs @@ -0,0 +1,762 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// Tests the TreeWalkerSession class. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker.UnitTests +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + using Forge.Attributes; + using Forge.DataContracts; + using Forge.TreeWalker; + using Forge.TreeWalker.ForgeExceptions; + + using Newtonsoft.Json; + + [TestClass] + public class TreeWalkerUnitTests + { + private Guid sessionId; + private IForgeDictionary forgeState = new ForgeDictionary(new Dictionary(), Guid.Empty); + private dynamic UserContext = new System.Dynamic.ExpandoObject(); + private ITreeWalkerCallbacks callbacks; + private CancellationToken token; + private TreeWalkerParameters parameters; + private TreeWalkerSession session; + + public void TestInitialize(string jsonSchema) + { + // Initialize contexts, callbacks, and actions. + this.sessionId = Guid.NewGuid(); + this.forgeState = new ForgeDictionary(new Dictionary(), this.sessionId); + this.callbacks = new TreeWalkerCallbacks(); + this.token = new CancellationTokenSource().Token; + + this.UserContext.Name = "MyName"; + this.UserContext.ResourceType = "Container"; + + this.UserContext.GetCount = (Func)(() => + { + return 1; + }); + + this.UserContext.GetCountAsync = (Func>)(() => + { + return Task.FromResult(2); + }); + + this.parameters = new TreeWalkerParameters( + this.sessionId, + jsonSchema, + this.forgeState, + this.callbacks, + this.token) + { + UserContext = this.UserContext, + ForgeActionsAssembly = typeof(CollectDiagnosticsAction).Assembly + }; + + this.session = new TreeWalkerSession(this.parameters); + } + + [TestMethod] + public void TestTreeWalkerSession_Constructor() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TardigradeScenario); + + // Test 1 - Verify jsonSchema was successfully deserialized in constructor. + Assert.AreEqual("Action", this.session.Schema.Tree["Tardigrade"].Type.ToString()); + + // Test 2 - Verify the Status is Initialized. + Assert.AreEqual("Initialized", this.session.Status, "Expected WalkTree status to be Initialized after initializing TreeWalkerSession."); + } + + [TestMethod] + public void TestTreeWalkerSession_VisitNode_Success() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TardigradeScenario); + + // Test - VisitNode and expect the first child to be returned. + string expected = "Tardigrade"; + string actualNextTreeNodeKey = this.session.VisitNode("Container").GetAwaiter().GetResult(); + + Assert.AreEqual(expected, actualNextTreeNodeKey, "Expected VisitNode(Container) to return Tardigrade."); + } + + [TestMethod] + public void TestTreeWalkerSession_VisitNode_LeafNode_Success() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TardigradeScenario); + + // Test - VisitNode on node of Leaf type and confirm it does not throw. + string expected = null; + string actualNextTreeNodeKey = this.session.VisitNode("Tardigrade_Success").GetAwaiter().GetResult(); + + Assert.AreEqual(expected, actualNextTreeNodeKey, "Expected VisitNode(Tardigrade_Success) to return without throwing."); + } + + [TestMethod] + public void TestTreeWalkerSession_VisitNode_NoTimeout_Success() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TardigradeScenario); + + // Test - VisitNode with no Timeout and execute an Action with no Timeout set. Confirm we do not throw exceptions. + string expected = "Tardigrade_Success"; + string actualNextTreeNodeKey = this.session.VisitNode("Tardigrade").GetAwaiter().GetResult(); + Assert.AreEqual(expected, actualNextTreeNodeKey, "Expected VisitNode(Tardigrade) to return Tardigrade_Success without throwing exception."); + } + + [TestMethod] + public void TestTreeWalkerSession_WalkTree_Success() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TardigradeScenario); + + // Test - WalkTree and expect the Status to be RanToCompletion. + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to run to completion."); + } + + [TestMethod] + public void TestTreeWalkerSession_WalkTree_ActionThrowsException_TimeoutOnAction() + { + // Initialize TreeWalkerSession with a schema containing Action that throws exception. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.ActionException_Fail); + + // Test - WalkTree and expect the Status to be TimeoutOnAction due to unexpected exceptions thrown in action. + Assert.ThrowsException(() => + { + string temp = this.session.WalkTree("Root").GetAwaiter().GetResult(); + }, "Expected WalkTree to timeout on action because the Action threw exceptions with no Continuation flags set."); + + string actualStatus = this.session.Status; + Assert.AreEqual("TimeoutOnAction", actualStatus, "Expected WalkTree to timeout on action because the Action threw exceptions with no Continuation flags set."); + } + + [TestMethod] + public void TestTreeWalkerSession_WalkTree_ActionThrowsException_ContinuationOnRetryExhaustion() + { + // Initialize TreeWalkerSession with a schema containing Action that throws exception but has ContinuationOnRetryExhaustion flag set. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.ActionException_ContinuationOnRetryExhaustion); + + // Test - Expect WalkTree to be successful because the TreeAction exhausted retries but ContinuationOnRetryExhaustion flag was set. + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to be successful because the TreeAction exhausted retries but ContinuationOnRetryExhaustion flag was set."); + + ActionResponse actionResponse = this.session.GetLastActionResponse(); + Assert.AreEqual("RetryExhaustedOnAction", actionResponse.Status, "Expected WalkTree to be successful because the TreeAction exhausted retries but ContinuationOnRetryExhaustion flag was set."); + } + + [TestMethod] + public void TestTreeWalkerSession_WalkTree_ActionHasDelay_TimeoutOnAction() + { + // Initialize TreeWalkerSession with a schema containing Action that will time out. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.ActionDelay_Fail); + + // Test - WalkTree and expect the Status to be TimeoutOnAction due to Action timing out. + Assert.ThrowsException(() => + { + string temp = this.session.WalkTree("Root").GetAwaiter().GetResult(); + }, "Expected WalkTree to timeout on action because the Action timed out with no Continuation flags set."); + + string actualStatus = this.session.Status; + Assert.AreEqual("TimeoutOnAction", actualStatus, "Expected WalkTree to timeout on action because the Action timed out with no Continuation flags set."); + } + + [TestMethod] + public void TestTreeWalkerSession_WalkTree_ActionHasDelay_ContinuationOnTimeout() + { + // Initialize TreeWalkerSession with a schema containing Action that will time out but has ContinuationOnTimeout flag set. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.ActionDelay_ContinuationOnTimeout); + + // Test - Expect WalkTree to be successful because the TreeAction timed out but ContinuationOnTimeout flag was set. + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to be successful because the TreeAction timed out but ContinuationOnTimeout flag was set."); + + ActionResponse actionResponse = this.session.GetLastActionResponse(); + Assert.AreEqual("TimeoutOnAction", actionResponse.Status, "Expected WalkTree to be successful because the TreeAction timed out but ContinuationOnTimeout flag was set."); + } + + [TestMethod] + public void TestTreeWalkerSession_WalkTree_ActionHasDelay_ContinuationOnTimeout_RetryPolicy_TimeoutInAction() + { + // Initialize TreeWalkerSession with a schema containing Action with RetryPolicy that will time out inside the Action but has ContinuationOnTimeout flag set. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.ActionDelay_ContinuationOnTimeout_RetryPolicy_TimeoutInAction); + + // Test - Expect WalkTree to be successful because the TreeAction timed out but ContinuationOnTimeout flag was set. + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to be successful because the TreeAction timed out but ContinuationOnTimeout flag was set."); + + ActionResponse actionResponse = this.session.GetLastActionResponse(); + Assert.AreEqual("TimeoutOnAction", actionResponse.Status, "Expected WalkTree to be successful because the TreeAction timed out but ContinuationOnTimeout flag was set."); + } + + [TestMethod] + public void TestTreeWalkerSession_WalkTree_ActionHasDelay_ContinuationOnTimeout_RetryPolicy_TimeoutBetweenRetries() + { + // Initialize TreeWalkerSession with a schema containing Action with RetryPolicy that will time out between retry attempts but has ContinuationOnTimeout flag set. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.ActionDelay_ContinuationOnTimeout_RetryPolicy_TimeoutBetweenRetries); + + // Test - Expect WalkTree to be successful because the TreeAction timed out but ContinuationOnTimeout flag was set. + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to be successful because the TreeAction timed out but ContinuationOnTimeout flag was set."); + + ActionResponse actionResponse = this.session.GetLastActionResponse(); + Assert.AreEqual("TimeoutOnAction", actionResponse.Status, "Expected WalkTree to be successful because the TreeAction timed out but ContinuationOnTimeout flag was set."); + } + + [TestMethod] + public void TestTreeWalkerSession_WalkTree_CancelledBeforeExecution() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TardigradeScenario); + + // Test - CancelWalkTree before WalkTree and expect the Status to be CancelledBeforeExecution. + this.session.CancelWalkTree(); + + Assert.ThrowsException(() => + { + string temp = this.session.WalkTree("Root").GetAwaiter().GetResult(); + }, "Expected WalkTree to throw exception after calling CancelWalkTree."); + + string actualStatus = this.session.Status; + Assert.AreEqual("CancelledBeforeExecution", actualStatus, "Expected WalkTree to be cancelled before execution after calling CancelWalkTree."); + } + + [TestMethod] + public void TestTreeWalkerSession_WalkTree_ActionWithDelay_CancelWalkTree() + { + // Initialize TreeWalkerSession with a schema containing Action with delay. + // This gives us time to start WalkTree before calling CancelWalkTree. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.ActionDelay_ContinuationOnTimeout_RetryPolicy_TimeoutInAction); + + // Test - WalkTree then CancelWalkTree while WalkTree is running and expect the Status to be Cancelled. + Task task = this.session.WalkTree("Root"); + Thread.Sleep(25); + this.session.CancelWalkTree(); + Assert.ThrowsException(() => + { + string temp = task.GetAwaiter().GetResult(); + }, "Expected WalkTree to throw exception after calling CancelWalkTree."); + + string actualStatus = this.session.Status; + Assert.AreEqual("Cancelled", actualStatus, "Expected WalkTree to be cancelled after calling CancelWalkTree."); + } + + [TestMethod] + public void TestTreeWalkerSession_WalkTree_Failed_MissingKey() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TardigradeScenario); + + // Test - WalkTree and expect the Status to be Failed because the key does not exist which threw an exception. + Assert.ThrowsException(() => + { + string temp = this.session.WalkTree("MissingKey").GetAwaiter().GetResult(); + }, "Expected WalkTree to fail because the key does not exist."); + + string actualStatus = this.session.Status; + Assert.AreEqual("Failed", actualStatus, "Expected WalkTree to fail because the key does not exist."); + } + + [TestMethod] + public void TestTreeWalkerSession_WalkTree_NoChildMatched() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.NoChildMatch); + + // Test - WalkTree and expect the Status to be NoChildMatched. + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion_NoChildMatched", actualStatus, "Expected WalkTree to end with NoChildMatched status."); + } + + [TestMethod] + public void TestGetCurrentTreeNode() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TardigradeScenario); + + // Test 1 - Confirm GetCurrentTreeNode returns null before walking tree. + Assert.AreEqual(null, this.session.GetCurrentTreeNode().GetAwaiter().GetResult(), "Expected CurrentTreeNode to be null before starting walk tree."); + + // Test 2 - Confirm GetCurrentTreeNode returns last node visited after walking tree. + this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("Tardigrade_Success", this.session.GetCurrentTreeNode().GetAwaiter().GetResult(), "Expected CurrentTreeNode to equal the last node visited."); + } + + [TestMethod] + public void TestGetLastTreeAction() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TardigradeScenario); + + // Test 1 - Confirm GetLastTreeAction returns null before walking tree. + Assert.AreEqual(null, this.session.GetLastTreeAction().GetAwaiter().GetResult(), "Expected LastTreeAction to be null before starting walk tree."); + + // Test 2 - Confirm GetLastTreeAction returns last tree action executed after walking tree. + this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("Tardigrade_TardigradeAction", this.session.GetLastTreeAction().GetAwaiter().GetResult(), "Expected LastTreeAction to equal the last tree action executed."); + } + + [TestMethod] + public void TestGetOutput() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TardigradeScenario); + + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to run to completion."); + + // Test 1 - Confirm ActionResponse can be read from GetOutputAsync. + ActionResponse actionResponse = this.session.GetOutputAsync("Container_CollectDiagnosticsAction").GetAwaiter().GetResult(); + Assert.AreEqual( + "RunCollectDiagnostics.exe_Results", + actionResponse.Output, + "Expected to successfully read ActionResponse.Output."); + + Assert.AreEqual( + "Success", + actionResponse.Status, + "Expected to successfully read ActionResponse.Status."); + + // Test 2 - Confirm ActionResponse can be read from GetOutput. + actionResponse = this.session.GetOutput("Container_CollectDiagnosticsAction"); + Assert.AreEqual( + "RunCollectDiagnostics.exe_Results", + actionResponse.Output, + "Expected to successfully read ActionResponse.Output."); + + Assert.AreEqual( + "Success", + actionResponse.Status, + "Expected to successfully read ActionResponse.Status."); + + // Test 3 - Confirm ActionResponse can be read from GetLastActionResponseAsync. + actionResponse = this.session.GetLastActionResponseAsync().GetAwaiter().GetResult(); + Assert.AreEqual( + "Success", + actionResponse.Status, + "Expected to successfully read ActionResponse.Status."); + + // Test 4 - Confirm ActionResponse can be read from GetLastActionResponse. + actionResponse = this.session.GetLastActionResponse(); + Assert.AreEqual( + "Success", + actionResponse.Status, + "Expected to successfully read ActionResponse.Status."); + } + + // TODO: Add back test once we decide how we want to handle this case: Should people be able to change function definitions? + // TestScript in local jsonSchema is related to this test. + // [TestMethod] + // public void TestTreeWalkerSession_WalkTree_Success_ChangingFunctionDefinition() + // { + // this.TestInitialize(jsonSchema: ForgeSchemaHelper.TardigradeScenario); + + // Assert.AreEqual(this.UserContext.GetCount(), 1); + // string actual = this.session.WalkTree("TestScript").GetAwaiter().GetResult(); + // Assert.AreEqual(2, this.UserContext.GetCount()); + // } + + // TODO: Add back test once we decide how we want to handle this case: Should people be able to change function definitions? + // TestScript in local jsonSchema is related to this test. + + [TestMethod] + public void Test_EvaluateInputType_Success() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TestEvaluateInputTypeAction); + + // Test - WalkTree to execute an Action with its ActionInput type defined in the ActionDefinition.InputType. + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + Assert.AreEqual( + true, + this.session.GetLastActionResponse().Output, + "Expected to successfully retrieve the Func output value from the action."); + } + + [TestMethod] + public void Test_EvaluateInputType_UnexpectedFieldFail() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TestEvaluateInputType_FailOnField_Action); + + // Test - WalkTree and expect the Status to be Failed_EvaluateDynamicProperty + // because ActionInput type defined in the ActionDefinition.InputType contained an unexpected public Field. + string actual; + Assert.ThrowsException(() => + { + actual = this.session.WalkTree("Root").GetAwaiter().GetResult(); + }, "Expected WalkTree to fail because ActionInput type defined in the ActionDefinition.InputType contained an unexpected public Field."); + + actual = this.session.Status; + Assert.AreEqual( + "Failed_EvaluateDynamicProperty", + actual, + "Expected WalkTree to fail because ActionInput type defined in the ActionDefinition.InputType contained an unexpected public Field."); + } + + [TestMethod] + public void Test_EvaluateInputType_UnexpectedPropertyFail() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TestEvaluateInputTypeAction_UnexpectedPropertyFail); + + // Test - WalkTree and expect the Status to be Failed_EvaluateDynamicProperty + // because the schema contained a Property that does not exist in ActionDefinition.InputType. + string actual; + Assert.ThrowsException(() => + { + actual = this.session.WalkTree("Root").GetAwaiter().GetResult(); + }, "Expected WalkTree to fail because the schema contained a Property that does not exist in ActionDefinition.InputType."); + + actual = this.session.Status; + Assert.AreEqual( + "Failed_EvaluateDynamicProperty", + actual, + "Expected WalkTree to fail because the schema contained a Property that does not exist in ActionDefinition.InputType."); + } + + [TestMethod] + public void Test_EvaluateInputType_ParameterizedConstructorFail() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.TestEvaluateInputType_FailOnNonEmptyCtor_Action); + + // Test - WalkTree and expect the Status to be Failed_EvaluateDynamicProperty + // because its ActionDefinition.InputType did not have a parameterless constructor. + string actual; + Assert.ThrowsException(() => + { + actual = this.session.WalkTree("Root").GetAwaiter().GetResult(); + }, "Expected WalkTree to fail because its ActionDefinition.InputType did not have a parameterless constructor."); + + actual = this.session.Status; + Assert.AreEqual( + "Failed_EvaluateDynamicProperty", + actual, + "Expected WalkTree to fail because its ActionDefinition.InputType did not have a parameterless constructor."); + } + + [TestMethod] + public void Test_LeafNodeSummaryAction_Success() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.LeafNodeSummaryAction); + + // Test - WalkTree to execute a LeafNodeSummaryAction node with its ActionInput set to ActionResponse properties. + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + ActionResponse leafActionResponse = this.session.GetLastActionResponse(); + Assert.AreEqual( + "Success", + leafActionResponse.Status, + "Expected to successfully retrieve the Func output value from the action."); + + Assert.AreEqual( + 1, + leafActionResponse.StatusCode, + "Expected to successfully retrieve the Func output value from the action."); + + Assert.AreEqual( + "TheResult", + leafActionResponse.Output, + "Expected to successfully retrieve the Func output value from the action."); + } + + [TestMethod] + public void Test_LeafNodeSummaryAction_InputAsObject_Success() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.LeafNodeSummaryAction_InputIsActionResponse); + + // Test - WalkTree to execute a LeafNodeSummaryAction node with its ActionInput set to ActionResponse object of the previously ran Action in the parent node. + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + ActionResponse leafActionResponse = this.session.GetLastActionResponse(); + Assert.AreEqual( + "Success", + leafActionResponse.Status, + "Expected to successfully retrieve the Func output value from the action."); + + Assert.AreEqual( + "TheCommand_Results", + leafActionResponse.Output, + "Expected to successfully retrieve the Func output value from the action."); + } + + [TestMethod] + public void Test_ExternalExecutors() + { + this.TestInitialize(jsonSchema: ForgeSchemaHelper.ExternalExecutors); + + Dictionary>> externalExecutors = new Dictionary>>(); + externalExecutors.Add("External|", External); + + this.parameters.ExternalExecutors = externalExecutors; + this.session = new TreeWalkerSession(this.parameters); + + // Test - WalkTree to execute an Action with an ActionInput that uses an external executor. Confirm expected results. + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus); + + ActionResponse leafActionResponse = this.session.GetLastActionResponse(); + Assert.AreEqual( + "StatusResult_Executed", + leafActionResponse.Status, + "Expected to successfully retrieve the Func output value from the action."); + } + + private static async Task External(string expression, CancellationToken token) + { + // External executes the expression and returns. + await Task.Delay(1, token); + return expression + "_Executed"; + } + + private sealed class TreeWalkerCallbacks : ITreeWalkerCallbacks + { + public async Task BeforeVisitNode( + Guid sessionId, + string treeNodeKey, + dynamic properties, + dynamic userContext, + CancellationToken token) + { + string serializeProperties = JsonConvert.SerializeObject(properties); + + await Task.Run(() => Console.WriteLine(string.Format( + "OnBeforeVisitNode: SessionId: {0}, TreeNodeKey: {1}, Properties: {2}.", + sessionId, + treeNodeKey, + serializeProperties))); + } + + public Task AfterVisitNode( + Guid sessionId, + string treeNodeKey, + dynamic properties, + dynamic userContext, + CancellationToken token) + { + Console.WriteLine(string.Format( + "OnAfterVisitNode: SessionId: {0}, TreeNodeKey: {1}, Properties: {2}.", + sessionId, + treeNodeKey, + JsonConvert.SerializeObject(properties))); + + return Task.FromResult(0); + } + } + + public abstract class BaseCommonAction : BaseAction + { + public object Input { get; private set; } + + public CancellationToken Token { get; private set; } + + public Guid SessionId { get; private set; } + + public string TreeNodeKey { get; private set; } + + private ActionContext actionContext; + + public override Task RunAction(ActionContext actionContext) + { + this.Input = actionContext.ActionInput; + this.Token = actionContext.Token; + this.SessionId = actionContext.SessionId; + this.TreeNodeKey = actionContext.TreeNodeKey; + this.actionContext = actionContext; + + return this.RunAction(); + } + + public abstract Task RunAction(); + + public Task CommitIntermediates(T intermediates) + { + return this.actionContext.CommitIntermediates(intermediates); + } + + public Task GetIntermediates() + { + return this.actionContext.GetIntermediates(); + } + } + + [ForgeAction(InputType: typeof(CollectDiagnosticsInput))] + public class CollectDiagnosticsAction : BaseCommonAction + { + public override async Task RunAction() + { + CollectDiagnosticsInput actionInput = (CollectDiagnosticsInput)this.Input; + + // Collect diagnostics using the input command and commit results. + string intermediates = this.GetIntermediates().GetAwaiter().GetResult(); + Assert.AreEqual(null, intermediates); + + string result = MockCollectDiagnosticsResult(actionInput.Command); + + intermediates = result; + this.CommitIntermediates(intermediates).GetAwaiter().GetResult(); + Assert.AreEqual(intermediates, this.GetIntermediates().GetAwaiter().GetResult()); + + await Task.Run(() => Console.WriteLine(string.Format( + "CollectDiagnosticsAction - SessionId: {0}, TreeNodeKey: {1}, ActionInput: {2}.", + this.SessionId, + this.TreeNodeKey, + JsonConvert.SerializeObject(actionInput)))); + + ActionResponse actionResponse = new ActionResponse() { Status = "Success", Output = result }; + return actionResponse; + } + + private static string MockCollectDiagnosticsResult(string command) + { + return command + "_Results"; + } + } + + public class CollectDiagnosticsInput + { + public string Command { get; set; } + } + + [ForgeAction] + public class TardigradeAction : BaseCommonAction + { + public override async Task RunAction() + { + await Task.Run(() => Console.WriteLine(string.Format( + "TardigradeAction - SessionId: {0}, TreeNodeKey: {1}.", + this.SessionId, + this.TreeNodeKey))); + + ActionResponse actionResponse = new ActionResponse() { Status = "Success" }; + return actionResponse; + } + } + + [ForgeAction(InputType: typeof(TestDelayExceptionInput))] + public class TestDelayExceptionAction : BaseCommonAction + { + public override async Task RunAction() + { + TestDelayExceptionInput actionInput = (TestDelayExceptionInput)this.Input; + Console.WriteLine(string.Format( + "TestDelayExceptionAction - SessionId: {0}, TreeNodeKey: {1}, ActionInput: {2}.", + this.SessionId, + this.TreeNodeKey, + JsonConvert.SerializeObject(actionInput))); + + await Task.Delay(actionInput.DelayMilliseconds, this.Token); + + if (actionInput.ThrowException) + { + throw new NullReferenceException("Throwing unexpected Exception!!"); + } + + ActionResponse actionResponse = new ActionResponse() { Status = "Success" }; + return actionResponse; + } + } + + public class TestDelayExceptionInput + { + public int DelayMilliseconds { get; set; } = 0; + + public bool ThrowException { get; set; } = false; + } + + [ForgeAction(InputType: typeof(FooActionInput))] + public class TestEvaluateInputTypeAction : BaseCommonAction + { + public override async Task RunAction() + { + FooActionInput actionInput = (FooActionInput)this.Input; + bool boolDelegateResult = actionInput.BoolDelegate(); + bool boolDelegateAsyncResult = await actionInput.BoolDelegateAsync(); + + Console.WriteLine(string.Format( + "TestEvaluateInputTypeAction - SessionId: {0}, TreeNodeKey: {1}, ActionInput: {2}, BoolDelegateResult: {3}, BoolDelegateAsyncResult: {4}.", + this.SessionId, + this.TreeNodeKey, + JsonConvert.SerializeObject(actionInput), + boolDelegateResult, + boolDelegateAsyncResult)); + + ActionResponse actionResponse = new ActionResponse() { Status = "Success", Output = boolDelegateResult && boolDelegateAsyncResult }; + return actionResponse; + } + } + + public class FooActionInput + { + public string Command { get; set; } + public int IntExpression { get; set; } + public bool BoolExpression { get; set; } + public string PropertyNotInSchema { get; set; } + public FooActionObject NestedObject { get; set; } + public FooActionObject[] ObjectArray { get; set; } + public string[] StringArray { get; set; } + public long[] LongArray { get; set; } + public Func BoolDelegate { get; set; } + public Func> BoolDelegateAsync { get; set; } + } + + public class FooActionObject + { + public string Name { get; set; } + public string Value { get; set; } + } + + [ForgeAction(InputType: typeof(FooActionInput_UnexpectedField))] + public class TestEvaluateInputType_FailOnField_Action : BaseCommonAction + { + public override async Task RunAction() + { + FooActionInput_UnexpectedField actionInput = (FooActionInput_UnexpectedField)this.Input; + + await Task.Run(() => Console.WriteLine(string.Format( + "TestEvaluateInputType_FailOnField_Action - SessionId: {0}, TreeNodeKey: {1}, ActionInput: {2}.", + this.SessionId, + this.TreeNodeKey, + JsonConvert.SerializeObject(actionInput)))); + + ActionResponse actionResponse = new ActionResponse() { Status = "Success" }; + return actionResponse; + } + } + + public class FooActionInput_UnexpectedField + { + public bool UnexpectedField = false; + } + + [ForgeAction(InputType: typeof(FooActionInput_NonEmptyConstructor))] + public class TestEvaluateInputType_FailOnNonEmptyCtor_Action : BaseCommonAction + { + public override async Task RunAction() + { + FooActionInput_NonEmptyConstructor actionInput = (FooActionInput_NonEmptyConstructor)this.Input; + + await Task.Run(() => Console.WriteLine(string.Format( + "TestEvaluateInputType_FailOnNonEmptyCtor_Action - SessionId: {0}, TreeNodeKey: {1}, ActionInput: {2}.", + this.SessionId, + this.TreeNodeKey, + JsonConvert.SerializeObject(actionInput)))); + + ActionResponse actionResponse = new ActionResponse() { Status = "Success" }; + return actionResponse; + } + } + + public class FooActionInput_NonEmptyConstructor + { + public bool BoolProperty { get; set; } + + public FooActionInput_NonEmptyConstructor(int unexpectedParameter) {} + } + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/Forge.TreeWalker.csproj b/Forge/Forge.TreeWalker/Forge.TreeWalker.csproj new file mode 100644 index 0000000..7b16aaa --- /dev/null +++ b/Forge/Forge.TreeWalker/Forge.TreeWalker.csproj @@ -0,0 +1,92 @@ + + + + + Debug + AnyCPU + {00FC1C22-6AE9-4F60-8A3E-05885BA34C9C} + Library + Properties + Forge.TreeWalker + Forge.TreeWalker + v4.6.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + ..\packages\System.Collections.Immutable.1.1.37\lib\dotnet\System.Collections.Immutable.dll + + + + + ..\packages\System.Reflection.Metadata.1.2.0\lib\portable-net45+win8\System.Reflection.Metadata.dll + + + + + + + + + ..\packages\Microsoft.CodeAnalysis.Common.1.3.2\lib\net45\Microsoft.CodeAnalysis.dll + + + ..\packages\Microsoft.CodeAnalysis.CSharp.1.3.2\lib\net45\Microsoft.CodeAnalysis.CSharp.dll + + + ..\packages\Microsoft.CodeAnalysis.CSharp.Scripting.1.3.2\lib\dotnet\Microsoft.CodeAnalysis.CSharp.Scripting.dll + + + ..\packages\Microsoft.CodeAnalysis.Scripting.Common.1.3.2\lib\dotnet\Microsoft.CodeAnalysis.Scripting.dll + + + ..\packages\Newtonsoft.Json.12.0.2\lib\net45\Newtonsoft.Json.dll + + + + + + + + + + + + + + + + + + + + + + + + + {c49a8494-13e5-4214-8434-708ba280c5b1} + Forge.DataContracts + + + + \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/Properties/AssemblyInfo.cs b/Forge/Forge.TreeWalker/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..c02949e --- /dev/null +++ b/Forge/Forge.TreeWalker/Properties/AssemblyInfo.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: InternalsVisibleTo("Forge.TreeWalker.UnitTests")] + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Forge")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Forge")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("00fc1c22-6ae9-4f60-8a3e-05885ba34c9c")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Forge/Forge.TreeWalker/app.config b/Forge/Forge.TreeWalker/app.config new file mode 100644 index 0000000..e4fbd2a --- /dev/null +++ b/Forge/Forge.TreeWalker/app.config @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/packages.config b/Forge/Forge.TreeWalker/packages.config new file mode 100644 index 0000000..9e3eb88 --- /dev/null +++ b/Forge/Forge.TreeWalker/packages.config @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/src/ActionContext.cs b/Forge/Forge.TreeWalker/src/ActionContext.cs new file mode 100644 index 0000000..d060e54 --- /dev/null +++ b/Forge/Forge.TreeWalker/src/ActionContext.cs @@ -0,0 +1,134 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The ActionContext class. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + + /// + /// The ActionContext class object is passed to Actions to give them contextual data and methods to help them execute. + /// + public class ActionContext + { + /// + /// The unique identifier for this tree walking session. + /// + public Guid SessionId { get; private set; } + + /// + /// The tree node key where the Action resides. + /// + public string TreeNodeKey { get; private set; } + + /// + /// The tree action key of this Action. + /// + public string TreeActionKey { get; private set; } + + /// + /// The name of this Action. + /// + public string ActionName { get; private set; } + + /// + /// The dynamic input for the Action given by the ForgeTree schema. + /// + public object ActionInput { get; private set; } + + /// + /// The dynamic properties of this Action given by the ForgeTree schema. + /// + public object Properties { get; private set; } + + /// + /// The dynamic user-defined context object that is able to be referenced when evaluating schema expressions or performing actions. + /// + public object UserContext { get; private set; } + + /// + /// The cancellation token. + /// + public CancellationToken Token { get; private set; } + + /// + /// The forgeState dictionary that holds information relevant to Forge and Actions. + /// + private IForgeDictionary forgeState; + + /// + /// Instantiates an ActionContext object. + /// + /// The unique identifier for this tree walking session. + /// The TreeNode's key where the Action is taking place. + /// The TreeAction's key of the Action taking place. + /// The name of the Action. + /// The input for this Action. + /// The properties of this Action. + /// The user context for this Action. + /// The cancellation token. + /// The forge state dictionary. + public ActionContext( + Guid sessionId, + string treeNodeKey, + string treeActionKey, + string actionName, + object actionInput, + object properties, + object userContext, + CancellationToken token, + IForgeDictionary forgeState) + { + if (sessionId == null) throw new ArgumentNullException("sessionId"); + if (string.IsNullOrWhiteSpace(treeNodeKey)) throw new ArgumentNullException("treeNodeKey"); + if (string.IsNullOrWhiteSpace(actionName)) throw new ArgumentNullException("actionName"); + if (userContext == null) throw new ArgumentNullException("userContext"); + if (token == null) throw new ArgumentNullException("token"); + if (forgeState == null) throw new ArgumentNullException("forgeState"); + + this.SessionId = sessionId; + this.TreeNodeKey = treeNodeKey; + this.TreeActionKey = treeActionKey; + this.ActionName = actionName; + this.ActionInput = actionInput; + this.Properties = properties; + this.UserContext = userContext; + this.Token = token; + this.forgeState = forgeState; + } + + /// + /// Commits an Intermediates object for this Action to the forgeState. + /// Since Intermediates are available to the Action on each retry, this allows Actions to persist state across retries. + /// + /// The intermediates object to be committed for this Action. + public Task CommitIntermediates(T intermediates) + { + return this.forgeState.Set(this.TreeActionKey + TreeWalkerSession.IntermediatesSuffix, intermediates); + } + + /// + /// Gets the previously committed Intermediates data for this Action from the forgeState. + /// + /// The Intermediates data for this Action if it exists, otherwise default(T). + public async Task GetIntermediates() + { + try + { + return await this.forgeState.GetValue(this.TreeActionKey + TreeWalkerSession.IntermediatesSuffix).ConfigureAwait(false); + } + catch + { + return default(T); + } + } + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/src/ActionDefinition.cs b/Forge/Forge.TreeWalker/src/ActionDefinition.cs new file mode 100644 index 0000000..d98568b --- /dev/null +++ b/Forge/Forge.TreeWalker/src/ActionDefinition.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The ActionDefinition class. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker +{ + using System; + using System.Threading.Tasks; + + /// + /// The ActionDefinition class holds definitions for the action. + /// + public class ActionDefinition + { + /// + /// The Type of the ForgeAction class. + /// + public Type ActionType { get; set; } + + /// + /// The InputType for this Action that will be passed to the Action by Forge when executing the Action. + /// When given, Forge will instantiate this type when evaluating the ActionInput from the schema. + /// When null, Forge will instead create a dynamic object. + /// Restrictions: Only the public Properties of the InputType will be instantiated. + /// Objects lacking a parameterless constructor are not supported. + /// Objects with public fields are not supported. + /// + public Type InputType { get; set; } + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/src/ActionResponse.cs b/Forge/Forge.TreeWalker/src/ActionResponse.cs new file mode 100644 index 0000000..a742eb5 --- /dev/null +++ b/Forge/Forge.TreeWalker/src/ActionResponse.cs @@ -0,0 +1,32 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The ActionResponse class. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker +{ + /// + /// The ActionResponse class holds the response information from actions. + /// + public class ActionResponse + { + /// + /// The status code of this action response. + /// + public int StatusCode { get; set; } + + /// + /// The status of this action response. + /// + public string Status { get; set; } + + /// + /// The dynamic output of this action response. + /// + public object Output { get; set; } + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/src/BaseAction.cs b/Forge/Forge.TreeWalker/src/BaseAction.cs new file mode 100644 index 0000000..378c9bb --- /dev/null +++ b/Forge/Forge.TreeWalker/src/BaseAction.cs @@ -0,0 +1,28 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The BaseAction abstract class. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker +{ + using System; + using System.Threading.Tasks; + + /// + /// The BaseAction abstract class must be inherited by all ForgeActions tagged with the ForgeActionAttribute. + /// All ForgeActionAttribute tagged classes should reside in the Assembly that is passed to Forge TreeWalkerSession. + /// When Forge encounters an ActionName while walking the tree, it will instantiate the ForgeAction type and then call RunAction. + /// + public abstract class BaseAction + { + /// + /// The RunAction method is called when Forge encounters an ActionName while walking the tree. + /// + /// The action context holding relevant information for this Action. + public abstract Task RunAction(ActionContext actionContext); + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/src/ExpressionExecutor.cs b/Forge/Forge.TreeWalker/src/ExpressionExecutor.cs new file mode 100644 index 0000000..322d6f0 --- /dev/null +++ b/Forge/Forge.TreeWalker/src/ExpressionExecutor.cs @@ -0,0 +1,155 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The ExpressionExecutor class. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Reflection; + using System.Threading.Tasks; + using Microsoft.CodeAnalysis.CSharp.Scripting; + using Microsoft.CodeAnalysis.Scripting; + + /// + /// The ExpressionExecutor dynamically compiles code and executes it using Roslyn. + /// + public class ExpressionExecutor + { + /// + /// List of external type dependencies needed to compile expressions. + /// + private List dependencies; + + /// + /// Script cache used to cache and re-use compiled Roslyn scripts. + /// + private ConcurrentDictionary> scriptCache; + + /// + /// Roslyn script options. + /// + private ScriptOptions scriptOptions; + + /// + /// Global parameters passed to Roslyn scripts that can be referenced inside expressions. + /// + private CodeGenInputParams parameters; + + /// + /// Instantiates the ExpressionExecutor class with objects that can be referenced in the schema. + /// + /// The tree session. + /// The dynamic user context. + /// Type dependencies required to compile the schema. Can be null if no external dependencies required. + /// Script cache used to cache and re-use compiled Roslyn scripts. + public ExpressionExecutor(ITreeSession session, object userContext, List dependencies, ConcurrentDictionary> scriptCache) + { + this.dependencies = dependencies; + this.parameters = new CodeGenInputParams + { + UserContext = userContext, + Session = session + }; + this.scriptCache = scriptCache ?? new ConcurrentDictionary>(); + this.Initialize(); + } + + /// + /// Instantiates the ExpressionExecutor class with objects that can be referenced in the schema. + /// + /// The tree session. + /// The dynamic user context. + /// Type dependencies required to compile the schema. Can be null if no external dependencies required. + public ExpressionExecutor(ITreeSession session, object userContext, List dependencies) + : this(session, userContext, dependencies, new ConcurrentDictionary>()) + { + } + + /// + /// Executes the given expression and returns the result as the given generic type. + /// + /// The expression to evaluate. + /// The T value of the evaluated code. + public async Task Execute(string expression) + { + var script = this.scriptCache.GetOrAdd( + expression, + (key) => CSharpScript.Create( + string.Format("return {0};", expression), + this.scriptOptions, + typeof(CodeGenInputParams))); + + return (T)(await script.RunAsync(this.parameters).ConfigureAwait(false)).ReturnValue; + } + + /// + /// Initializes Roslyn script options with all the required assemblies, references, and external dependencies. + /// + private void Initialize() + { + this.scriptOptions = ScriptOptions.Default; + + // Add references to required assemblies. + Assembly mscorlib = typeof(object).Assembly; + Assembly cSharpAssembly = typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly; + this.scriptOptions = this.scriptOptions.AddReferences(mscorlib, cSharpAssembly); + + // Add required namespaces. + this.scriptOptions = this.scriptOptions.AddImports("System"); + this.scriptOptions = this.scriptOptions.AddImports("System.Threading.Tasks"); + + string systemCoreAssemblyName = mscorlib.GetName().Name; + + // Add external dependencies. + if (this.dependencies != null) + { + foreach (Type type in this.dependencies) + { + string fullAssemblyName = type.Assembly.GetName().Name; + + // While adding the reference again is okay, we can not AddImports for systemCoreAssembly. + if (fullAssemblyName == systemCoreAssemblyName) + { + continue; + } + + this.scriptOptions = this.scriptOptions.AddReferences(type.Assembly).AddImports(type.Namespace); + } + } + } + + /// + /// Internal method used for testing if ScriptCache contains the expression key. + /// + /// The expression key. + /// True if the expression key exists, otherwise false. + internal bool ScriptCacheContainsKey(string expression) + { + return this.scriptCache.ContainsKey(expression); + } + + /// + /// This class defines the global parameter that will be + /// passed into the Roslyn expression evaluator. + /// + public class CodeGenInputParams + { + /// + /// The dynamic UserContext object that holds properties and methods that can be referenced in the schema. + /// + public dynamic UserContext { get; set; } + + /// + /// The ITreeSession interface that holds accessor methods into the forgeState dictionary that can be referenced in the schema. + /// + public ITreeSession Session { get; set; } + } + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/src/ForgeActionAttribute.cs b/Forge/Forge.TreeWalker/src/ForgeActionAttribute.cs new file mode 100644 index 0000000..b1db267 --- /dev/null +++ b/Forge/Forge.TreeWalker/src/ForgeActionAttribute.cs @@ -0,0 +1,52 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The ForgeActionAttribute class. +// +//----------------------------------------------------------------------- + +namespace Forge.Attributes +{ + using System; + + /// + /// The ForgeActionAttribute class defines the ForgeAction attribute. + /// + /// This attribute should be added to all Forge Action classes that wish to be called by Forge while walking the tree. + /// The Name of the Action class will be used in the ForgeTree schema file to map to the Action with this ForgeAction attribute. + /// Action classes must inherit from Forge's BaseAction class, as Forge will call the Setup and RunAction methods on these classes. + /// + /// InputType should be included if the Action wishes for Forge to instantiate that object from the ForgeSchema and pass it to the Action. + /// Otherwise, Forge will create a dynamic object from the Input in ForgeSchema if it exists, and pass it to the Action. + /// + /// Ex) [ForgeAction(InputType: typeof(FooInput))] + /// + [AttributeUsage(AttributeTargets.Class)] + public class ForgeActionAttribute : Attribute + { + /// + /// InputType should be included if the Action wishes for Forge to instantiate that object from the ForgeSchema and pass it to the Action. + /// If null, Forge will create a dynamic object from the Input in ForgeSchema if it exists and pass it to the Action. + /// + public Type InputType { get; private set; } + + /// + /// Instantiates a ForgeActionAttribute. + /// + /// The input Type for this Action. + public ForgeActionAttribute(Type InputType) + { + this.InputType = InputType; + } + + /// + /// Instantiates a ForgeActionAttribute. + /// + public ForgeActionAttribute() + : this(null) + { + } + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/src/ForgeDictionary.cs b/Forge/Forge.TreeWalker/src/ForgeDictionary.cs new file mode 100644 index 0000000..d0e4676 --- /dev/null +++ b/Forge/Forge.TreeWalker/src/ForgeDictionary.cs @@ -0,0 +1,165 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The ForgeDictionary class. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + /// + /// The ForgeDictionary class defines the methods for accessing forge state. + /// Note: The KeyPrefix should always precede the key when using the forgeStateTable to limit the scope to the current SessionId. + /// + public class ForgeDictionary : IForgeDictionary + { + /// + /// The unique identifier for this session. + /// + public Guid SessionId { get; set; } + + /// + /// The key prefix that should precede the keys when using the forgeStateTable. + /// This ensures the scope of the table is limited to the current SessionId. + /// + private string keyPrefix; + + /// + /// The dictionary holding the forge state. + /// Maps the string key to object value. + /// The key should always be preceded by the KeyPrefix. This ensures the scope of the table is limited to the current SessionId. + /// + private IDictionary forgeStateTable; + + /// + /// ForgeDictionary Constructor. + /// + /// The forge state table dictionary object. + /// The unique identifier for this session. + public ForgeDictionary(IDictionary forgeStateTable, Guid sessionId) + { + this.forgeStateTable = forgeStateTable; + this.SessionId = sessionId; + this.keyPrefix = this.SessionId + "_"; + } + + /// + /// Sets an element with the provided key and value to the backing store. + /// + /// The key of the element to set. + /// The value of the element to be set. + public Task Set(string key, T value) + { + this.forgeStateTable[this.keyPrefix + key] = (object)value; + return Task.FromResult(0); + } + + /// + /// Sets a list of key value pairs to the backing store. + /// + /// The list of key value pairs to set. + public Task SetRange(List> kvps) + { + foreach(KeyValuePair kvp in kvps) + { + this.forgeStateTable[this.keyPrefix + kvp.Key] = (object)kvp.Value; + } + + return Task.FromResult(0); + } + + /// + /// Gets an element with the provided key from the backing store. + /// + /// The key of the element to get. + /// The value of the element to get. + public Task GetValue(string key) + { + return Task.FromResult((T)this.forgeStateTable[this.keyPrefix + key]); + } + + /// + /// Removes an element with the provided key from the backing store. + /// + /// The key of the element to remove. + /// True of the element was removed, False otherwise. + public Task RemoveKey(string key) + { + return Task.FromResult(this.forgeStateTable.Remove(this.keyPrefix + key)); + } + + /// + /// Removes a list of elements with the provided keys from the backing store. + /// + /// The list of keys to remove. + public Task RemoveKeys(List keys) + { + foreach(string key in keys) + { + this.forgeStateTable.Remove(this.keyPrefix + key); + } + + return Task.FromResult(0); + } + + /// + /// Sets an element with the provided key and value to the backing store. + /// + /// The key of the element to set. + /// The value of the element to be set. + public void SetSync(string key, T value) + { + this.forgeStateTable[this.keyPrefix + key] = (object)value; + } + + /// + /// Sets a list of key value pairs to the backing store. + /// + /// The list of key value pairs to set. + public void SetRangeSync(List> kvps) + { + foreach(KeyValuePair kvp in kvps) + { + this.forgeStateTable[this.keyPrefix + kvp.Key] = (object)kvp.Value; + } + } + + /// + /// Gets an element with the provided key from the backing store. + /// + /// The key of the element to get. + /// The value of the element to get. + public T GetValueSync(string key) + { + return (T)this.forgeStateTable[this.keyPrefix + key]; + } + + /// + /// Removes an element with the provided key from the backing store. + /// + /// The key of the element to remove. + /// True of the element was removed, False otherwise. + public bool RemoveKeySync(string key) + { + return this.forgeStateTable.Remove(this.keyPrefix + key); + } + + /// + /// Removes a list of elements with the provided keys from the backing store. + /// + /// The list of keys to remove. + public void RemoveKeysSync(List keys) + { + foreach(string key in keys) + { + this.forgeStateTable.Remove(this.keyPrefix + key); + } + } + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/src/ForgeExceptions.cs b/Forge/Forge.TreeWalker/src/ForgeExceptions.cs new file mode 100644 index 0000000..5d9edbe --- /dev/null +++ b/Forge/Forge.TreeWalker/src/ForgeExceptions.cs @@ -0,0 +1,78 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The ForgeExceptions. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker.ForgeExceptions +{ + using System; + + /// + /// Exception thrown on action timeout. + /// + public class ActionTimeoutException : Exception + { + /// + /// Initializes a new instance of the class. + /// + /// The message. + public ActionTimeoutException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message. + /// The inner exception. + public ActionTimeoutException(string message, Exception inner) + : base(message, inner) + { + } + } + + /// + /// Exception thrown when ChildSelector fails to select any child. + /// + public class NoChildMatchedException : Exception + { + /// + /// Initializes a new instance of the class. + /// + /// The message. + public NoChildMatchedException(string message) + : base(message) + { + } + } + + /// + /// Exception thrown when EvaluateDynamicProperty fails. + /// + public class EvaluateDynamicPropertyException : Exception + { + /// + /// Initializes a new instance of the class. + /// + /// The message. + public EvaluateDynamicPropertyException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message. + /// The inner exception. + public EvaluateDynamicPropertyException(string message, Exception inner) + : base(message, inner) + { + } + } +} diff --git a/Forge/Forge.TreeWalker/src/IForgeDictionary.cs b/Forge/Forge.TreeWalker/src/IForgeDictionary.cs new file mode 100644 index 0000000..9d0854d --- /dev/null +++ b/Forge/Forge.TreeWalker/src/IForgeDictionary.cs @@ -0,0 +1,60 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The IForgeDictionary interface. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + /// + /// The IForgeDictionary interface defines the methods for accessing forge state. + /// Note: The KeyPrefix should always precede the key when using the forgeStateTable to limit the scope to the current SessionId. + /// + public interface IForgeDictionary + { + /// + /// The unique identifier for this session. + /// + Guid SessionId { get; set; } + + /// + /// Sets an element with the provided key and value to the backing store. + /// + /// The key of the element to set. + /// The value of the element to be set. + Task Set(string key, T value); + + /// + /// Sets a list of key value pairs to the backing store. + /// + /// The list of key value pairs to set. + Task SetRange(List> kvps); + + /// + /// Gets an element with the provided key from the backing store. + /// + /// The key of the element to get. + /// The value of the element to get. + Task GetValue(string key); + + /// + /// Removes an element with the provided key from the backing store. + /// + /// The key of the element to remove. + /// True of the element was removed, False otherwise. + Task RemoveKey(string key); + + /// + /// Removes a list of elements with the provided keys from the backing store. + /// + /// The list of keys to remove. + Task RemoveKeys(List keys); + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/src/ITreeSession.cs b/Forge/Forge.TreeWalker/src/ITreeSession.cs new file mode 100644 index 0000000..a6f3ad7 --- /dev/null +++ b/Forge/Forge.TreeWalker/src/ITreeSession.cs @@ -0,0 +1,46 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The ITreeSession interface. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker +{ + using System; + using System.Threading.Tasks; + + /// + /// The ITreeSession interface holds accessor methods into the forgeState dictionary. + /// + public interface ITreeSession + { + /// + /// Gets the ActionResponse data from the forgeState for the given tree action key. + /// + /// The TreeAction's key of the action that was executed. + /// The ActionResponse data for the given tree action key if it exists, otherwise null. + ActionResponse GetOutput(string treeActionKey); + + /// + /// Asynchronously gets the ActionResponse data from the forgeState for the given tree action key. + /// + /// The TreeAction's key of the action that was executed. + /// The ActionResponse data for the given tree action key if it exists, otherwise null. + Task GetOutputAsync(string treeActionKey); + + /// + /// Gets the last executed TreeAction's ActionResponse data from the forgeState. + /// + /// The ActionResponse data for the last executed tree action key if it exists, otherwise null. + ActionResponse GetLastActionResponse(); + + /// + /// Asynchronously gets the last executed TreeAction's ActionResponse data from the forgeState. + /// + /// The ActionResponse data for the last executed tree action key if it exists, otherwise null. + Task GetLastActionResponseAsync(); + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/src/ITreeWalkerCallbacks.cs b/Forge/Forge.TreeWalker/src/ITreeWalkerCallbacks.cs new file mode 100644 index 0000000..efe44e4 --- /dev/null +++ b/Forge/Forge.TreeWalker/src/ITreeWalkerCallbacks.cs @@ -0,0 +1,43 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The ITreeWalkerCallbacks interface. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker +{ + using System; + using System.Collections.Generic; + using System.Dynamic; + using System.Threading; + using System.Threading.Tasks; + + /// + /// The ITreeWalkerCallbacks interface defines the callback Tasks that are awaited while walking the tree. + /// + public interface ITreeWalkerCallbacks + { + /// + /// The callback Task that is awaited before visiting each node. + /// + /// The Id of this tree walking session. + /// The key of the current tree node being visited by Forge. + /// The additional properties for this node. + /// The dynamic user-defined context object. + /// The cancellation token. + Task BeforeVisitNode(Guid sessionId, string treeNodeKey, dynamic properties, object userContext, CancellationToken token); + + /// + /// The callback Task that is awaited after visiting each node. + /// + /// The Id of this tree walking session. + /// The key of the current tree node being visited by Forge. + /// The additional properties for this node. + /// The dynamic user-defined context object. + /// The cancellation token. + Task AfterVisitNode(Guid sessionId, string treeNodeKey, dynamic properties, object userContext, CancellationToken token); + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/src/TreeWalkerParameters.cs b/Forge/Forge.TreeWalker/src/TreeWalkerParameters.cs new file mode 100644 index 0000000..9562159 --- /dev/null +++ b/Forge/Forge.TreeWalker/src/TreeWalkerParameters.cs @@ -0,0 +1,114 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The TreeWalkerParameters class. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Reflection; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.CodeAnalysis.Scripting; + + /// + /// The TreeWalkerParameters class contains the required and optional properties used by the TreeWalkerSession. + /// + public class TreeWalkerParameters + { + #region Required Properties + + /// + /// The unique identifier for this session. + /// + public Guid SessionId { get; private set; } + + /// + /// The string representation of the JSON schema. + /// + public string JsonSchema { get; private set; } + + /// + /// The state given to TreeWalker on construction by a wrapper class. + /// The state holds information that is relevant to TreeWalker while walking the tree. + /// + public IForgeDictionary ForgeState { get; private set; } + + /// + /// The ITreeWalkerCallbacks interface defines the callback Tasks that are awaited while walking the tree. + /// + public ITreeWalkerCallbacks Callbacks { get; private set; } + + /// + /// The cancellation token. + /// + public CancellationToken Token { get; private set; } + + #endregion + + #region Optional Properties + + /// + /// The dynamic object that is able to be referenced when evaluating schema expressions or performing actions. + /// + public object UserContext { get; set; } + + /// + /// The Assembly containing ForgeActionAttribute tagged classes. + /// + public Assembly ForgeActionsAssembly { get; set; } + + /// + /// Script cache used by ExpressionExecutor to cache and re-use compiled Roslyn scripts. + /// + public ConcurrentDictionary> ScriptCache { get; set; } + + /// + /// Dependencies required to compile and execute the schema. Null if no external dependencies required. + /// + public List Dependencies { get; set; } + + /// + /// The external executors work similarly to the built-in ExpressionExecutor, but use their own string match and evaluation logic on schema expressions. + /// The key is the string that EvaluateDynamicProperty will attempt to match string properties against. Similar to "C#|" for Roslyn expressions. + /// The value is the Func that is called when the string key matches. This Func takes the expression and token, and should return the expected value type. + /// + public Dictionary>> ExternalExecutors { get; set; } + + #endregion + + /// + /// Instantiates a TreeWalkerParameters object with the properies that are required to instantiate a TreeWalkerSession object. + /// + /// The unique identifier for this session. + /// The JSON schema. + /// The Forge state. + /// The callbacks object. + /// The cancellation token. + public TreeWalkerParameters( + Guid sessionId, + string jsonSchema, + IForgeDictionary forgeState, + ITreeWalkerCallbacks callbacks, + CancellationToken token) + { + if (sessionId == Guid.Empty) throw new ArgumentNullException("sessionId"); + if (string.IsNullOrWhiteSpace(jsonSchema)) throw new ArgumentNullException("jsonSchema"); + if (forgeState == null) throw new ArgumentNullException("forgeState"); + if (callbacks == null) throw new ArgumentNullException("callbacks"); + if (token == null) throw new ArgumentNullException("token"); + + this.SessionId = sessionId; + this.JsonSchema = jsonSchema; + this.ForgeState = forgeState; + this.Callbacks = callbacks; + this.Token = token; + } + } +} \ No newline at end of file diff --git a/Forge/Forge.TreeWalker/src/TreeWalkerSession.cs b/Forge/Forge.TreeWalker/src/TreeWalkerSession.cs new file mode 100644 index 0000000..a0a01fb --- /dev/null +++ b/Forge/Forge.TreeWalker/src/TreeWalkerSession.cs @@ -0,0 +1,969 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// +// The TreeWalkerSession class. +// +//----------------------------------------------------------------------- + +namespace Forge.TreeWalker +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Diagnostics; + using System.Reflection; + using System.Text.RegularExpressions; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.CodeAnalysis.Scripting; + + using Forge.Attributes; + using Forge.DataContracts; + using Forge.TreeWalker.ForgeExceptions; + + using Newtonsoft.Json; + using Newtonsoft.Json.Linq; + + /// + /// The TreeWalkerSession tries to walk the given tree schema to completion. + /// It holds the logic for walking the tree schema, executing actions, and calling callbacks. + /// + public class TreeWalkerSession : ITreeSession + { + /// + /// The ActionResponse suffix appended to the end of the key in forgeState that maps to an ActionResponse. + /// Key: __AR + /// + public static string ActionResponseSuffix = "_AR"; + + /// + /// The CurrentTreeNode suffix appended to the end of the key in forgeState that maps to the current TreeNode being walked. + /// Key: _CTN + /// + public static string CurrentTreeNodeSuffix = "CTN"; + + /// + /// The LastTreeAction suffix appended to the end of the key in forgeState that maps to the last TreeAction that was committed. + /// Key: _LTA + /// + public static string LastTreeActionSuffix = "LTA"; + + /// + /// The Intermediates suffix appended to the end of the key in forgeState that maps to an ActionContext's GetIntermediates object. + /// Key: __Int + /// + public static string IntermediatesSuffix = "_Int"; + + /// + /// The name of the native LeafNodeSummaryAction. + /// This Action can only live on Leaf type TreeNodes. Similarly, Leaf type TreeNodes can only have this Action. + /// This Action takes an ActionResponse as the ActionInput, either as an object or as properties, and commits this object as the ActionResponse. + /// This Action is intended to give schema authors the ability to cleanly end a tree walking path with a summary. + /// + public static string LeafNodeSummaryAction = "LeafNodeSummaryAction"; + + /// + /// The Roslyn regex expression. Used to check if dynamic schema values should be evaluated with Roslyn. + /// Type can be added to indicate that Roslyn should evaluate the expression and return the specified type. + /// If the property is a "KnownType", the KnownType is used even if a is specified in the expression. ActionDefinition.InputType is an example of a KnownType. + /// If the property is not a KnownType and no type is specified in the expression, string is used by default. + /// Ex) C#|"expression" + /// Ex) C#|"expression" + /// + private static Regex RoslynRegex = new Regex(@"^C#(\<(\w+)\>)?\|"); + + /// + /// The leading text to add to Schema strings to indicate the property value should be evaluated with Roslyn. + /// The property value must match the RoslynRegex to be evaluated with Roslyn. + /// + private static string RoslynLeadingText = "C#"; + + /// + /// The TreeWalkerParameters contains the required and optional properties used by the TreeWalkerSession. + /// + public TreeWalkerParameters Parameters { get; private set; } + + /// + /// The JSON schema holding the tree to navigate during WalkTree. + /// + public ForgeTree Schema { get; private set; } + + /// + /// The current status of the tree walker. + /// + public string Status { get; private set; } + + /// + /// The WalkTree cancellation token source. + /// Used to send cancellation signal to action tasks and to stop tree walker from visiting future nodes. + /// + private CancellationTokenSource walkTreeCts; + + /// + /// The ExpressionExecutor dynamically compiles code and executes it. + /// + private ExpressionExecutor expressionExecutor; + + /// + /// The map of string ActionNames to ActionDefinitions. + /// This map is generated using reflection to find all the classes with the applied ForgeActionAttribute from the given Assembly. + /// The string key is the Action class name. + /// The ActionDefinition value contains the Action class type, and the InputType for the Action. + /// + private Dictionary actionsMap; + + /// + /// Instantiates a tree walker session with the required parameters. + /// + /// The parameters object contains the required and optional properties used by the TreeWalkerSession. + public TreeWalkerSession(TreeWalkerParameters parameters) + { + if (parameters == null) throw new ArgumentNullException("parameters"); + + this.Parameters = parameters; + + // Initialize properties from required TreeWalkerParameters properties. + this.Schema = JsonConvert.DeserializeObject(parameters.JsonSchema); + this.walkTreeCts = CancellationTokenSource.CreateLinkedTokenSource(parameters.Token); + + // Initialize properties from optional TreeWalkerParameters properties. + GetActionsMapFromAssembly(parameters.ForgeActionsAssembly, out this.actionsMap); + parameters.ExternalExecutors = parameters.ExternalExecutors ?? new Dictionary>>(); + this.expressionExecutor = new ExpressionExecutor(this as ITreeSession, parameters.UserContext, parameters.Dependencies, parameters.ScriptCache); + + this.Status = "Initialized"; + } + + /// + /// Gets the ActionResponse data from the forgeState for the given tree action key. + /// + /// The TreeAction's key of the action that was executed. + /// The ActionResponse data for the given tree action key if it exists, otherwise null. + public ActionResponse GetOutput(string treeActionKey) + { + try + { + return this.Parameters.ForgeState.GetValue(treeActionKey + ActionResponseSuffix).GetAwaiter().GetResult(); + } + catch + { + return null; + } + } + + /// + /// Asynchronously gets the ActionResponse data from the forgeState for the given tree action key. + /// + /// The TreeAction's key of the action that was executed. + /// The ActionResponse data for the given tree action key if it exists, otherwise null. + public async Task GetOutputAsync(string treeActionKey) + { + try + { + return await this.Parameters.ForgeState.GetValue(treeActionKey + ActionResponseSuffix).ConfigureAwait(false); + } + catch + { + return null; + } + } + + /// + /// Gets the last executed TreeAction's ActionResponse data from the forgeState. + /// + /// The ActionResponse data for the last executed tree action key if it exists, otherwise null. + public ActionResponse GetLastActionResponse() + { + try + { + return this.Parameters.ForgeState.GetValue(this.GetLastTreeAction().GetAwaiter().GetResult() + ActionResponseSuffix).GetAwaiter().GetResult(); + } + catch + { + return null; + } + } + + /// + /// Asynchronously gets the last executed TreeAction's ActionResponse data from the forgeState. + /// + /// The ActionResponse data for the last executed tree action key if it exists, otherwise null. + public async Task GetLastActionResponseAsync() + { + try + { + return await this.Parameters.ForgeState.GetValue(await this.GetLastTreeAction().ConfigureAwait(false) + ActionResponseSuffix).ConfigureAwait(false); + } + catch + { + return null; + } + } + + /// + /// Gets the current tree node being walked from the forgeState. + /// + /// The current tree node if it has been persisted, otherwise null. + public async Task GetCurrentTreeNode() + { + try + { + return await this.Parameters.ForgeState.GetValue(CurrentTreeNodeSuffix).ConfigureAwait(false); + } + catch + { + return null; + } + } + + /// + /// Gets the last committed tree action from the forgeState. + /// + /// The last committed tree action if it has been persisted, otherwise null. + public async Task GetLastTreeAction() + { + try + { + return await this.Parameters.ForgeState.GetValue(LastTreeActionSuffix).ConfigureAwait(false); + } + catch + { + return null; + } + } + + /// + /// Signals the WalkTree and VisitNode cancellation token sources to cancel. + /// + public void CancelWalkTree() + { + this.walkTreeCts.Cancel(); + } + + /// + /// Walks the tree schema starting at the given tree node key. + /// + /// The TreeNode key to start walking. + /// The string status of the tree walker. + public async Task WalkTree(string treeNodeKey) + { + this.Status = "Running"; + + // Start a single task to walk the tree to completion. + // This task will only start new tasks to execute actions. + Task walkTreeTask = Task.Run(async () => + { + string current = treeNodeKey; + string next; + + // Starting with the given tree node key, visit the returned children nodes until you hit a node with no matching children. + // Call the callbacks before/after visiting each node. + do + { + await this.CommitCurrentTreeNode(current).ConfigureAwait(false); + if (this.walkTreeCts.Token.IsCancellationRequested) + { + this.Status = "Cancelled"; + this.walkTreeCts.Token.ThrowIfCancellationRequested(); + } + + await this.Parameters.Callbacks.BeforeVisitNode( + this.Parameters.SessionId, + current, + await this.EvaluateDynamicProperty(this.Schema.Tree[current].Properties, null), + this.Parameters.UserContext, + this.walkTreeCts.Token).ConfigureAwait(false); + + try + { + // Exceptions are thrown here if VisitNode hit a timeout, was cancelled, or failed. + next = await this.VisitNode(current).ConfigureAwait(false); + } + finally + { + // Always call this callback, whether or not visit node was successful. + await this.Parameters.Callbacks.AfterVisitNode( + this.Parameters.SessionId, + current, + await this.EvaluateDynamicProperty(this.Schema.Tree[current].Properties, null), + this.Parameters.UserContext, + this.walkTreeCts.Token).ConfigureAwait(false); + } + + current = next; + } while (!string.IsNullOrWhiteSpace(current)); + + if (string.IsNullOrWhiteSpace(current)) + { + // Null child means the tree ran to completion. + this.Status = "RanToCompletion"; + } + }, this.walkTreeCts.Token); + + try + { + // Exceptions are thrown here if tree walker hit a timeout, was cancelled, or failed. + // Let's update the Status according to the exception thrown before rethrowing the exception. + await walkTreeTask; + } + catch (TaskCanceledException) + { + // Tree walker was cancelled before calling WalkTree. + this.Status = "CancelledBeforeExecution"; + } + catch (OperationCanceledException) + { + // Tree walker was cancelled after calling WalkTree (no timeouts hit). + this.Status = "Cancelled"; + } + catch (ActionTimeoutException) + { + // An action-level timeout was hit. + this.Status = "TimeoutOnAction"; + } + catch (TimeoutException) + { + // A node-level timeout was hit. + this.Status = "TimeoutOnNode"; + } + catch (NoChildMatchedException) + { + // ChildSelector couldn't select any child. + this.Status = "RanToCompletion_NoChildMatched"; + } + catch (EvaluateDynamicPropertyException) + { + this.Status = "Failed_EvaluateDynamicProperty"; + } + catch (Exception) + { + // TODO: Consider checking the exception for specific Data entry and setting Status to that. + // This would allow callbacks such as BeforeVisitNode to throw exceptions and control the status instead of it being Failed. + // Unexpected exception was thrown in actions, callbacks, or elsewhere, resulting in failure and cancellation. + this.Status = "Failed"; + } + finally + { + this.CancelWalkTree(); + } + + try + { + // Exceptions are thrown here if tree walker hit a timeout, was cancelled, or failed. + await walkTreeTask; + } + catch (NoChildMatchedException) + { + // For now, suppressing this exception so that its treated as successful end stage. + } + + return this.Status; + } + + /// + /// Visits a TreeNode in the ForgeTree, performing type-specific behavior as necessary before selecting the next child to visit. + /// + /// The TreeNode key to visit. + /// If the node-level timeout was hit. + /// If the action-level timeout was hit. + /// If the cancellation token was triggered. + /// If an unexpected exception was thrown. + /// The key of the next child to visit, or null if no match was found. + internal async Task VisitNode(string treeNodeKey) + { + TreeNode treeNode = this.Schema.Tree[treeNodeKey]; + + // Do type-specific behavior. + switch (treeNode.Type) + { + case TreeNodeType.Leaf: + { + await this.PerformLeafTypeBehavior(treeNode).ConfigureAwait(false); + + // Leaf type can't have ChildSelector so we return here. + return null; + } + case TreeNodeType.Action: + { + // Exceptions are thrown here if the actions hit a timeout, were cancelled, or failed. + await this.PerformActionTypeBehavior(treeNode, treeNodeKey).ConfigureAwait(false); + break; + } + default: + { + break; + } + } + + // Return next child to visit, if possible. + return await this.SelectChild(treeNode).ConfigureAwait(false); + } + + /// + /// Iterates the child selectors for a matching child. + /// + /// The TreeNode to select a child from. + /// The key of the next child to visit, or null if no match was found. + internal async Task SelectChild(TreeNode treeNode) + { + if (treeNode.ChildSelector == null) + { + // No children to select, we are done walking the tree. + return null; + } + + foreach (ChildSelector cs in treeNode.ChildSelector) + { + // Empty expressions default to true. Otherwise, evaluate the expression. + if (string.IsNullOrWhiteSpace(cs.ShouldSelect) && !string.IsNullOrWhiteSpace(cs.Child)) + { + return cs.Child; + } + if ((bool)await this.EvaluateDynamicProperty(cs.ShouldSelect, typeof(bool)).ConfigureAwait(false)) + { + return cs.Child; + } + } + + // No children were successfully matched. + throw new NoChildMatchedException("ChildSelector couldn't match any child."); + } + + /// + /// Performs Leaf TreeNodeType behavior. + /// + /// The Leaf TreeNode. + internal async Task PerformLeafTypeBehavior(TreeNode treeNode) + { + // Check if Leaf node contains the LeafNodeSummaryAction and commit ActionInput as ActionResponse if it does. + if (treeNode.Actions == null || treeNode.Actions.Count != 1) + { + return; + } + + foreach (KeyValuePair kvp in treeNode.Actions) + { + string treeActionKey = kvp.Key; + TreeAction treeAction = kvp.Value; + + if (treeAction.Action != LeafNodeSummaryAction) + { + return; + } + + ActionResponse actionResponse = await this.EvaluateDynamicProperty(treeAction.Input, typeof(ActionResponse)).ConfigureAwait(false); + await this.CommitActionResponse(treeActionKey, actionResponse).ConfigureAwait(false); + return; + } + } + + /// + /// Executes the actions for the given tree node. + /// Returns without throwing exception if all actions were completed successfully. + /// + /// The TreeNode containing actions to execute. + /// The TreeNode's key where the actions are taking place. + /// If the node-level timeout was hit. + /// If the action-level timeout was hit. + /// If the cancellation token was triggered. + internal async Task PerformActionTypeBehavior(TreeNode treeNode, string treeNodeKey) + { + List actionTasks = new List(); + + if (treeNode.Actions == null) + { + return; + } + + // Start new parallel tasks for each action on this node. + foreach (KeyValuePair kvp in treeNode.Actions) + { + string treeActionKey = kvp.Key; + TreeAction treeAction = kvp.Value; + + if (await this.GetOutputAsync(treeActionKey).ConfigureAwait(false) != null) + { + // Handle rehydration case. Commit LastTreeAction if it was not committed. Do not execute actions for which we have already received a response. + if (await this.GetLastTreeAction().ConfigureAwait(false) == null) + { + await this.CommitLastTreeAction(treeActionKey).ConfigureAwait(false); + } + + continue; + } + + if (this.actionsMap.TryGetValue(treeAction.Action, out ActionDefinition actionDefinition)) + { + actionTasks.Add(this.ExecuteActionWithRetry(treeNodeKey, treeActionKey, treeAction, actionDefinition, this.walkTreeCts.Token)); + } + } + + // Wait for all parallel tasks to complete until the given timout. + // If any task hits a timeout, gets cancelled, or fails, an exception will be thrown. + // Note: CancelWalkTree is called at the end of every session to ensure all Actions/Tasks see the triggered cancellation token. + Task nodeTimeoutTask = Task.Delay((int)await this.EvaluateDynamicProperty(treeNode.Timeout ?? -1, typeof(int)).ConfigureAwait(false), this.walkTreeCts.Token); + actionTasks.Add(nodeTimeoutTask); + + while (actionTasks.Count > 1) + { + // Throw if cancellation was requested between actions completing. + this.walkTreeCts.Token.ThrowIfCancellationRequested(); + + Task completedTask = await Task.WhenAny(actionTasks).ConfigureAwait(false); + actionTasks.Remove(completedTask); + + if (completedTask == nodeTimeoutTask) + { + // Throw on cancellation requested if that's the reason the timeout task completed. + this.walkTreeCts.Token.ThrowIfCancellationRequested(); + + // NodeTimeout was hit, throw a special exception to differentiate between timeout and cancellation. + throw new TimeoutException("Hit node-level timeout in TreeNodeKey: " + treeNodeKey); + } + + // Await the completed task to propagate any exceptions. + // Exceptions are thrown here if the action hit a timeout, was cancelled, or failed. + await completedTask; + } + } + + /// + /// Executes the given action. Attempts retries according to the retry policy and timeout. + /// Returns without throwing exception if the action was completed successfully. + /// + /// The TreeNode's key where the actions are taking place. + /// The TreeAction's key of the action taking place. + /// The TreeAction object that holds properties of the action. + /// The object holding definitions for the action to execute. + /// The cancellation token. + /// If the action-level timeout was hit. + /// If the cancellation token was triggered. + internal async Task ExecuteActionWithRetry( + string treeNodeKey, + string treeActionKey, + TreeAction treeAction, + ActionDefinition actionDefinition, + CancellationToken token) + { + // Initialize values. Default infinite timeout. Default RetryPolicyType.None. + int retryCount = 0; + Exception innerException = null; + Stopwatch stopwatch = new Stopwatch(); + + int actionTimeout = (int)await this.EvaluateDynamicProperty(treeAction.Timeout ?? -1, typeof(int)).ConfigureAwait(false); + RetryPolicyType retryPolicyType = treeAction.RetryPolicy != null ? treeAction.RetryPolicy.Type : RetryPolicyType.None; + TimeSpan waitTime = treeAction.RetryPolicy != null ? TimeSpan.FromMilliseconds(treeAction.RetryPolicy.MinBackoffMs) : new TimeSpan(); + + // Kick off timers. + Task actionTimeoutTask = Task.Delay(actionTimeout, token); + stopwatch.Start(); + + // Attmpt to ExecuteAction based on RetryPolicy and Timeout. + // Throw on non-retriable exceptions. + while (actionTimeout == -1 || stopwatch.ElapsedMilliseconds < actionTimeout) + { + token.ThrowIfCancellationRequested(); + + try + { + await this.ExecuteAction(treeNodeKey, treeActionKey, treeAction, actionDefinition, actionTimeoutTask, token).ConfigureAwait(false); + return; // success! + } + catch (OperationCanceledException) + { + throw; // non-retriable exception + } + catch (ActionTimeoutException) + { + throw; // non-retriable exception + } + catch (EvaluateDynamicPropertyException) + { + throw; // non-retriable exception + } + catch (Exception e) + { + // Cache exception as innerException in case we need to throw ActionTimeoutException. + innerException = e; + + // Hit retriable exception. Retry according to RetryPolicy. + // When retries are exhausted, throw ActionTimeoutException with Exception e as the innerException. + switch (retryPolicyType) + { + case RetryPolicyType.FixedInterval: + { + // FixedInterval retries every MinBackoffMs until the timeout. + // Ex) 200ms, 200ms, 200ms... + waitTime = TimeSpan.FromMilliseconds(treeAction.RetryPolicy.MinBackoffMs); + break; + } + case RetryPolicyType.ExponentialBackoff: + { + // ExponentialBackoff retries every Math.Min(MinBackoffMs * 2^(retryCount), MaxBackoffMs) until the timeout. + // Ex) 100ms, 200ms, 400ms... + waitTime = TimeSpan.FromMilliseconds(Math.Min(treeAction.RetryPolicy.MaxBackoffMs, waitTime.TotalMilliseconds * 2)); + break; + } + case RetryPolicyType.None: + default: + { + // No retries. Break out below to throw non-retriable exception. + break; + } + } + } + + // Break out if no retry policy set. + if (retryPolicyType == RetryPolicyType.None) + { + // If the retries have exhausted and the ContinuationOnRetryExhaustion flag is set, commit a new ActionResponse + // with the status set to RetryExhaustedOnAction and return. + if (treeAction.ContinuationOnRetryExhaustion) + { + ActionResponse timeoutResponse = new ActionResponse + { + Status = "RetryExhaustedOnAction" + }; + + await this.CommitActionResponse(treeActionKey, timeoutResponse).ConfigureAwait(false); + return; + } + + break; + } + + // Break out early if we would hit timeout before next retry. + if (actionTimeout != -1 && stopwatch.ElapsedMilliseconds + waitTime.TotalMilliseconds >= actionTimeout) + { + // If the timeout is hit and the ContinuationOnTimeout flag is set, commit a new ActionResponse + // with the status set to TimeoutOnAction and return. + if (treeAction.ContinuationOnTimeout) + { + ActionResponse timeoutResponse = new ActionResponse + { + Status = "TimeoutOnAction" + }; + + await this.CommitActionResponse(treeActionKey, timeoutResponse).ConfigureAwait(false); + return; + } + + break; + } + + token.ThrowIfCancellationRequested(); + await Task.Delay(waitTime, token).ConfigureAwait(false); + retryCount++; + } + + // Retries are exhausted. Throw ActionTimeoutException with executeAction exception as innerException. + throw new ActionTimeoutException( + string.Format( + "Action did not complete successfully. TreeNodeKey: {0}, TreeActionKey: {1}, ActionName: {2}, RetryCount: {3}, RetryPolicy: {4}", + treeNodeKey, + treeActionKey, + treeAction.Action, + retryCount, + retryPolicyType), + innerException); + } + + /// + /// Executes the given actionTask and commits the ActionResponse to forgeState on success. + /// + /// The TreeNode's key where the actions are taking place. + /// The TreeAction's key of the action taking place. + /// The TreeAction object that holds properties of the action. + /// The object holding definitions for the action to execute. + /// The delay task tied to the action timeout. + /// The cancellation token. + /// If the action-level timeout was hit. + /// If the cancellation token was triggered. + /// + /// RanToCompletion if the action was completed successfully. + /// Exceptions are thrown on timeout, cancellation, or retriable failures. + /// + internal async Task ExecuteAction( + string treeNodeKey, + string treeActionKey, + TreeAction treeAction, + ActionDefinition actionDefinition, + Task actionTimeoutTask, + CancellationToken token) + { + // Set up a linked cancellation token to trigger on timeout if ContinuationOnTimeout is set. + // This ensures the runActionTask gets canceled when Forge timeout is hit. + CancellationTokenSource actionCts = CancellationTokenSource.CreateLinkedTokenSource(token); + token = treeAction.ContinuationOnTimeout ? actionCts.Token : token; + + // Evaluate the dynamic properties that are used by the actionTask. + ActionContext actionContext = new ActionContext( + this.Parameters.SessionId, + treeNodeKey, + treeActionKey, + treeAction.Action, + await this.EvaluateDynamicProperty(treeAction.Input, actionDefinition.InputType).ConfigureAwait(false), + await this.EvaluateDynamicProperty(treeAction.Properties, null).ConfigureAwait(false), + this.Parameters.UserContext, + token, + this.Parameters.ForgeState + ); + + // Instantiate the BaseAction-derived ActionType class and invoke the RunAction method on it. + var actionObject = Activator.CreateInstance(actionDefinition.ActionType); + MethodInfo method = typeof(BaseAction).GetMethod("RunAction"); + Task runActionTask = (Task) method.Invoke(actionObject, new object[] { actionContext }); + + // Await for the first completed task between our runActionTask and the timeout task. + // This allows us to continue without awaiting the runActionTask upon timeout. + var completedTask = await Task.WhenAny(runActionTask, actionTimeoutTask).ConfigureAwait(false); + + if (completedTask == actionTimeoutTask) + { + // Throw on cancellation requested if that's the reason the timeout task completed. + token.ThrowIfCancellationRequested(); + + // If the timeout is hit and the ContinuationOnTimeout flag is set, commit a new ActionResponse + // with the status set to TimeoutOnAction and return. + if (treeAction.ContinuationOnTimeout) + { + // Trigger linked cancellation token before continuing to ensure the runActionTask gets cancelled. + actionCts.Cancel(); + + ActionResponse timeoutResponse = new ActionResponse + { + Status = "TimeoutOnAction" + }; + + await this.CommitActionResponse(treeActionKey, timeoutResponse).ConfigureAwait(false); + return; + } + + // ActionTimeout has been hit. Throw special exception to indicate this. + throw new ActionTimeoutException(string.Format( + "ActionTimeoutTask timed out before Action could complete. TreeNodeKey: {0}, TreeActionKey: {1}, ActionName: {2}.", + treeNodeKey, + treeActionKey, + treeAction.Action)); + } + else + { + // Handle the completed runActionTask. + if (runActionTask.Status == TaskStatus.RanToCompletion) + { + await this.CommitActionResponse(treeActionKey, await runActionTask).ConfigureAwait(false); + } + + // Await the completed task to propagate any exceptions. + // Exceptions are thrown here if the action hit a timeout, was cancelled, or failed. + await runActionTask; + } + } + + /// + /// Iterates through the given schema object, evaluating any Roslyn expressions found in the values. + /// When a knownType is given, Forge will instantiate that type instead of a dynamic object. + /// String properties matching the represent a code-snippet that will be evaluated. + /// + /// The object given from the schema. + /// The type of the object being evaluated. Null here represents an unknown type that will be evaluated dynamically. + /// If exceptions are thrown while evaluating the dynamic property. + /// The properties after evaluation. + public async Task EvaluateDynamicProperty(dynamic schemaObj, Type knownType) + { + try + { + if (schemaObj == null) + { + return null; + } + else if (schemaObj is string && schemaObj.StartsWith(RoslynLeadingText)) + { + // Case when schema property is a Roslyn expression. + // Evaluate it as either the knownType if it exists, the embeded in the RoslynRegex, or a string by default, in that order. + Match result = RoslynRegex.Match(schemaObj); + if (result.Success) + { + string typeStr = string.IsNullOrWhiteSpace(result.Groups[2].Value) ? "String" : result.Groups[2].Value; + Type type = knownType ?? Type.GetType("System." + typeStr); + + MethodInfo method = typeof(ExpressionExecutor).GetMethod("Execute"); + MethodInfo genericMethod = method.MakeGenericMethod(type); + string expression = schemaObj.Substring(result.Groups[0].Value.Length); + var res = await (dynamic) genericMethod.Invoke(this.expressionExecutor, new object[] { expression }); + return res; + } + } + else if (schemaObj is string) + { + foreach (var kvp in this.Parameters.ExternalExecutors) + { + string regexString = kvp.Key; + Func> externalExecutor = kvp.Value; + + if (schemaObj.StartsWith(regexString)) + { + // Case when schema property matches an external executor. + // Evaluate it and return the result as the knownType if it exists or as a string otherwise. + var result = await externalExecutor(schemaObj.Substring(regexString.Length), this.walkTreeCts.Token).ConfigureAwait(false); + return knownType != null ? Convert.ChangeType(result, knownType) : result; + } + } + + return schemaObj; + } + else if (schemaObj is JObject) + { + // Case when schema object has properties (i.e. is a dictionary). + // Instantiate and use the knownType if given, then evaluate each property using recursion. + dynamic knownObj = Activator.CreateInstance(knownType ?? typeof(object)); + + IDictionary propertyValues = schemaObj.ToObject>(); + foreach (string key in new List(propertyValues.Keys)) + { + if (knownType != null) + { + var prop = knownType.GetProperty(key); + prop.SetValue(knownObj, await this.EvaluateDynamicProperty(propertyValues[key], prop.PropertyType).ConfigureAwait(false)); + } + else + { + propertyValues[key] = await this.EvaluateDynamicProperty(propertyValues[key], null).ConfigureAwait(false); + } + } + + return knownType != null ? knownObj : (dynamic)JObject.FromObject(propertyValues); + } + else if (schemaObj is JArray) + { + // Case when schema object is an array. + // Create an array with the knownType if given, then use recursion to evaluate each index. + dynamic knownObj = Activator.CreateInstance(knownType ?? typeof(object[]), schemaObj.Count); + + for (int i = 0; i < schemaObj.Count; i++) + { + if (knownType != null) + { + knownObj.SetValue(await this.EvaluateDynamicProperty(schemaObj[i], knownType.GetElementType()).ConfigureAwait(false), i); + } + else + { + schemaObj[i] = await this.EvaluateDynamicProperty(schemaObj[i], null).ConfigureAwait(false); + } + } + + return knownType != null ? knownObj : schemaObj; + } + else if (schemaObj is JValue) + { + // Case when schema object is a value type. + // Return the value as the knownType if given. + return knownType != null ? Convert.ChangeType(schemaObj.Value, knownType) : schemaObj.Value; + } + else + { + // Case when schema object is a value type. + // Return the value as the knownType if given. + return knownType != null ? Convert.ChangeType(schemaObj, knownType) : schemaObj; + } + } + catch (OperationCanceledException) + { + throw; // rethrow on cancelled. + } + catch (Exception e) + { + throw new EvaluateDynamicPropertyException( + string.Format("EvaluateDynamicProperty failed to parse schemaObj: {0}, knownType: {1}.", schemaObj?.ToString(), knownType), + e); + } + + return null; + } + + /// + /// Commits the ActionResponse to the forgeState. + /// This allows the ActionResponses to be dynamically referenced in the ForgeTree through ITreeSession interface. + /// + /// The TreeAction's key of the action that was executed. + /// The action response object returned from the action. + private async Task CommitActionResponse(string treeActionKey, ActionResponse actionResponse) + { + await this.Parameters.ForgeState.Set(treeActionKey + ActionResponseSuffix, actionResponse).ConfigureAwait(false); + await this.CommitLastTreeAction(treeActionKey).ConfigureAwait(false); + } + + /// + /// Commits the current tree node to the forgeState. + /// The wrapper class has access to this state, allowing it to rehydrate/retry on failures if desired. + /// + /// The TreeNode's key that tree walker is currently walking. + private Task CommitCurrentTreeNode(string treeNodeKey) + { + // TODO: Consider adding a "nodeStep" to save if we already completed the BeforeVisitNode step and should skip straight to VisitNode. + return this.Parameters.ForgeState.Set(CurrentTreeNodeSuffix, treeNodeKey); + } + + /// + /// Commits the last tree action to the forgeState. + /// + /// The TreeAction's key of the last action that was committed. + private Task CommitLastTreeAction(string treeActionKey) + { + return this.Parameters.ForgeState.Set(LastTreeActionSuffix, treeActionKey); + } + + /// + /// Initializes the actionsMap from the given assembly. + /// This map is generated using reflection to find all the classes with the applied ForgeActionAttribute from the given Assembly. + /// + /// The Assembly containing ForgeActionAttribute tagged classes. + /// The map of string ActionNames to ActionDefinitions. + public static void GetActionsMapFromAssembly(Assembly forgeActionsAssembly, out Dictionary actionsMap) + { + actionsMap = new Dictionary(); + + if (forgeActionsAssembly == null) + { + return; + } + + foreach (Type type in forgeActionsAssembly.GetExportedTypes()) + { + // Find all classes with the applied ForgeActionAttribute. + ForgeActionAttribute forgeAction = (ForgeActionAttribute) type.GetCustomAttribute(typeof(ForgeActionAttribute), false); + if (forgeAction != null) + { + // Confirm that ForgeActionAttribute is attached to only BaseAction classes and there are no duplicates. + Type derived = type; + bool isBaseAction = false; + do + { + if (derived == typeof(BaseAction)) + { + isBaseAction = true; + break; + } + + derived = derived.BaseType; + } while (derived != null); + + if (isBaseAction) + { + actionsMap.Add(type.Name, new ActionDefinition() { ActionType = type, InputType = forgeAction.InputType }); + continue; + } + else + { + throw new CustomAttributeFormatException( + string.Format( + "The given type: {0} must implement the BaseAction abstract class in order to apply the ForgeActionAttribute.", + type.ToString())); + } + } + } + } + } +} \ No newline at end of file diff --git a/Forge/Forge.sln b/Forge/Forge.sln new file mode 100644 index 0000000..59a24ba --- /dev/null +++ b/Forge/Forge.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29215.179 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Forge.TreeWalker", "Forge.TreeWalker\Forge.TreeWalker.csproj", "{00FC1C22-6AE9-4F60-8A3E-05885BA34C9C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Forge.DataContracts", "Forge.DataContracts\Forge.DataContracts.csproj", "{C49A8494-13E5-4214-8434-708BA280C5B1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Forge.TreeWalker.UnitTests", "Forge.TreeWalker.UnitTests\Forge.TreeWalker.UnitTests.csproj", "{A33AF1FF-1291-4CB1-A733-3243E1FE967E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {00FC1C22-6AE9-4F60-8A3E-05885BA34C9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00FC1C22-6AE9-4F60-8A3E-05885BA34C9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00FC1C22-6AE9-4F60-8A3E-05885BA34C9C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00FC1C22-6AE9-4F60-8A3E-05885BA34C9C}.Release|Any CPU.Build.0 = Release|Any CPU + {C49A8494-13E5-4214-8434-708BA280C5B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C49A8494-13E5-4214-8434-708BA280C5B1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C49A8494-13E5-4214-8434-708BA280C5B1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C49A8494-13E5-4214-8434-708BA280C5B1}.Release|Any CPU.Build.0 = Release|Any CPU + {A33AF1FF-1291-4CB1-A733-3243E1FE967E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A33AF1FF-1291-4CB1-A733-3243E1FE967E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A33AF1FF-1291-4CB1-A733-3243E1FE967E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A33AF1FF-1291-4CB1-A733-3243E1FE967E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A8067EC1-B359-4193-8603-5E6524B2B3A1} + EndGlobalSection +EndGlobal