diff --git a/src/Test/Directory.Build.props b/src/Test/Directory.Build.props index 77a38eb8..0660e52c 100644 --- a/src/Test/Directory.Build.props +++ b/src/Test/Directory.Build.props @@ -1,6 +1,7 @@ net6.0;net6.0-windows + latest diff --git a/src/Test/WorkflowApplicationTestExtensions/NoPersistAsyncActivity.cs b/src/Test/WorkflowApplicationTestExtensions/NoPersistAsyncActivity.cs new file mode 100644 index 00000000..c73c993f --- /dev/null +++ b/src/Test/WorkflowApplicationTestExtensions/NoPersistAsyncActivity.cs @@ -0,0 +1,42 @@ +using System.Activities; +using System.Activities.Hosting; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace WorkflowApplicationTestExtensions +{ + /// + /// Activity that induces Idle for a few milliseconds but not PersistableIdle. + /// This is similar to UiPath asynchronous in-process activities. + /// + public class NoPersistAsyncActivity : NativeActivity + { + private readonly Variable _noPersist = new(); + + protected override bool CanInduceIdle => true; + + protected override void CacheMetadata(NativeActivityMetadata metadata) + { + metadata.AddImplementationVariable(_noPersist); + metadata.AddDefaultExtensionProvider(() => new BookmarkResumer()); + base.CacheMetadata(metadata); + } + + protected override void Execute(NativeActivityContext context) + { + _noPersist.Get(context).Enter(context); + context.GetExtension().ResumeSoon(context.CreateBookmark()); + } + } + + public class BookmarkResumer : IWorkflowInstanceExtension + { + private WorkflowInstanceProxy _instance; + public IEnumerable GetAdditionalExtensions() => []; + public void SetInstance(WorkflowInstanceProxy instance) => _instance = instance; + public void ResumeSoon(Bookmark bookmark) => Task.Delay(10).ContinueWith(_ => + { + _instance.BeginResumeBookmark(bookmark, null, null, null); + }); + } +} diff --git a/src/Test/WorkflowApplicationTestExtensions/SuspendingWrapper.cs b/src/Test/WorkflowApplicationTestExtensions/SuspendingWrapper.cs new file mode 100644 index 00000000..1bf2e953 --- /dev/null +++ b/src/Test/WorkflowApplicationTestExtensions/SuspendingWrapper.cs @@ -0,0 +1,68 @@ +using System; +using System.Activities; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.ExceptionServices; + +namespace WorkflowApplicationTestExtensions +{ + /// + /// Wrapper over one/multiple sequential activities. + /// Between scheduling the activity/activities, it induces PersistableIdle + /// by creating bookmarks. + /// The idea is to induce unload/load as much as possible to test persistence + /// serialization/deserialization. + /// When using , the bookmarks + /// can be automatically resumed and workflow continued transparently until + /// completion. + /// + public class SuspendingWrapper : NativeActivity + { + private readonly Variable _nextIndexToExecute = new(); + public List Activities { get; } + protected override bool CanInduceIdle => true; + + public SuspendingWrapper(IEnumerable activities) + { + Activities = activities.ToList(); + } + + public SuspendingWrapper(Activity activity) : this([activity]) + { + } + + public SuspendingWrapper() : this([]) + { + } + + protected override void CacheMetadata(NativeActivityMetadata metadata) + { + metadata.AddImplementationVariable(_nextIndexToExecute); + base.CacheMetadata(metadata); + } + + protected override void Execute(NativeActivityContext context) => ExecuteNext(context); + + private void OnChildCompleted(NativeActivityContext context, ActivityInstance completedInstance) => + ExecuteNext(context); + + private void OnChildFaulted(NativeActivityFaultContext faultContext, Exception propagatedException, ActivityInstance propagatedFrom) => + ExceptionDispatchInfo.Capture(propagatedException).Throw(); + + private void ExecuteNext(NativeActivityContext context) => + context.CreateBookmark( + $"{WorkflowApplicationTestExtensions.AutoResumedBookmarkNamePrefix}{Guid.NewGuid()}", + AfterResume); + + private void AfterResume(NativeActivityContext context, Bookmark bookmark, object value) + { + var nextIndex = _nextIndexToExecute.Get(context); + if (nextIndex == Activities.Count) + { + return; + } + _nextIndexToExecute.Set(context, nextIndex + 1); + context.ScheduleActivity(Activities[nextIndex], OnChildCompleted, OnChildFaulted); + } + } +} diff --git a/src/Test/WorkflowApplicationTestExtensions/WorkflowApplicationTestExtensions.cs b/src/Test/WorkflowApplicationTestExtensions/WorkflowApplicationTestExtensions.cs new file mode 100644 index 00000000..7f63c8b0 --- /dev/null +++ b/src/Test/WorkflowApplicationTestExtensions/WorkflowApplicationTestExtensions.cs @@ -0,0 +1,96 @@ +using JsonFileInstanceStore; +using System; +using System.Activities; +using System.Diagnostics; +using System.Threading.Tasks; +using StringToObject = System.Collections.Generic.IDictionary; + +namespace WorkflowApplicationTestExtensions +{ + public static class WorkflowApplicationTestExtensions + { + public const string AutoResumedBookmarkNamePrefix = "AutoResumedBookmark_"; + + public record WorkflowApplicationResult(StringToObject Outputs, int PersistenceCount); + + /// + /// Simple API to wait for the workflow to complete or propagate to the caller any error. + /// Also, when PersistableIdle, will automatically Unload, Load, resume some bookmarks + /// (those named "AutoResumedBookmark_...") and continue execution. + /// + public static WorkflowApplicationResult RunUntilCompletion(this WorkflowApplication application) + { + var persistenceCount = 0; + var output = new TaskCompletionSource(); + application.Completed += (WorkflowApplicationCompletedEventArgs args) => + { + if (args.TerminationException is { } ex) + { + output.TrySetException(ex); + } + if (args.CompletionState == ActivityInstanceState.Canceled) + { + throw new OperationCanceledException("Workflow canceled."); + } + output.TrySetResult(new(args.Outputs, persistenceCount)); + }; + + application.Aborted += args => output.TrySetException(args.Reason); + + application.InstanceStore = new FileInstanceStore(Environment.CurrentDirectory); + application.PersistableIdle += (WorkflowApplicationIdleEventArgs args) => + { + Debug.WriteLine("PersistableIdle"); + var bookmarks = args.Bookmarks; + Task.Delay(100).ContinueWith(_ => + { + try + { + if (++persistenceCount > 100) + { + throw new Exception("Persisting too many times, aborting test."); + } + application = CloneWorkflowApplication(application); + application.Load(args.InstanceId); + foreach (var bookmark in bookmarks) + { + application.ResumeBookmark(new Bookmark(bookmark.BookmarkName), null); + } + } + catch (Exception ex) + { + output.TrySetException(ex); + } + }); + return PersistableIdleAction.Unload; + }; + + application.BeginRun(null, null); + + try + { + output.Task.Wait(TimeSpan.FromSeconds(15)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + } + return output.Task.GetAwaiter().GetResult(); + } + + private static WorkflowApplication CloneWorkflowApplication(WorkflowApplication application) + { + var clone = new WorkflowApplication(application.WorkflowDefinition, application.DefinitionIdentity) + { + Aborted = application.Aborted, + Completed = application.Completed, + PersistableIdle = application.PersistableIdle, + InstanceStore = application.InstanceStore, + }; + foreach (var extension in application.Extensions.GetAllSingletonExtensions()) + { + clone.Extensions.Add(extension); + } + return clone; + } + } +} diff --git a/src/Test/WorkflowApplicationTestExtensions/WorkflowApplicationTestExtensions.csproj b/src/Test/WorkflowApplicationTestExtensions/WorkflowApplicationTestExtensions.csproj new file mode 100644 index 00000000..787ec943 --- /dev/null +++ b/src/Test/WorkflowApplicationTestExtensions/WorkflowApplicationTestExtensions.csproj @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/Test/WorkflowApplicationTestExtensions/WorkflowApplicationTestSamples.cs b/src/Test/WorkflowApplicationTestExtensions/WorkflowApplicationTestSamples.cs new file mode 100644 index 00000000..7fe4f0bb --- /dev/null +++ b/src/Test/WorkflowApplicationTestExtensions/WorkflowApplicationTestSamples.cs @@ -0,0 +1,54 @@ +using Shouldly; +using System; +using System.Activities; +using System.Activities.Statements; +using System.Threading.Tasks; +using Xunit; + +namespace WorkflowApplicationTestExtensions +{ + public class WorkflowApplicationTestSamples + { + [Fact] + public void RunUntilCompletion_Outputs() + { + var app = new WorkflowApplication(new DynamicActivity + { + Properties = { new DynamicActivityProperty { Name = "result", Type = typeof(OutArgument) } }, + Implementation = () => new Assign { To = new Reference("result"), Value = "value" } + }); + app.RunUntilCompletion().Outputs["result"].ShouldBe("value"); + } + + [Fact] + public void RunUntilCompletion_Faulted() + { + var app = new WorkflowApplication(new Throw { Exception = new InArgument(_ => new ArgumentException()) }); + Should.Throw(app.RunUntilCompletion); + } + + [Fact] + public void RunUntilCompletion_Aborted() + { + var app = new WorkflowApplication(new Delay { Duration = TimeSpan.MaxValue }); + Task.Delay(10).ContinueWith(_ => app.Abort()); + Should.Throw(app.RunUntilCompletion); + } + + [Fact] + public void RunUntilCompletion_AutomaticPersistence() + { + var app = new WorkflowApplication(new SuspendingWrapper + { + Activities = + { + new WriteLine(), + new NoPersistAsyncActivity(), + new WriteLine() + } + }); + var result = app.RunUntilCompletion(); + result.PersistenceCount.ShouldBe(4); + } + } +} diff --git a/src/UiPath.Workflow.Runtime/Hosting/WorkflowInstanceExtensionManager.cs b/src/UiPath.Workflow.Runtime/Hosting/WorkflowInstanceExtensionManager.cs index 6e1cbfc6..b71a83ce 100644 --- a/src/UiPath.Workflow.Runtime/Hosting/WorkflowInstanceExtensionManager.cs +++ b/src/UiPath.Workflow.Runtime/Hosting/WorkflowInstanceExtensionManager.cs @@ -90,7 +90,7 @@ public virtual void Add(Func extensionCreationFunction) where T : class ExtensionProviders.Add(new KeyValuePair(typeof(T), new WorkflowInstanceExtensionProvider(extensionCreationFunction))); } - internal List GetAllSingletonExtensions() => _allSingletonExtensions; + public List GetAllSingletonExtensions() => _allSingletonExtensions; internal void AddAllExtensionTypes(HashSet extensionTypes) { diff --git a/src/UiPath.Workflow.sln b/src/UiPath.Workflow.sln index 3b22a654..ac5edfeb 100644 --- a/src/UiPath.Workflow.sln +++ b/src/UiPath.Workflow.sln @@ -46,7 +46,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Perf", "Perf", "{8E6A125F-0 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreWf.Benchmarks", "Perf\CoreWf.Benchmarks\CoreWf.Benchmarks.csproj", "{FE1D4185-DF43-467C-8380-CBA60C6B92DA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CustomTestObjects", "Test\CustomTestObjects\CustomTestObjects.csproj", "{E8CED08F-0838-4167-AA20-5BD42372558E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CustomTestObjects", "Test\CustomTestObjects\CustomTestObjects.csproj", "{E8CED08F-0838-4167-AA20-5BD42372558E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkflowApplicationTestExtensions", "Test\WorkflowApplicationTestExtensions\WorkflowApplicationTestExtensions.csproj", "{3942321A-09CE-4CF0-A4B7-BF29EF9A17AF}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -118,6 +120,10 @@ Global {E8CED08F-0838-4167-AA20-5BD42372558E}.Debug|Any CPU.Build.0 = Debug|Any CPU {E8CED08F-0838-4167-AA20-5BD42372558E}.Release|Any CPU.ActiveCfg = Release|Any CPU {E8CED08F-0838-4167-AA20-5BD42372558E}.Release|Any CPU.Build.0 = Release|Any CPU + {3942321A-09CE-4CF0-A4B7-BF29EF9A17AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3942321A-09CE-4CF0-A4B7-BF29EF9A17AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3942321A-09CE-4CF0-A4B7-BF29EF9A17AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3942321A-09CE-4CF0-A4B7-BF29EF9A17AF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -134,6 +140,7 @@ Global {154DD961-CA7E-410A-B31F-E057A06E2A68} = {4D92EFCA-7902-49DE-B98F-8CF7675ED86C} {FE1D4185-DF43-467C-8380-CBA60C6B92DA} = {8E6A125F-0530-4D8E-8CD4-3429DAF57706} {E8CED08F-0838-4167-AA20-5BD42372558E} = {4D92EFCA-7902-49DE-B98F-8CF7675ED86C} + {3942321A-09CE-4CF0-A4B7-BF29EF9A17AF} = {4D92EFCA-7902-49DE-B98F-8CF7675ED86C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {1ED6A6D7-ACEE-4780-B630-50DCAED74BA9}