diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 2f8f487..bb02267 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -2,7 +2,10 @@ variables:
solution: 'src/Eto.Toolkit.sln'
build.version: '0.1.0-ci-$(Build.BuildNumber)'
build.configuration: 'Release'
- build.arguments: /restore /t:Build;Pack /p:BuildVersion=$(build.version) /p:BuildBranch=$(Build.SourceBranch)
+ build.basearguments: '/v:minimal /p:BuildVersion=$(build.version) /p:BuildBranch=$(Build.SourceBranch)'
+ build.arguments: '/restore /t:Build;Pack $(build.basearguments)'
+ build.dotnetversion: 3.1.100
+ build.xamarinversion: 6_6_0
trigger:
- master
@@ -17,8 +20,16 @@ jobs:
steps:
- checkout: self
submodules: true
- - script: sudo $AGENT_HOMEDIRECTORY/scripts/select-xamarin-sdk.sh 5_18_1
+
+ - script: sudo $AGENT_HOMEDIRECTORY/scripts/select-xamarin-sdk.sh $(build.xamarinversion)
displayName: 'Select Xamarin SDK version'
+
+ - task: UseDotNet@2
+ displayName: Install .NET Core SDK
+ inputs:
+ packageType: sdk
+ version: $(build.dotnetversion)
+
- task: MSBuild@1
displayName: Restore, Build and Package
inputs:
@@ -26,6 +37,7 @@ jobs:
platform: '$(build.platform)'
configuration: '$(build.configuration)'
msbuildArguments: '$(build.arguments)'
+
- task: PublishBuildArtifacts@1
displayName: Publish nupkg
inputs:
@@ -33,6 +45,23 @@ jobs:
artifactName: nuget
publishLocation: container
+ - task: CopyFiles@2
+ displayName: Copy Eto.UnitTest.Desktop.app
+ inputs:
+ sourceFolder: artifacts/core/$(build.configuration)/net461
+ contents: Eto.UnitTest.Desktop.app/**
+ targetFolder: $(Build.ArtifactStagingDirectory)/dmg
+
+ - script: hdiutil create -fs HFS+ -srcfolder "$(Build.ArtifactStagingDirectory)/dmg" -volname "Eto.UnitTest.Desktop" "$(Build.ArtifactStagingDirectory)/Eto.UnitTest.Desktop.dmg"
+ displayName: 'Create Eto.UnitTest.Desktop.dmg'
+
+ - task: PublishBuildArtifacts@1
+ displayName: Publish Eto.UnitTest.Desktop for Mac
+ inputs:
+ pathtoPublish: $(Build.ArtifactStagingDirectory)/Eto.UnitTest.Desktop.dmg
+ artifactName: Eto.UnitTest
+ publishLocation: container
+
- job: Windows
pool:
vmImage: 'windows-2019'
@@ -41,16 +70,45 @@ jobs:
steps:
- checkout: self
submodules: true
+
+ - task: UseDotNet@2
+ displayName: Install .NET Core SDK
+ inputs:
+ packageType: sdk
+ version: $(build.dotnetversion)
+
- task: MSBuild@1
displayName: Restore, Build and Package
inputs:
solution: '$(solution)'
platform: '$(build.platform)'
configuration: '$(build.configuration)'
- msbuildArguments: '$(build.arguments)'
+ msbuildArguments: '/t:Build;Pack $(build.arguments)'
+
+ - task: MSBuild@1
+ displayName: Publish Eto.UnitTest.Desktop
+ inputs:
+ solution: 'src/Eto.UnitTest.Desktop/Eto.UnitTest.Desktop.csproj'
+ platform: '$(build.platform)'
+ configuration: '$(build.configuration)'
+ msbuildArguments: '/t:Publish $(build.basearguments)'
+
- task: PublishBuildArtifacts@1
displayName: Publish nupkg
inputs:
pathtoPublish: artifacts/nuget/$(build.configuration)
artifactName: nuget
publishLocation: container
+
+ - task: CopyFiles@2
+ displayName: Copy Eto.UnitTest.Desktop
+ inputs:
+ sourceFolder: artifacts/core/Windows/$(build.configuration)/net461/publish
+ targetFolder: $(Build.ArtifactStagingDirectory)/Eto.UnitTest.Desktop/
+
+ - task: PublishBuildArtifacts@1
+ displayName: Publish Eto.UnitTest.Desktop
+ inputs:
+ pathtoPublish: $(Build.ArtifactStagingDirectory)
+ artifactName: Eto.UnitTest
+ publishLocation: container
diff --git a/src/Eto.Toolkit.sln b/src/Eto.Toolkit.sln
index d3fb800..0e57218 100644
--- a/src/Eto.Toolkit.sln
+++ b/src/Eto.Toolkit.sln
@@ -42,6 +42,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{23803176
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eto.HtmlRenderer.XamMac2", "Eto.HtmlRenderer.Mac\Eto.HtmlRenderer.XamMac2.csproj", "{B5D415B5-22D9-44C9-888A-26301E76AE99}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UnitTest", "UnitTest", "{AEA85D35-82E6-4863-B54C-4B6EE89D9234}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eto.UnitTest", "Eto.UnitTest\Eto.UnitTest.csproj", "{6E2E7041-9E81-4B7D-BE9D-A3796954871D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eto.UnitTest.NUnit", "Eto.UnitTest.NUnit\Eto.UnitTest.NUnit.csproj", "{3076C3F6-4313-47DD-8CB2-C1B1A617FC6A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eto.UnitTest.Xunit", "Eto.UnitTest.Xunit\Eto.UnitTest.Xunit.csproj", "{CBCB2F2F-0BCF-4B0B-8AAC-85891FDDCEEE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eto.UnitTest.Desktop", "Eto.UnitTest.Desktop\Eto.UnitTest.Desktop.csproj", "{9E9C86EE-7E14-4E98-93EA-6CC8F54DC663}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Mac = Debug|Mac
@@ -140,6 +150,38 @@ Global
{B5D415B5-22D9-44C9-888A-26301E76AE99}.Release|Mac.ActiveCfg = Release|Any CPU
{B5D415B5-22D9-44C9-888A-26301E76AE99}.Release|Mac.Build.0 = Release|Any CPU
{B5D415B5-22D9-44C9-888A-26301E76AE99}.Release|Windows.ActiveCfg = Release|Any CPU
+ {6E2E7041-9E81-4B7D-BE9D-A3796954871D}.Debug|Mac.ActiveCfg = Debug|Any CPU
+ {6E2E7041-9E81-4B7D-BE9D-A3796954871D}.Debug|Mac.Build.0 = Debug|Any CPU
+ {6E2E7041-9E81-4B7D-BE9D-A3796954871D}.Debug|Windows.ActiveCfg = Debug|Any CPU
+ {6E2E7041-9E81-4B7D-BE9D-A3796954871D}.Debug|Windows.Build.0 = Debug|Any CPU
+ {6E2E7041-9E81-4B7D-BE9D-A3796954871D}.Release|Mac.ActiveCfg = Release|Any CPU
+ {6E2E7041-9E81-4B7D-BE9D-A3796954871D}.Release|Mac.Build.0 = Release|Any CPU
+ {6E2E7041-9E81-4B7D-BE9D-A3796954871D}.Release|Windows.ActiveCfg = Release|Any CPU
+ {6E2E7041-9E81-4B7D-BE9D-A3796954871D}.Release|Windows.Build.0 = Release|Any CPU
+ {3076C3F6-4313-47DD-8CB2-C1B1A617FC6A}.Debug|Mac.ActiveCfg = Debug|Any CPU
+ {3076C3F6-4313-47DD-8CB2-C1B1A617FC6A}.Debug|Mac.Build.0 = Debug|Any CPU
+ {3076C3F6-4313-47DD-8CB2-C1B1A617FC6A}.Debug|Windows.ActiveCfg = Debug|Any CPU
+ {3076C3F6-4313-47DD-8CB2-C1B1A617FC6A}.Debug|Windows.Build.0 = Debug|Any CPU
+ {3076C3F6-4313-47DD-8CB2-C1B1A617FC6A}.Release|Mac.ActiveCfg = Release|Any CPU
+ {3076C3F6-4313-47DD-8CB2-C1B1A617FC6A}.Release|Mac.Build.0 = Release|Any CPU
+ {3076C3F6-4313-47DD-8CB2-C1B1A617FC6A}.Release|Windows.ActiveCfg = Release|Any CPU
+ {3076C3F6-4313-47DD-8CB2-C1B1A617FC6A}.Release|Windows.Build.0 = Release|Any CPU
+ {CBCB2F2F-0BCF-4B0B-8AAC-85891FDDCEEE}.Debug|Mac.ActiveCfg = Debug|Any CPU
+ {CBCB2F2F-0BCF-4B0B-8AAC-85891FDDCEEE}.Debug|Mac.Build.0 = Debug|Any CPU
+ {CBCB2F2F-0BCF-4B0B-8AAC-85891FDDCEEE}.Debug|Windows.ActiveCfg = Debug|Any CPU
+ {CBCB2F2F-0BCF-4B0B-8AAC-85891FDDCEEE}.Debug|Windows.Build.0 = Debug|Any CPU
+ {CBCB2F2F-0BCF-4B0B-8AAC-85891FDDCEEE}.Release|Mac.ActiveCfg = Release|Any CPU
+ {CBCB2F2F-0BCF-4B0B-8AAC-85891FDDCEEE}.Release|Mac.Build.0 = Release|Any CPU
+ {CBCB2F2F-0BCF-4B0B-8AAC-85891FDDCEEE}.Release|Windows.ActiveCfg = Release|Any CPU
+ {CBCB2F2F-0BCF-4B0B-8AAC-85891FDDCEEE}.Release|Windows.Build.0 = Release|Any CPU
+ {9E9C86EE-7E14-4E98-93EA-6CC8F54DC663}.Debug|Mac.ActiveCfg = Debug|Any CPU
+ {9E9C86EE-7E14-4E98-93EA-6CC8F54DC663}.Debug|Mac.Build.0 = Debug|Any CPU
+ {9E9C86EE-7E14-4E98-93EA-6CC8F54DC663}.Debug|Windows.ActiveCfg = Debug|Any CPU
+ {9E9C86EE-7E14-4E98-93EA-6CC8F54DC663}.Debug|Windows.Build.0 = Debug|Any CPU
+ {9E9C86EE-7E14-4E98-93EA-6CC8F54DC663}.Release|Mac.ActiveCfg = Release|Any CPU
+ {9E9C86EE-7E14-4E98-93EA-6CC8F54DC663}.Release|Mac.Build.0 = Release|Any CPU
+ {9E9C86EE-7E14-4E98-93EA-6CC8F54DC663}.Release|Windows.ActiveCfg = Release|Any CPU
+ {9E9C86EE-7E14-4E98-93EA-6CC8F54DC663}.Release|Windows.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -158,6 +200,10 @@ Global
{364EDFB1-8C1A-46B1-A9F7-B9D7F9417E55} = {C5C9E1F4-B112-4BDE-A589-722E87D63962}
{F9D593CA-8448-466D-BC47-A1753E3949FD} = {C5C9E1F4-B112-4BDE-A589-722E87D63962}
{B5D415B5-22D9-44C9-888A-26301E76AE99} = {C5C9E1F4-B112-4BDE-A589-722E87D63962}
+ {6E2E7041-9E81-4B7D-BE9D-A3796954871D} = {AEA85D35-82E6-4863-B54C-4B6EE89D9234}
+ {3076C3F6-4313-47DD-8CB2-C1B1A617FC6A} = {AEA85D35-82E6-4863-B54C-4B6EE89D9234}
+ {CBCB2F2F-0BCF-4B0B-8AAC-85891FDDCEEE} = {AEA85D35-82E6-4863-B54C-4B6EE89D9234}
+ {9E9C86EE-7E14-4E98-93EA-6CC8F54DC663} = {AEA85D35-82E6-4863-B54C-4B6EE89D9234}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B290EE1E-3782-4C57-A127-FD078514CD8C}
diff --git a/src/Eto.UnitTest.Desktop/App.config b/src/Eto.UnitTest.Desktop/App.config
new file mode 100644
index 0000000..731f6de
--- /dev/null
+++ b/src/Eto.UnitTest.Desktop/App.config
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Eto.UnitTest.Desktop/AppDomainRunner.cs b/src/Eto.UnitTest.Desktop/AppDomainRunner.cs
new file mode 100644
index 0000000..f569d5b
--- /dev/null
+++ b/src/Eto.UnitTest.Desktop/AppDomainRunner.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Eto.UnitTest.App
+{
+ class AppDomainTestRunnerWrapper : MarshalByRefObject
+ {
+ ITestRunner _runner;
+ public bool IsRunning => _runner.IsRunning;
+
+ public ITest Test => _runner.TestSuite;
+
+ public event EventHandler Log;
+ public event EventHandler Progress;
+ public event EventHandler TestFinished;
+ public event EventHandler TestStarted;
+ public event EventHandler IsRunningChanged;
+
+ public async Task> GetCategories(ITestFilter filter)
+ {
+ var categories = await _runner.GetCategories(filter);
+ return categories.ToList();
+ }
+
+ public Task GetTestCount(ITestFilter filter)
+ {
+ return _runner.GetTestCount(filter);
+ }
+
+ public Task RunAsync(ITestFilter filter)
+ {
+ return _runner.RunAsync(filter);
+ }
+
+ public void StopTests() => _runner.StopTests();
+
+ public void CreateRunner(Type type)
+ {
+ var runnerType = Activator.CreateInstance(type) as ITestRunnerType;
+ _runner = runnerType.CreateRunner();
+ }
+
+ public Task Load(ITestSource source) => _runner.Load(source);
+ }
+
+ public class AppDomainTestRunner : ITestRunner, IDisposable
+ {
+ AppDomainTestRunnerWrapper _wrapper;
+ public bool IsRunning => _wrapper.IsRunning;
+
+ public ITest TestSuite => _wrapper.Test;
+
+ public event EventHandler Log;
+ public event EventHandler Progress;
+ public event EventHandler TestFinished;
+ public event EventHandler TestStarted;
+ public event EventHandler IsRunningChanged;
+
+ public AppDomainTestRunner()
+ {
+ _wrapper = Domain.CreateInstanceFromAndUnwrap(typeof(AppDomainTestRunnerWrapper).Assembly.CodeBase, typeof(AppDomainTestRunnerWrapper).FullName) as AppDomainTestRunnerWrapper;
+ }
+
+ public void Initialize(Type runnerType)
+ {
+ _wrapper.CreateRunner(runnerType);
+ }
+
+ AppDomain _domain;
+
+ AppDomain Domain
+ {
+ get
+ {
+ if (_domain != null)
+ return _domain;
+
+ var cur = AppDomain.CurrentDomain;
+ var si = cur.SetupInformation;
+ //var path = AssemblyPath;
+ var setup = new AppDomainSetup
+ {
+ ApplicationName = si.ApplicationName ?? "Eto.UnitTest",
+ CachePath = si.CachePath,
+ ShadowCopyFiles = "true",//si.ShadowCopyFiles,
+ ShadowCopyDirectories = si.ShadowCopyDirectories,
+ PrivateBinPath = si.PrivateBinPath,
+ //DynamicBase = si.DynamicBase,
+ TargetFrameworkName = si.TargetFrameworkName,
+ ConfigurationFile = si.ConfigurationFile,
+ ApplicationBase = si.ApplicationBase,
+ LoaderOptimization = LoaderOptimization.MultiDomain,//= si.LoaderOptimization,
+ //SandboxInterop = si.SandboxInterop
+ };
+ //setup.SetCompatibilitySwitches(new[] { "NetFx40_LegacySecurityPolicy" });
+ _domain = AppDomain.CreateDomain("UnitTests");//, cur.Evidence, setup);
+ cur.DomainUnload += (sender, e) => UnloadDomain();
+ return _domain;
+ }
+ }
+
+ public Task> GetCategories(ITestFilter filter)
+ {
+ return _wrapper.GetCategories(filter);
+ }
+
+ public Task GetTestCount(ITestFilter filter)
+ {
+ return _wrapper.GetTestCount(filter);
+ }
+
+ public Task RunAsync(ITestFilter filter)
+ {
+ return _wrapper.RunAsync(filter);
+ }
+
+ public void StopTests()
+ {
+ _wrapper.StopTests();
+ }
+
+ void UnloadDomain()
+ {
+ if (_domain == null)
+ return;
+
+ AppDomain.Unload(_domain);
+ _domain = null;
+ }
+
+ public void Dispose() => UnloadDomain();
+
+ public Task Load(ITestSource source) => _wrapper.Load(source);
+ }
+
+}
diff --git a/src/Eto.UnitTest.Desktop/Eto.UnitTest.Desktop.csproj b/src/Eto.UnitTest.Desktop/Eto.UnitTest.Desktop.csproj
new file mode 100644
index 0000000..c7e0b5e
--- /dev/null
+++ b/src/Eto.UnitTest.Desktop/Eto.UnitTest.Desktop.csproj
@@ -0,0 +1,36 @@
+
+
+
+
+
+ WinExe
+ net461
+ False
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Eto.UnitTest.Desktop/MainForm.cs b/src/Eto.UnitTest.Desktop/MainForm.cs
new file mode 100644
index 0000000..912473a
--- /dev/null
+++ b/src/Eto.UnitTest.Desktop/MainForm.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Threading.Tasks;
+using Eto.Forms;
+using Eto.UnitTest.UI;
+
+namespace Eto.UnitTest.App
+{
+
+ public class MainForm : Form
+ {
+ public MainForm(IReadOnlyCollection assemblies)
+ {
+ var viewModel = new UnitTestViewModel();
+ DataContext = viewModel;
+
+ Title = "Eto Unit Test";
+
+ NUnit.NUnitTestRunnerType.Register();
+ Xunit.XunitTestRunnerType.Register();
+
+ var unitTestPanel = new UnitTestPanel(true);
+ unitTestPanel.BindDataContext(c => c.Runner, (UnitTestViewModel m) => m.Runner);
+
+ Content = unitTestPanel;
+
+ CreateMenu();
+
+ if (assemblies?.Count > 0)
+ {
+ Application.Instance.AsyncInvoke(() =>
+ {
+ foreach (var assembly in assemblies)
+ {
+ viewModel.OpenFile(this, assembly.FullName);
+ }
+ });
+ }
+
+ }
+
+ void CreateMenu()
+ {
+ var openItem = new ButtonMenuItem { Text = "Open...", Shortcut = Application.Instance.CommonModifier | Keys.O };
+ openItem.BindDataContext(c => c.Command, (UnitTestViewModel m) => m.OpenCommand);
+ openItem.CommandParameter = this;
+
+ var clear = new ButtonMenuItem { Text = "Close tests" };
+ clear.BindDataContext(c => c.Command, (UnitTestViewModel m) => m.ClearCommand);
+
+ var showOutput = new CheckMenuItem { Text = "Show output" };
+ showOutput.BindDataContext(c => c.Checked, (UnitTestViewModel m) => m.ShowOutput);
+
+ var showOnlyFailed = new CheckMenuItem { Text = "Show only failed tests" };
+ showOnlyFailed.BindDataContext(c => c.Checked, (UnitTestViewModel m) => m.ShowOnlyFailed);
+
+ Menu = new MenuBar
+ {
+ Items =
+ {
+ new ButtonMenuItem { Text = "&File", Items = { openItem, clear } },
+ new ButtonMenuItem { Text = "&View", Items = { showOutput, showOnlyFailed } }
+ }
+ };
+ }
+ }
+}
diff --git a/src/Eto.UnitTest.Desktop/Program.cs b/src/Eto.UnitTest.Desktop/Program.cs
new file mode 100644
index 0000000..c3c3529
--- /dev/null
+++ b/src/Eto.UnitTest.Desktop/Program.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.CommandLine.Invocation;
+using System.CommandLine.Parsing;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Threading.Tasks;
+using Eto.Forms;
+
+namespace Eto.UnitTest.App
+{
+ class Program
+ {
+ [STAThread]
+ static int Main(string[] args)
+ {
+ // options
+ var assemblyOption = new Option(new[] { "-a", "--assembly" }, "The assembly(ies) to test.")
+ {
+ Argument = new Argument { Arity = ArgumentArity.ZeroOrMore }
+ };
+
+ // Add them to the root command
+ var rootCommand = new RootCommand { Description = "Eto.UnitTest" };
+ rootCommand.AddOption(assemblyOption);
+
+
+ var result = rootCommand.Parse(args);
+ FileInfo[] assemblies = null;
+ if (result.HasOption(assemblyOption))
+ {
+ assemblies = result.FindResultFor(assemblyOption)?.GetValueOrDefault();
+ }
+
+ new Application().Run(new MainForm(assemblies));
+ return 0;
+ }
+ }
+}
diff --git a/src/Eto.UnitTest.Desktop/UnitTestViewModel.cs b/src/Eto.UnitTest.Desktop/UnitTestViewModel.cs
new file mode 100644
index 0000000..9f33e13
--- /dev/null
+++ b/src/Eto.UnitTest.Desktop/UnitTestViewModel.cs
@@ -0,0 +1,102 @@
+using System.ComponentModel;
+using System.Reflection;
+using System.Windows.Input;
+using Eto.Forms;
+using Eto.UnitTest.UI;
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using System;
+using Eto.UnitTest.Runners;
+
+namespace Eto.UnitTest.App
+{
+ class UnitTestViewModel : INotifyPropertyChanged
+ {
+ ITestRunner _runner;
+ MultipleTestRunner _multipleTestRunner;
+
+ public ICommand OpenCommand => new RelayCommand(OpenRunner);
+
+ public ICommand ClearCommand => new RelayCommand(ClearRunner);
+
+ public bool ShowOnlyFailed
+ {
+ get => (_runner as LoggingTestRunner)?.ShowOnlyFailed ?? false;
+ set
+ {
+ if (_runner is LoggingTestRunner runner)
+ runner.ShowOnlyFailed = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowOutput
+ {
+ get => (_runner as LoggingTestRunner)?.ShowOutput ?? false;
+ set
+ {
+ if (_runner is LoggingTestRunner runner)
+ runner.ShowOutput = value;
+ OnPropertyChanged();
+ }
+ }
+
+ private void ClearRunner()
+ {
+ _multipleTestRunner.Clear();
+ Runner = null;
+ }
+
+ public ITestRunner Runner
+ {
+ get => _runner;
+ set
+ {
+ _runner = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(ShowOutput));
+ OnPropertyChanged(nameof(ShowOnlyFailed));
+ }
+ }
+
+ void OnPropertyChanged([CallerMemberName] string memberName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(memberName));
+ }
+
+ public event PropertyChangedEventHandler PropertyChanged;
+
+ void OpenRunner(Control parent)
+ {
+ var open = new OpenFileDialog();
+ open.CheckFileExists = true;
+ open.Filters.Add(new FileFilter("Assembly", ".dll"));
+ open.Filters.Add(new FileFilter("Executable", ".exe"));
+ open.CurrentFilterIndex = 0;
+ if (open.ShowDialog(parent) == DialogResult.Ok)
+ {
+ OpenFile(parent, open.FileName);
+ }
+ }
+
+ public void OpenFile(Control parent, string fileName)
+ {
+ Application.Instance.Invoke(async () =>
+ {
+ var source = new TestSource(fileName);
+ if (_multipleTestRunner == null)
+ _multipleTestRunner = new MultipleTestRunner();
+ try
+ {
+ await _multipleTestRunner.Load(source);
+
+ Runner = new LoggingTestRunner(_multipleTestRunner);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(parent, $"Error loading assembly: {ex.Message}", MessageBoxType.Information);
+ }
+ });
+ }
+ }
+}
diff --git a/src/Eto.UnitTest.NUnit/Eto.UnitTest.NUnit.csproj b/src/Eto.UnitTest.NUnit/Eto.UnitTest.NUnit.csproj
new file mode 100644
index 0000000..b3cc860
--- /dev/null
+++ b/src/Eto.UnitTest.NUnit/Eto.UnitTest.NUnit.csproj
@@ -0,0 +1,22 @@
+
+
+
+ netstandard2.0
+ $(DefineConstants);INCLUDE_TESTS
+
+
+
+ NUnit testing UI and utilities for Eto.Forms
+ Provides a control to use in Eto.Forms applications that can be used to display, filter, and run unit tests.
+ xunit;unit test;test;testing;tdd
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Eto.UnitTest.NUnit/InvokeOnUIAttribute.cs b/src/Eto.UnitTest.NUnit/InvokeOnUIAttribute.cs
new file mode 100644
index 0000000..2af87dd
--- /dev/null
+++ b/src/Eto.UnitTest.NUnit/InvokeOnUIAttribute.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Runtime.ExceptionServices;
+using Eto.Forms;
+using NUnit.Framework.Interfaces;
+using NUnit.Framework.Internal;
+using NUnit.Framework.Internal.Commands;
+
+namespace Eto.UnitTest.NUnit
+{
+
+ ///
+ /// Runs the test on the UI thread
+ ///
+ [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = false)]
+ public sealed class InvokeOnUIAttribute : Attribute, IWrapSetUpTearDown
+ {
+ ///
+ /// Wraps the specified command
+ ///
+ /// command to wrap
+ /// A new command that wraps the specified command in a UI invoke
+ public TestCommand Wrap(TestCommand command) => new RunOnUICommand(command);
+
+ class RunOnUICommand : DelegatingTestCommand
+ {
+ public RunOnUICommand(TestCommand innerCommand)
+ : base(innerCommand)
+ {
+ }
+
+ public override TestResult Execute(TestExecutionContext context)
+ {
+ Exception exception = null;
+
+ var result = Application.Instance.Invoke(() =>
+ {
+ try
+ {
+ context.EstablishExecutionEnvironment();
+ return innerCommand.Execute(context);
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ return null;
+ }
+ });
+
+ if (exception != null)
+ {
+ ExceptionDispatchInfo.Capture(exception).Throw();
+ }
+
+ return result;
+ }
+ }
+ }
+}
diff --git a/src/Eto.UnitTest.NUnit/MyTests.cs b/src/Eto.UnitTest.NUnit/MyTests.cs
new file mode 100644
index 0000000..70da54b
--- /dev/null
+++ b/src/Eto.UnitTest.NUnit/MyTests.cs
@@ -0,0 +1,161 @@
+using System;
+using NUnit.Framework;
+using System.Threading;
+using Eto.Forms;
+using System.Threading.Tasks;
+
+#if INCLUDE_TESTS
+
+// Example tests for.. testing puproses
+
+[assembly: Parallelizable(ParallelScope.All)]
+
+
+namespace Eto.UnitTest.NUnit.TheTests
+{
+ [TestFixture]
+ public class SomeTests
+ {
+ [Category("CategoryA")]
+ [Test]
+ public void Blar()
+ {
+ Thread.Sleep(1000);
+ }
+
+ [Category("CategoryB")]
+ [Test]
+ public void ShouldNotPass()
+ {
+ Thread.Sleep(1000);
+ throw new InvalidOperationException("woo");
+ }
+ }
+
+ [TestFixture]
+ public class TestStatus
+ {
+ [Test]
+ public void TestShouldError()
+ {
+ Thread.Sleep(200);
+ throw new InvalidOperationException("this is a non-test exception");
+ }
+
+ [Test]
+ public void TestShouldFail()
+ {
+ Thread.Sleep(200);
+ Assert.AreEqual(10, 20);
+ }
+
+ [Test]
+ public void TestShouldPass()
+ {
+ Thread.Sleep(200);
+ Assert.AreEqual(10, 10);
+ }
+
+ [Test]
+ [Ignore("Ignore reason")]
+ public void TestShouldSkip()
+ {
+ Thread.Sleep(200);
+ throw new InvalidOperationException("boo");
+ }
+
+ [TestCase(3)]
+ [TestCase(5)]
+ [TestCase(6, Ignore = "Not working")]
+ [TestCase(8)]
+ public void TestTheory(int value)
+ {
+ Thread.Sleep(200);
+ Assert.True(value % 2 == 1);
+ }
+ }
+}
+
+namespace Eto.UnitTest.NUnit.OtherTests
+{
+ [TestFixture]
+ public class SomeOtherTests
+ {
+ [Category("CategoryA")]
+ [Test]
+ public void Test1()
+ {
+ Thread.Sleep(1000);
+ }
+
+ [Test]
+ public void Test2()
+ {
+ Thread.Sleep(1000);
+ throw new InvalidOperationException("woo");
+ }
+ }
+
+ [TestFixture]
+ public class MoreTests
+ {
+ [Test]
+ public void Test1()
+ {
+ Thread.Sleep(1000);
+ }
+
+ [Test]
+ public void Test2()
+ {
+ Thread.Sleep(1000);
+ }
+
+ [Test]
+ public void Test3()
+ {
+ Thread.Sleep(1000);
+ }
+
+ [Test]
+ public void Test4()
+ {
+ Thread.Sleep(1000);
+ }
+ }
+
+ [TestFixture]
+ public class UITests
+ {
+ [Test]
+ [InvokeOnUI]
+ public void RunThisOnUIThread()
+ {
+ Thread currentThread = Thread.CurrentThread;
+ Thread uiThread = null;
+ Application.Instance.Invoke(() =>
+ {
+ uiThread = Thread.CurrentThread;
+ });
+
+ Assert.IsNotNull(uiThread, "#1. UI thread was not found");
+ Assert.AreEqual(currentThread.ManagedThreadId, uiThread.ManagedThreadId, "#2. UI Thread should be the same as the test thread");
+ }
+
+ [Test]
+ public void RunThisOnTestThread()
+ {
+ Thread currentThread = Thread.CurrentThread;
+ Thread uiThread = null;
+ Application.Instance.Invoke(() =>
+ {
+ uiThread = Thread.CurrentThread;
+ });
+
+ Assert.IsNotNull(uiThread, "#1. UI thread was not found");
+ Assert.AreNotEqual(currentThread.ManagedThreadId, uiThread.ManagedThreadId, "#2. UI Thread should not be the same as the test thread");
+ }
+ }
+}
+
+#endif
\ No newline at end of file
diff --git a/src/Eto.UnitTest.NUnit/NUnitTestRunner.cs b/src/Eto.UnitTest.NUnit/NUnitTestRunner.cs
new file mode 100644
index 0000000..1e1328f
--- /dev/null
+++ b/src/Eto.UnitTest.NUnit/NUnitTestRunner.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using Eto.UnitTest;
+using Eto.UnitTest.Runners;
+using nua = NUnit.Framework.Api;
+using nui = NUnit.Framework.Interfaces;
+using nuint = NUnit.Framework.Internal;
+
+[assembly: TestRunnerType(typeof(Eto.UnitTest.NUnit.NUnitTestRunnerType))]
+
+namespace Eto.UnitTest.NUnit
+{
+ public class NUnitTestRunnerType : TestRunnerType
+ {
+ public override string Name => "NUnit";
+
+ public static void Register() => TestRunnerType.Add(new NUnitTestRunnerType());
+
+ protected override string[] RequiredReferences => new[] { "nunit.framework" };
+ }
+
+ public class NUnitTestRunner : ProgressTestRunner, nui.ITestListener
+ {
+ TaskCompletionSource _tcs;
+ nua.ITestAssemblyBuilder _builder = new nua.DefaultTestAssemblyBuilder();
+ nua.ITestAssemblyRunner _runner;
+
+ public nua.ITestAssemblyRunner NUnitRunner => _runner;
+
+ public override Task Load(ITestSource source)
+ {
+ var settings = new Dictionary();
+
+ _runner = new nua.NUnitTestAssemblyRunner(_builder);
+ _runner.Load(source.Assembly, settings);
+ return Task.CompletedTask;
+ }
+
+ public override ITest TestSuite => new TestWrapper(_runner.ExploreTests(nuint.TestFilter.Empty));
+
+ public override Task> GetCategories(ITestFilter filter)
+ {
+ return Task.FromResult(TestSuite.GetChildren().Where(filter.Pass).SelectMany(r => r.Categories).Distinct());
+ }
+
+ public override Task GetTestCount(ITestFilter filter)
+ {
+ return Task.FromResult(TestSuite.GetChildren().Count(r => !r.IsSuite && filter.Pass(r)));
+ }
+
+ protected override Task RunInternalAsync(ITestFilter filter)
+ {
+ _tcs = new TaskCompletionSource();
+ new nuint.TestExecutionContext.AdhocContext().EstablishExecutionEnvironment();
+ _runner.RunAsync(this, new TestFilterWrapper(filter));
+ return _tcs.Task;
+ }
+
+ public override void StopTests()
+ {
+ lock (this)
+ {
+ _runner.StopRun(true);
+ _tcs.SetResult(new TestResultWrapper(_runner.Result));
+ }
+ }
+
+ void nui.ITestListener.TestStarted(nui.ITest test)
+ {
+ OnTestStarted(new UnitTestTestEventArgs(new TestWrapper(test)));
+ }
+
+ void nui.ITestListener.TestFinished(nui.ITestResult result)
+ {
+ var wrappedResult = new TestResultWrapper(result);
+ OnTestFinished(new UnitTestResultEventArgs(wrappedResult));
+
+ if (result.Test is nuint.TestAssembly)
+ {
+ _tcs.SetResult(new TestResultWrapper(_runner.Result));
+ }
+ }
+
+ void nui.ITestListener.TestOutput(nui.TestOutput output)
+ {
+ WriteLog(output.ToString());
+ }
+
+ void nui.ITestListener.SendMessage(nui.TestMessage message)
+ {
+ WriteLog(message.ToString());
+ }
+
+ }
+}
diff --git a/src/Eto.UnitTest.NUnit/TestAssertionWrapper.cs b/src/Eto.UnitTest.NUnit/TestAssertionWrapper.cs
new file mode 100644
index 0000000..e32e6e9
--- /dev/null
+++ b/src/Eto.UnitTest.NUnit/TestAssertionWrapper.cs
@@ -0,0 +1,56 @@
+using System;
+using NUnit.Framework.Interfaces;
+
+namespace Eto.UnitTest.NUnit
+{
+ class TestAssertion : ITestAssertion
+ {
+ public string Message { get; set; }
+
+ public TestStatus Status { get; set; }
+
+ public string StackTrace { get; set; }
+
+ public object NativeObject => null;
+ }
+
+ class TestAssertionWrapper : ITestAssertion
+ {
+ AssertionResult _assertion;
+
+ public TestAssertionWrapper(AssertionResult assertion)
+ {
+ _assertion = assertion;
+ }
+
+ public string Message => _assertion.Message;
+
+ public TestStatus Status
+ {
+ get
+ {
+ switch (_assertion.Status)
+ {
+ case AssertionStatus.Error:
+ return TestStatus.Failed;
+ //return TestStatus.Error;
+ case AssertionStatus.Failed:
+ return TestStatus.Failed;
+ case AssertionStatus.Inconclusive:
+ return TestStatus.Inconclusive;
+ case AssertionStatus.Passed:
+ return TestStatus.Passed;
+ case AssertionStatus.Warning:
+ return TestStatus.Warning;
+ default:
+ throw new NotSupportedException();
+ }
+ }
+
+ }
+
+ public string StackTrace => _assertion.StackTrace;
+
+ public object NativeObject => _assertion;
+ }
+}
\ No newline at end of file
diff --git a/src/Eto.UnitTest.NUnit/TestFilterWrapper.cs b/src/Eto.UnitTest.NUnit/TestFilterWrapper.cs
new file mode 100644
index 0000000..a40a064
--- /dev/null
+++ b/src/Eto.UnitTest.NUnit/TestFilterWrapper.cs
@@ -0,0 +1,23 @@
+using System;
+using nui = NUnit.Framework.Interfaces;
+
+namespace Eto.UnitTest.NUnit
+{
+ class TestFilterWrapper : nui.ITestFilter
+ {
+ ITestFilter _filter;
+
+ public TestFilterWrapper(ITestFilter filter)
+ {
+ _filter = filter;
+ }
+
+ public bool IsExplicitMatch(nui.ITest test) => _filter.IsExplicitMatch(new TestWrapper(test));
+
+ public bool Pass(nui.ITest test) => _filter.Pass(new TestWrapper(test));
+
+ public nui.TNode ToXml(bool recursive) => throw new NotImplementedException();
+
+ public nui.TNode AddToXml(nui.TNode parentNode, bool recursive) => throw new NotImplementedException();
+ }
+}
diff --git a/src/Eto.UnitTest.NUnit/TestResultWrapper.cs b/src/Eto.UnitTest.NUnit/TestResultWrapper.cs
new file mode 100644
index 0000000..6c9b322
--- /dev/null
+++ b/src/Eto.UnitTest.NUnit/TestResultWrapper.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using nui = NUnit.Framework.Interfaces;
+
+namespace Eto.UnitTest.NUnit
+{
+ class TestResultWrapper : ITestResult
+ {
+ nui.ITestResult _result;
+ TestStatus? _status;
+ ITest _test;
+
+ public TestResultWrapper(nui.ITestResult result)
+ {
+ _result = result;
+ }
+
+ public int AssertCount => _result.AssertCount;
+
+ public int FailCount => _result.FailCount;
+
+ public int ErrorCount => 0;
+
+ public int WarningCount => _result.WarningCount;
+
+ public int PassCount => _result.PassCount;
+
+ public int InconclusiveCount => _result.InconclusiveCount;
+
+ public int SkipCount => _result.SkipCount;
+
+ public TestStatus Status => _status ?? (_status = GetStatus()).Value;
+
+ private TestStatus GetStatus()
+ {
+ switch (_result.ResultState.Status)
+ {
+ case nui.TestStatus.Failed:
+ return TestStatus.Failed;
+ case nui.TestStatus.Inconclusive:
+ return TestStatus.Inconclusive;
+ case nui.TestStatus.Passed:
+ return TestStatus.Passed;
+ case nui.TestStatus.Skipped:
+ return TestStatus.Skipped;
+ case nui.TestStatus.Warning:
+ return TestStatus.Warning;
+ default:
+ throw new NotSupportedException();
+ }
+ }
+
+ public ITest Test => _test ?? (_test = new TestWrapper(_result.Test));
+
+ public IEnumerable Children => _result.Children?.Select(r => new TestResultWrapper(r));
+
+ public TimeSpan Duration => TimeSpan.FromSeconds(_result.Duration);
+
+ public DateTime EndTime => _result.EndTime;
+
+ public bool HasChildren => _result.HasChildren;
+
+ public string Message => _result.Message;
+
+ public string Name => _result.Name;
+
+ public string Output => _result.Output;
+
+ public DateTime StartTime => _result.StartTime;
+
+ public object NativeObject => _result;
+
+ public IEnumerable Assersions
+ {
+ get
+ {
+ if (_result.AssertionResults.Count == 0)
+ return new[] {
+ new TestAssertion {
+ Message = _result.Message,
+ Status = (TestStatus)_result.ResultState.Status,
+ StackTrace = _result.StackTrace
+ }
+ };
+ else
+ return _result.AssertionResults.Select(r => new TestAssertionWrapper(r));
+ }
+ }
+ }
+}
diff --git a/src/Eto.UnitTest.NUnit/TestWrapper.cs b/src/Eto.UnitTest.NUnit/TestWrapper.cs
new file mode 100644
index 0000000..37e6646
--- /dev/null
+++ b/src/Eto.UnitTest.NUnit/TestWrapper.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.Serialization;
+using nui = NUnit.Framework.Interfaces;
+using nuint = NUnit.Framework.Internal;
+
+namespace Eto.UnitTest.NUnit
+{
+ [Serializable]
+ class TestWrapper : ITest
+ {
+ readonly nui.ITest _test;
+
+ public TestWrapper(nui.ITest test)
+ {
+ _test = test;
+ }
+
+ protected TestWrapper(SerializationInfo info, StreamingContext context)
+ {
+ }
+
+ public bool IsAssembly => _test is nuint.TestAssembly;
+
+ public bool IsSuite => _test.IsSuite;
+
+ public bool IsParameterized => _test is nuint.ParameterizedMethodSuite;
+
+ public string Name => _test.Name;
+
+ public ITest Parent => _test.Parent != null ? new TestWrapper(_test.Parent) : null;
+
+ public bool HasChildren => _test.HasChildren;
+
+ public IEnumerable Tests => _test.Tests.Select(r => new TestWrapper(r));
+
+ public IEnumerable Categories
+ {
+ get
+ {
+ var categories = _test.Properties["Category"];
+ if (categories == null || categories.Count == 0)
+ return Enumerable.Empty();
+ return categories.OfType();
+ }
+ }
+
+ public string FullName => _test.FullName;
+
+ public object NativeObject => _test;
+
+ public Type Type => _test.TypeInfo.Type;
+
+ public Assembly Assembly => (_test is nuint.TestAssembly testAssembly) ? testAssembly.Assembly : _test.TypeInfo?.Assembly;
+
+ public override int GetHashCode() => _test.GetHashCode();
+
+ public override bool Equals(object obj)
+ {
+ return obj is TestWrapper wrapper && wrapper._test.Equals(_test);
+ }
+ }
+}
diff --git a/src/Eto.UnitTest.Xunit/Eto.UnitTest.Xunit.csproj b/src/Eto.UnitTest.Xunit/Eto.UnitTest.Xunit.csproj
new file mode 100644
index 0000000..745b9b4
--- /dev/null
+++ b/src/Eto.UnitTest.Xunit/Eto.UnitTest.Xunit.csproj
@@ -0,0 +1,24 @@
+
+
+
+
+
+ net461;netcoreapp2.0;netstandard2.0
+ $(DefineConstants);INCLUDE_TESTS
+ True
+
+
+
+ Xunit testing UI and utilities for Eto.Forms
+ Provides a control to use in Eto.Forms applications that can be used to display, filter, and run unit tests.
+ xunit;unit test;test;testing;tdd
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Eto.UnitTest.Xunit/MyTests.cs b/src/Eto.UnitTest.Xunit/MyTests.cs
new file mode 100644
index 0000000..db9ec0c
--- /dev/null
+++ b/src/Eto.UnitTest.Xunit/MyTests.cs
@@ -0,0 +1,119 @@
+using System;
+using Xunit;
+using System.Threading;
+
+#if INCLUDE_TESTS
+
+// Example tests for.. testing puproses
+
+namespace Eto.UnitTest.Xunit.TheTests
+{
+ public class SomeTests
+ {
+ [Trait("Category", "CategoryA")]
+ [Fact]
+ public void Blar()
+ {
+ Thread.Sleep(1000);
+ }
+
+ [Trait("Category", "CategoryB")]
+ [Fact]
+ public void ShouldNotPass()
+ {
+ Thread.Sleep(1000);
+ throw new InvalidOperationException("woo");
+ }
+ }
+
+ public class TestStatus
+ {
+ [Fact]
+ public void TestShouldError()
+ {
+ Thread.Sleep(200);
+ throw new InvalidOperationException("this is a non-test exception");
+ }
+
+ [Fact]
+ public void TestShouldFail()
+ {
+ Thread.Sleep(200);
+ Assert.Equal(10, 20);
+ }
+
+ [Fact]
+ public void TestShouldPass()
+ {
+ Thread.Sleep(200);
+ Assert.Equal(10, 10);
+ }
+
+ [Fact(Skip = "Skip reason")]
+ public void TestShouldSkip()
+ {
+ Thread.Sleep(200);
+ throw new InvalidOperationException("boo");
+ }
+
+ [Theory]
+ [InlineData(3)]
+ [InlineData(5)]
+ [InlineData(6, Skip = "Not working")]
+ [InlineData(8)]
+ public void TestTheory(int value)
+ {
+ Thread.Sleep(200);
+ Assert.True(value % 2 == 1);
+ }
+ }
+}
+
+namespace Eto.UnitTest.Xunit.OtherTests
+{
+ public class SomeOtherTests
+ {
+ [Trait("Category", "CategoryA")]
+ [Fact]
+ public void Test1()
+ {
+ Thread.Sleep(1000);
+ }
+
+ [Fact]
+ public void Test2()
+ {
+ Thread.Sleep(1000);
+ throw new InvalidOperationException("woo");
+ }
+ }
+
+ public class MoreTests
+ {
+ [Fact]
+ public void Test1()
+ {
+ Thread.Sleep(1000);
+ }
+
+ [Fact]
+ public void Test2()
+ {
+ Thread.Sleep(1000);
+ }
+
+ [Fact]
+ public void Test3()
+ {
+ Thread.Sleep(1000);
+ }
+
+ [Fact]
+ public void Test4()
+ {
+ Thread.Sleep(1000);
+ }
+ }
+}
+
+#endif
\ No newline at end of file
diff --git a/src/Eto.UnitTest.Xunit/TestCaseWrapper.cs b/src/Eto.UnitTest.Xunit/TestCaseWrapper.cs
new file mode 100644
index 0000000..660f7aa
--- /dev/null
+++ b/src/Eto.UnitTest.Xunit/TestCaseWrapper.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+using xua = Xunit.Abstractions;
+using System.Linq;
+using System.Reflection;
+
+namespace Eto.UnitTest.Xunit
+{
+ class TestCaseWrapper : ITest
+ {
+ readonly xua.ITestCase _test;
+ Type _type;
+
+ public TestCaseWrapper(xua.ITestCase test)
+ {
+ _test = test;
+ }
+
+ public List Children { get; set; }
+
+ public bool IsSuite => false;
+
+ public bool IsAssembly => false;
+
+ public bool IsParameterized => false;
+
+ public string Name => _test.TestMethod.Method.Name;
+
+ public ITest Parent { get; set; }
+
+ public bool HasChildren => Children?.Count > 0;
+
+ public IEnumerable Tests => Children ?? Enumerable.Empty();
+
+ public IEnumerable Categories => new string[0];
+
+ public Type Type => _type ?? (_type = _test.TestMethod.TestClass.Class.ToRuntimeType());
+
+ public Assembly Assembly => Type.Assembly;
+
+ public string FullName => _test.DisplayName;// _fullName ?? (_fullName = Parent != null ? $"{Parent.FullName}.{Name}" : Name);
+
+ public object NativeObject => _test;
+
+ public override int GetHashCode() => _test.UniqueID.GetHashCode();
+
+ public override bool Equals(object obj)
+ {
+ return obj is TestCaseWrapper wrapper && wrapper._test.UniqueID.Equals(_test.UniqueID);
+ }
+ }
+}
diff --git a/src/Eto.UnitTest.Xunit/TestCollectionWrapper.cs b/src/Eto.UnitTest.Xunit/TestCollectionWrapper.cs
new file mode 100644
index 0000000..840993b
--- /dev/null
+++ b/src/Eto.UnitTest.Xunit/TestCollectionWrapper.cs
@@ -0,0 +1,35 @@
+using System.Linq;
+using System.Reflection;
+
+namespace Eto.UnitTest.Xunit
+{
+ class TestCollectionWrapper : TestSuiteWrapper
+ {
+ public override bool IsAssembly => true;
+
+ public TestCollectionWrapper(Assembly assembly)
+ {
+ Assembly = assembly;
+ Name = assembly.GetName().Name;
+ }
+
+ public TestSuiteWrapper GetSuite(string fullName, bool isClass)
+ {
+ var names = fullName.Split('.');
+ TestSuiteWrapper suite = this;
+ var len = isClass ? names.Length : names.Length - 1;
+ for (int i = 0; i < len; i++)
+ {
+ string name = names[i];
+ var child = suite.Children.FirstOrDefault(r => r.Name == name) as TestSuiteWrapper;
+ if (child == null)
+ {
+ child = new TestSuiteWrapper { Name = name, Parent = suite, Assembly = Assembly };
+ suite.Children.Add(child);
+ }
+ suite = child;
+ }
+ return suite;
+ }
+ }
+}
diff --git a/src/Eto.UnitTest.Xunit/TestResultWrapper.cs b/src/Eto.UnitTest.Xunit/TestResultWrapper.cs
new file mode 100644
index 0000000..78e8777
--- /dev/null
+++ b/src/Eto.UnitTest.Xunit/TestResultWrapper.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Collections.Generic;
+using xua = Xunit.Abstractions;
+using System.Linq;
+
+namespace Eto.UnitTest.Xunit
+{
+ class TestAssertionWrapper : ITestAssertion
+ {
+ public string Message { get; set; }
+
+ public TestStatus Status { get; set; }
+
+ public string StackTrace { get; set; }
+
+ public object NativeObject => null;
+ }
+
+ class TestResultWrapper : ITestResult
+ {
+ List _testAssertions;
+ public TestResultWrapper(ITest test, TestStatus status)
+ {
+ Test = test;
+ Status = status;
+ }
+
+ public TestResultWrapper(xua.ITestAssemblyFinished assemblyFinished)
+ {
+ FailCount = assemblyFinished.TestsFailed;
+ SkipCount = assemblyFinished.TestsSkipped;
+ PassCount = assemblyFinished.TestsRun - FailCount - SkipCount;
+ Status = FailCount > 0 ? TestStatus.Failed : SkipCount > 0 ? TestStatus.Skipped : TestStatus.Passed;
+ Duration = TimeSpan.FromSeconds((double)assemblyFinished.ExecutionTime);
+ }
+
+ public TestResultWrapper(xua.ITestClassFinished classFinished, ITest test)
+ {
+ Test = test;
+ FailCount = classFinished.TestsFailed;
+ SkipCount = classFinished.TestsSkipped;
+ PassCount = classFinished.TestsRun - FailCount - SkipCount;
+ Status = FailCount > 0 ? TestStatus.Failed : SkipCount > 0 ? TestStatus.Skipped : TestStatus.Passed;
+ Duration = TimeSpan.FromSeconds((double)classFinished.ExecutionTime);
+ NativeObject = classFinished;
+ }
+
+ public TestResultWrapper(xua.ITestCollectionFinished collectionFinished, ITest test)
+ {
+ Test = test;
+ FailCount = collectionFinished.TestsFailed;
+ SkipCount = collectionFinished.TestsSkipped;
+ PassCount = collectionFinished.TestsRun - FailCount - SkipCount;
+ Status = FailCount > 0 ? TestStatus.Failed : SkipCount > 0 ? TestStatus.Skipped : TestStatus.Passed;
+ Duration = TimeSpan.FromSeconds((double)collectionFinished.ExecutionTime);
+ NativeObject = collectionFinished;
+ }
+ public TestResultWrapper(xua.ITestFailed failed, ITest test)
+ {
+ Test = test;
+ FailCount = 1;
+ Set(failed);
+ Status = SetFailureInfo(failed);
+ //if (Status == TestStatus.Error)
+ // ErrorCount = 1;
+ }
+ public TestResultWrapper(xua.ITestSkipped skipped, ITest test)
+ {
+ Test = test;
+ SkipCount = 1;
+ Status = TestStatus.Skipped;
+ Set(skipped);
+ }
+
+ public TestResultWrapper(xua.ITestPassed passed, ITest test)
+ {
+ Test = test;
+ PassCount = 1;
+ Status = TestStatus.Passed;
+ Set(passed);
+ }
+
+ void Set(xua.ITestResultMessage message)
+ {
+ NativeObject = message;
+ Duration = TimeSpan.FromSeconds((double)message.ExecutionTime);
+ Output = message.Output;
+ }
+
+ TestStatus SetFailureInfo(xua.IFailureInformation failure)
+ {
+ var status = TestStatus.Failed;
+ _testAssertions = new List();
+ for (int i = 0; i < failure.Messages.Length; i++)
+ {
+ var assertion = new TestAssertionWrapper();
+ var exceptionType = failure.ExceptionTypes[i];
+ TestStatus assertionStatus = TestStatus.Failed;
+ /*
+ if (!exceptionType.StartsWith("Xunit.Sdk", StringComparison.Ordinal))
+ assertionStatus = TestStatus.Error;
+ */
+
+ status = (TestStatus)Math.Max((int)assertionStatus, (int)status);
+ assertion.Message = failure.Messages[i];
+ assertion.StackTrace = failure.StackTraces[i];
+ assertion.Status = assertionStatus;
+ _testAssertions.Add(assertion);
+ }
+ return status;
+ }
+
+ public int AssertCount { get; set; }
+
+ public int FailCount { get; set; }
+
+ public int ErrorCount { get; set; }
+
+ public int WarningCount { get; set; }
+
+ public int PassCount { get; set; }
+
+ public int InconclusiveCount { get; set; }
+
+ public int SkipCount { get; set; }
+
+ public TestStatus Status { get; set; }
+
+ public ITest Test { get; set; }
+
+ public IEnumerable Assersions => _testAssertions ?? Enumerable.Empty();
+
+ public IEnumerable Children { get; set; } = Enumerable.Empty();
+
+ public TimeSpan Duration { get; set; }
+
+ public DateTime EndTime { get; set; }
+
+ public bool HasChildren { get; set; }
+
+ public string Message { get; set; }
+
+ public string Name { get; set; }
+
+ public string Output { get; set; }
+
+ public DateTime StartTime { get; set; }
+
+ public object NativeObject { get; set; }
+ }
+}
diff --git a/src/Eto.UnitTest.Xunit/TestSuiteWrapper.cs b/src/Eto.UnitTest.Xunit/TestSuiteWrapper.cs
new file mode 100644
index 0000000..2e3a388
--- /dev/null
+++ b/src/Eto.UnitTest.Xunit/TestSuiteWrapper.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+
+namespace Eto.UnitTest.Xunit
+{
+ class TestSuiteWrapper : ITest
+ {
+ string _fullName;
+ public List Children { get; } = new List();
+
+ public virtual bool IsSuite => true;
+
+ public virtual bool IsAssembly => false;
+
+ public bool IsParameterized { get; set; }
+
+ public string Name { get; set; }
+
+ public ITest Parent { get; set; }
+
+ public bool HasChildren => true;
+
+ public IEnumerable Tests => Children;
+
+ public IEnumerable Categories => Enumerable.Empty();
+
+ public List RunningTests { get; } = new List();
+
+ public Type Type => null;
+
+ public Assembly Assembly { get; set; }
+
+ public string FullName => _fullName ?? (_fullName = Parent != null ? $"{Parent.FullName}.{Name}" : Name);
+
+ public object NativeObject => null;
+ }
+}
diff --git a/src/Eto.UnitTest.Xunit/XunitTestRunner.cs b/src/Eto.UnitTest.Xunit/XunitTestRunner.cs
new file mode 100644
index 0000000..622413e
--- /dev/null
+++ b/src/Eto.UnitTest.Xunit/XunitTestRunner.cs
@@ -0,0 +1,262 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using xua = Xunit.Abstractions;
+using xu = Xunit;
+using Eto.UnitTest;
+using System.Linq;
+using System.Reflection;
+using Eto.Forms;
+using Eto.UnitTest.Runners;
+
+namespace Eto.UnitTest.Xunit
+{
+ public class XunitTestRunner : ProgressTestRunner, xu.IMessageSinkWithTypes
+ {
+ xu.XunitFrontController controller;
+ TaskCompletionSource _tcs;
+ TaskCompletionSource _tcsRun;
+ TaskCompletionSource _tcsFilter;
+ TestCollectionWrapper _testWrapper;
+ ITestSource _source;
+ Assembly _assembly;
+ ITestFilter _filter;
+ bool _cancel;
+ HashSet _testSuiteStarted;
+ Dictionary _testSuiteStatus;
+
+ List _testsToRun = new List();
+
+ public override ITest TestSuite => _testWrapper;
+
+ public override Task> GetCategories(ITestFilter filter)
+ {
+ return Task.FromResult(Enumerable.Empty());
+ }
+
+ public override Task GetTestCount(ITestFilter filter)
+ {
+ _tcsFilter = new TaskCompletionSource();
+ _filter = filter;
+ messageHandler = FilterTestCases;
+ _testsToRun.Clear();
+ var discoveryOptions = xu.TestFrameworkOptions.ForDiscovery();
+ controller.Find(true, this, discoveryOptions);
+ return _tcsFilter.Task;
+ }
+
+ public override Task Load(ITestSource source)
+ {
+ _source = source;
+ _tcs = new TaskCompletionSource();
+ controller = new xu.XunitFrontController(xu.AppDomainSupport.Denied, source.Assembly.Location, diagnosticMessageSink: xu.MessageSinkAdapter.Wrap(this));
+ _assembly = source.Assembly;
+ _testWrapper = new TestCollectionWrapper(_assembly);
+
+ var discoveryOptions = xu.TestFrameworkOptions.ForDiscovery();
+ messageHandler = DiscoverMessageHandler;
+ controller.Find(true, this, discoveryOptions);
+ return _tcs.Task;
+ }
+
+ void DiscoverMessageHandler(xua.IMessageSinkMessage message)
+ {
+ try
+ {
+ if (message is xua.ITestCaseDiscoveryMessage testCaseDiscovery)
+ {
+ Wrap(testCaseDiscovery.TestCase, true);
+ }
+ else if (message is xua.IDiscoveryCompleteMessage discoveryComplete)
+ {
+ _tcs.SetResult(true);
+ }
+ else if (message is xua.IErrorMessage errorMessage)
+ {
+ Console.WriteLine($"Error ocurred {errorMessage.ToString()}");
+ }
+ else
+ {
+ Console.WriteLine($"Unhandled message of type {message.GetType()}");
+ }
+ }
+ catch (Exception ex)
+ {
+ _tcs.SetException(ex);
+ }
+ }
+
+ public override void StopTests()
+ {
+ _cancel = true;
+ }
+
+ Action messageHandler;
+
+ protected override async Task RunInternalAsync(ITestFilter filter)
+ {
+ _tcsRun = new TaskCompletionSource();
+
+ var config = new xu.TestAssemblyConfiguration();
+
+ _cancel = false;
+ _testsToRun.Clear();
+ _testSuiteStarted = new HashSet();
+ _testSuiteStatus = new Dictionary();
+ messageHandler = FilterTestCases;
+ _filter = filter;
+ _tcsFilter = new TaskCompletionSource();
+ controller.Find(false, this, xu.TestFrameworkOptions.ForDiscovery(config));
+ await _tcsFilter.Task;
+
+ messageHandler = RunMessageHandler;
+ controller.RunTests(_testsToRun, this, xu.TestFrameworkOptions.ForExecution(config));
+
+ return await _tcsRun.Task;
+ }
+
+ private void FilterTestCases(xua.IMessageSinkMessage message)
+ {
+ try
+ {
+ if (message is xua.ITestCaseDiscoveryMessage testDiscovered)
+ {
+ if (_filter?.Pass(Wrap(testDiscovered.TestCase)) != false)
+ _testsToRun.Add(testDiscovered.TestCase);
+ }
+ else if (message is xua.IDiscoveryCompleteMessage discoveryComplete)
+ {
+ _tcsFilter.SetResult(_testsToRun.Count);
+ }
+ else if (message is xua.IErrorMessage errorMessage)
+ {
+ Console.WriteLine($"Error ocurred {errorMessage.ToString()}");
+ }
+ else
+ {
+ Console.WriteLine($"Unhandled message of type {message.GetType()}");
+ }
+ }
+ catch (Exception ex)
+ {
+ _tcsFilter?.SetException(ex);
+ _tcsRun?.SetException(ex);
+ _tcs?.SetException(ex);
+ }
+ }
+
+
+ TestCaseWrapper Wrap(xua.ITestCase test, bool addToSuite = false)
+ {
+ var suite = _testWrapper.GetSuite(test.DisplayName, false);
+ var testWrapper = new TestCaseWrapper(test) { Parent = suite };
+ if (addToSuite)
+ suite.Children.Add(testWrapper);
+ return testWrapper;
+ }
+
+ TestSuiteWrapper Wrap(xua.ITestClass test)
+ {
+ return _testWrapper.GetSuite(test.Class.Name, true);
+ }
+
+ void SetStatus(TestSuiteWrapper suite, TestStatus status)
+ {
+ if (suite == null)
+ return;
+ if (_testSuiteStatus.TryGetValue(suite, out var currentStatus))
+ {
+ status = (TestStatus)Math.Max((int)currentStatus, (int)status);
+ }
+ _testSuiteStatus[suite] = status;
+ if (_testSuiteStarted.Contains(suite) && suite.RunningTests.Count == 0)
+ {
+ _testSuiteStarted.Remove(suite);
+ var parent = suite.Parent as TestSuiteWrapper;
+ if (parent != null)
+ {
+ parent.RunningTests.Remove(suite);
+ }
+ OnTestFinished(new UnitTestResultEventArgs(new TestResultWrapper(suite, status)));
+ SetStatus(parent, status);
+ }
+ }
+
+ bool SetStarted(TestSuiteWrapper suite)
+ {
+ if (suite == null)
+ return false;
+ if (!_testSuiteStarted.Contains(suite))
+ {
+ _testSuiteStarted.Add(suite);
+ var parent = suite.Parent as TestSuiteWrapper;
+ if (parent != null)
+ {
+ SetStarted(parent);
+ parent.RunningTests.Add(suite);
+ }
+ OnTestStarted(new UnitTestTestEventArgs(suite));
+ return true;
+ }
+ return false;
+ }
+
+ void RunMessageHandler(xua.IMessageSinkMessage message)
+ {
+ try
+ {
+ if (message is xua.ITestAssemblyFinished assemblyFinished)
+ {
+ _tcsRun.SetResult(new TestResultWrapper(assemblyFinished));
+ }
+ else if (message is xua.ITestClassStarting classStarting)
+ {
+ var suite = Wrap(classStarting.TestClass);
+ SetStarted(suite);
+ OnTestStarted(new UnitTestTestEventArgs(suite));
+ }
+ else if (message is xua.ITestClassFinished classFinished)
+ {
+ var suite = Wrap(classFinished.TestClass);
+ var result = new TestResultWrapper(classFinished, suite);
+ SetStatus(suite, result.Status);
+ OnTestFinished(new UnitTestResultEventArgs(result));
+ }
+ else if (message is xua.ITestStarting testStarting)
+ {
+ OnTestStarted(new UnitTestTestEventArgs(Wrap(testStarting.TestCase)));
+ }
+ else if (message is xua.ITestFailed testFailed)
+ {
+ OnTestFinished(new UnitTestResultEventArgs(new TestResultWrapper(testFailed, Wrap(testFailed.TestCase))));
+ }
+ else if (message is xua.ITestPassed testPassed)
+ {
+ OnTestFinished(new UnitTestResultEventArgs(new TestResultWrapper(testPassed, Wrap(testPassed.TestCase))));
+ }
+ else if (message is xua.ITestSkipped testSkipped)
+ {
+ OnTestFinished(new UnitTestResultEventArgs(new TestResultWrapper(testSkipped, Wrap(testSkipped.TestCase))));
+ }
+ else if (message is xua.IErrorMessage errorMessage)
+ {
+ Console.WriteLine($"Error ocurred {errorMessage.ToString()}");
+ }
+ else
+ {
+ Console.WriteLine($"Unhandled message of type {message.GetType()}");
+ }
+ }
+ catch (Exception ex)
+ {
+ _tcsRun.SetException(ex);
+ }
+ }
+
+ bool xu.IMessageSinkWithTypes.OnMessageWithTypes(xua.IMessageSinkMessage message, HashSet messageTypes)
+ {
+ messageHandler?.Invoke(message);
+ return !_cancel;
+ }
+ }
+}
diff --git a/src/Eto.UnitTest.Xunit/XunitTestRunnerType.cs b/src/Eto.UnitTest.Xunit/XunitTestRunnerType.cs
new file mode 100644
index 0000000..c68792c
--- /dev/null
+++ b/src/Eto.UnitTest.Xunit/XunitTestRunnerType.cs
@@ -0,0 +1,16 @@
+using Eto.UnitTest;
+
+[assembly: TestRunnerType(typeof(Eto.UnitTest.Xunit.XunitTestRunnerType))]
+
+
+namespace Eto.UnitTest.Xunit
+{
+ public class XunitTestRunnerType : TestRunnerType
+ {
+ public override string Name => "Xunit";
+
+ protected override string[] RequiredReferences => new[] { "xunit.core" };
+
+ public static void Register() => TestRunnerType.Add(new XunitTestRunnerType());
+ }
+}
diff --git a/src/Eto.UnitTest/Eto.UnitTest.csproj b/src/Eto.UnitTest/Eto.UnitTest.csproj
new file mode 100644
index 0000000..2f336ac
--- /dev/null
+++ b/src/Eto.UnitTest/Eto.UnitTest.csproj
@@ -0,0 +1,23 @@
+
+
+
+ netstandard2.0
+
+
+ Unit testing UI and utilities for Eto.Forms
+ Provides a control to use in Eto.Forms applications that can be used to display, filter, and run unit tests.
+ unit test;test;testing;tdd
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Eto.UnitTest/Filters/AndFilter.cs b/src/Eto.UnitTest/Filters/AndFilter.cs
new file mode 100644
index 0000000..8ad3371
--- /dev/null
+++ b/src/Eto.UnitTest/Filters/AndFilter.cs
@@ -0,0 +1,45 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Eto.UnitTest
+{
+ class AndFilter : ITestFilter
+ {
+ public List Filters { get; }
+
+ public AndFilter()
+ {
+ Filters = new List();
+ }
+
+ public AndFilter(params ITestFilter[] filters)
+ {
+ Filters = filters.ToList();
+ }
+
+ public AndFilter(IEnumerable filters)
+ {
+ Filters = filters.ToList();
+ }
+
+ public bool IsExplicitMatch(ITest test)
+ {
+ for (int i = 0; i < Filters.Count; i++)
+ {
+ if (!Filters[i].IsExplicitMatch(test))
+ return false;
+ }
+ return true;
+ }
+
+ public bool Pass(ITest test)
+ {
+ for (int i = 0; i < Filters.Count; i++)
+ {
+ if (!Filters[i].Pass(test))
+ return false;
+ }
+ return true;
+ }
+ }
+}
diff --git a/src/Eto.UnitTest/Filters/BaseFilter.cs b/src/Eto.UnitTest/Filters/BaseFilter.cs
new file mode 100644
index 0000000..1a21579
--- /dev/null
+++ b/src/Eto.UnitTest/Filters/BaseFilter.cs
@@ -0,0 +1,33 @@
+using System.Linq;
+
+namespace Eto.UnitTest
+{
+ abstract class BaseFilter : ITestFilter
+ {
+ public bool IsExplicitMatch(ITest test) => Matches(test);
+
+ public bool ChildCanMatch { get; set; } = true;
+ public bool ParentCanMatch { get; set; } = true;
+
+ public bool Pass(ITest test)
+ {
+ var matches = Matches(test);
+
+ if (test.IsSuite)
+ {
+ if (ChildCanMatch)
+ matches |= test.GetChildren().Any(Matches);
+ }
+ else if (ParentCanMatch)
+ matches |= test.GetParents().Any(ParentMatch);
+
+ return matches;
+ }
+
+ protected bool ParentMatch(ITest test) => Matches(test, true);
+
+ protected bool Matches(ITest test) => Matches(test, false);
+
+ protected abstract bool Matches(ITest test, bool parent);
+ }
+}
diff --git a/src/Eto.UnitTest/Filters/CategoryFilter.cs b/src/Eto.UnitTest/Filters/CategoryFilter.cs
new file mode 100644
index 0000000..cac64ef
--- /dev/null
+++ b/src/Eto.UnitTest/Filters/CategoryFilter.cs
@@ -0,0 +1,33 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Eto.UnitTest
+{
+ class CategoryFilter : BaseFilter
+ {
+ public List Categories { get; }
+
+ public CategoryFilter()
+ {
+ Categories = new List();
+ }
+
+ public CategoryFilter(IEnumerable categories)
+ {
+ Categories = categories.ToList();
+ }
+
+ public bool MatchAll { get; set; }
+
+ public bool AllowNone { get; set; }
+
+ protected override bool Matches(ITest test, bool parent)
+ {
+ var categories = test.Categories.ToList();
+ if (categories == null || categories.Count == 0)
+ return Categories.Count == 0;
+
+ return MatchAll ? Categories.All(categories.Contains) : Categories.Any(categories.Contains);
+ }
+ }
+}
diff --git a/src/Eto.UnitTest/Filters/EmptyFilter.cs b/src/Eto.UnitTest/Filters/EmptyFilter.cs
new file mode 100644
index 0000000..01615ee
--- /dev/null
+++ b/src/Eto.UnitTest/Filters/EmptyFilter.cs
@@ -0,0 +1,9 @@
+namespace Eto.UnitTest
+{
+ class EmptyFilter : ITestFilter
+ {
+ public bool IsExplicitMatch(ITest test) => true;
+
+ public bool Pass(ITest test) => true;
+ }
+}
diff --git a/src/Eto.UnitTest/Filters/KeywordFilter.cs b/src/Eto.UnitTest/Filters/KeywordFilter.cs
new file mode 100644
index 0000000..7b4e9ce
--- /dev/null
+++ b/src/Eto.UnitTest/Filters/KeywordFilter.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace Eto.UnitTest
+{
+ class KeywordFilter : BaseFilter
+ {
+ string keywords;
+ string[][] keywordTokens;
+
+ string[] SplitMatches(string value, string regex) => Regex.Matches(value, regex).OfType().Select(r => r.Value).ToArray();
+
+ ///
+ /// Gets or sets the keyword string to search for.
+ ///
+ ///
+ /// Supports:
+ /// - '-' prefix to exclude keyword
+ /// - Quotes for literal matches e.g. "my test"
+ /// - Multiple keywords separated by whitespace
+ ///
+ /// The keywords.
+ public string Keywords
+ {
+ get => keywords;
+ set
+ {
+ keywords = value;
+ if (string.IsNullOrWhiteSpace(value))
+ keywordTokens = null;
+ else
+ {
+ var searches = SplitMatches(value, @"([-]?""[^""]*"")|((?<=[\s]|^)[^""\s]+(?=[\s]|$))");
+ keywordTokens = searches
+ .Select(s => SplitMatches(s, @"(^-)|((?<=^-?"").+(?=""$))|([A-Z][^A-Z]*[^A-Z""]?)|((?= 0;
+ }
+ }
+}
diff --git a/src/Eto.UnitTest/Filters/NotFilter.cs b/src/Eto.UnitTest/Filters/NotFilter.cs
new file mode 100644
index 0000000..e13df67
--- /dev/null
+++ b/src/Eto.UnitTest/Filters/NotFilter.cs
@@ -0,0 +1,20 @@
+namespace Eto.UnitTest
+{
+ class NotFilter : ITestFilter
+ {
+ public ITestFilter Filter { get; set; }
+
+ public NotFilter(ITestFilter filter)
+ {
+ Filter = filter;
+ }
+
+ public NotFilter()
+ {
+ }
+
+ public bool IsExplicitMatch(ITest test) => Filter?.IsExplicitMatch(test) != true;
+
+ public bool Pass(ITest test) => Filter?.Pass(test) != true;
+ }
+}
diff --git a/src/Eto.UnitTest/Filters/NotRunFilter.cs b/src/Eto.UnitTest/Filters/NotRunFilter.cs
new file mode 100644
index 0000000..ace5e85
--- /dev/null
+++ b/src/Eto.UnitTest/Filters/NotRunFilter.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Linq;
+
+namespace Eto.UnitTest
+{
+ class NotRunFilter : ITestFilter
+ {
+ Func LookupResult { get; }
+
+ public NotRunFilter(Func lookupResult)
+ {
+ LookupResult = lookupResult;
+ }
+
+ public override string ToString() => "Not Run";
+
+ public bool Pass(ITest test)
+ {
+ if (test.IsSuite)
+ return test.GetChildren(false).Any(Pass);
+ return IsExplicitMatch(test);
+ }
+
+ public bool IsExplicitMatch(ITest test) => LookupResult(test) == null;
+
+ }
+}
diff --git a/src/Eto.UnitTest/Filters/OrFilter.cs b/src/Eto.UnitTest/Filters/OrFilter.cs
new file mode 100644
index 0000000..163730a
--- /dev/null
+++ b/src/Eto.UnitTest/Filters/OrFilter.cs
@@ -0,0 +1,46 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Eto.UnitTest
+{
+ class OrFilter : ITestFilter
+ {
+ public List Filters { get; }
+
+ public OrFilter()
+ {
+ Filters = new List();
+ }
+
+ public OrFilter(params ITestFilter[] filters)
+ {
+ Filters = filters.ToList();
+ }
+
+ public OrFilter(IEnumerable filters)
+ {
+ Filters = filters.ToList();
+ }
+
+ public bool IsExplicitMatch(ITest test)
+ {
+ for (int i = 0; i < Filters.Count; i++)
+ {
+ if (Filters[i].IsExplicitMatch(test))
+ return true;
+ }
+ return false;
+ }
+
+ public bool Pass(ITest test)
+ {
+ for (int i = 0; i < Filters.Count; i++)
+ {
+ if (Filters[i].Pass(test))
+ return true;
+ }
+ return false;
+ }
+
+ }
+}
diff --git a/src/Eto.UnitTest/Filters/SingleTestFilter.cs b/src/Eto.UnitTest/Filters/SingleTestFilter.cs
new file mode 100644
index 0000000..e06102a
--- /dev/null
+++ b/src/Eto.UnitTest/Filters/SingleTestFilter.cs
@@ -0,0 +1,54 @@
+using System.Reflection;
+
+namespace Eto.UnitTest
+{
+ class SingleTestFilter : ITestFilter
+ {
+ public ITest Test { get; set; }
+
+ public Assembly Assembly { get; set; }
+
+ public bool IsExplicitMatch(ITest test)
+ {
+ if (Assembly != null)
+ {
+ if (!test.IsSuite && test.Assembly != Assembly)
+ return false;
+ if (test.IsAssembly && test.Assembly != Assembly)
+ return false;
+ }
+ return test.FullName == Test.FullName;
+ }
+
+ public bool Pass(ITest test)
+ {
+ if (Assembly != null)
+ {
+ if (!test.IsSuite && test.Assembly != Assembly)
+ return false;
+
+ if (test.IsAssembly && test.Assembly != Assembly)
+ return false;
+ }
+
+
+ var parent = Test;
+ // check if it is a parent of the test
+ while (parent != null)
+ {
+ if (test.FullName == parent.FullName)
+ return true;
+ parent = parent.Parent;
+ }
+ // execute all children of the test
+ parent = test;
+ while (parent != null)
+ {
+ if (parent.FullName == Test.FullName)
+ return true;
+ parent = parent.Parent;
+ }
+ return false;
+ }
+ }
+}
diff --git a/src/Eto.UnitTest/Filters/StatusFilter.cs b/src/Eto.UnitTest/Filters/StatusFilter.cs
new file mode 100644
index 0000000..625e4e9
--- /dev/null
+++ b/src/Eto.UnitTest/Filters/StatusFilter.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Linq;
+
+namespace Eto.UnitTest
+{
+ class StatusFilter : ITestFilter
+ {
+ Func _lookupResult;
+
+ public string Name => Status.ToString();
+
+ public TestStatus Status { get; }
+
+ public StatusFilter(Func lookupResult, TestStatus status)
+ {
+ Status = status;
+ _lookupResult = lookupResult;
+ }
+
+ public override string ToString() => Name;
+
+ public bool Pass(ITest test)
+ {
+ if (test.IsSuite)
+ return test.GetChildren(false).Any(Pass);
+ return IsExplicitMatch(test);
+ }
+
+ public bool IsExplicitMatch(ITest test) => _lookupResult(test)?.Status == Status;
+
+ }
+}
diff --git a/src/Eto.UnitTest/Filters/TestFilter.cs b/src/Eto.UnitTest/Filters/TestFilter.cs
new file mode 100644
index 0000000..7a218d3
--- /dev/null
+++ b/src/Eto.UnitTest/Filters/TestFilter.cs
@@ -0,0 +1,12 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Collections;
+
+namespace Eto.UnitTest
+{
+
+ public static class TestFilter
+ {
+ public static ITestFilter Empty { get; } = new EmptyFilter();
+ }
+}
diff --git a/src/Eto.UnitTest/INativeObject.cs b/src/Eto.UnitTest/INativeObject.cs
new file mode 100644
index 0000000..cf80401
--- /dev/null
+++ b/src/Eto.UnitTest/INativeObject.cs
@@ -0,0 +1,7 @@
+namespace Eto.UnitTest
+{
+ public interface INativeObject
+ {
+ object NativeObject { get; }
+ }
+}
diff --git a/src/Eto.UnitTest/ITest.cs b/src/Eto.UnitTest/ITest.cs
new file mode 100644
index 0000000..e875d27
--- /dev/null
+++ b/src/Eto.UnitTest/ITest.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Reflection;
+using System.Collections.Generic;
+
+namespace Eto.UnitTest
+{
+ public interface ITest : INativeObject
+ {
+ bool IsSuite { get; }
+ bool IsAssembly { get; }
+ bool IsParameterized { get; }
+ string Name { get; }
+ ITest Parent { get; }
+ bool HasChildren { get; }
+ IEnumerable Tests { get; }
+ IEnumerable Categories { get; }
+ Type Type { get; }
+ Assembly Assembly { get; }
+ string FullName { get; }
+ }
+}
diff --git a/src/Eto.UnitTest/ITestCategory.cs b/src/Eto.UnitTest/ITestCategory.cs
new file mode 100644
index 0000000..d3077b5
--- /dev/null
+++ b/src/Eto.UnitTest/ITestCategory.cs
@@ -0,0 +1,7 @@
+namespace Eto.UnitTest
+{
+ public interface ITestCategory
+ {
+ string Name { get; }
+ }
+}
diff --git a/src/Eto.UnitTest/ITestFilter.cs b/src/Eto.UnitTest/ITestFilter.cs
new file mode 100644
index 0000000..0943d05
--- /dev/null
+++ b/src/Eto.UnitTest/ITestFilter.cs
@@ -0,0 +1,8 @@
+namespace Eto.UnitTest
+{
+ public interface ITestFilter
+ {
+ bool IsExplicitMatch(ITest test);
+ bool Pass(ITest test);
+ }
+}
diff --git a/src/Eto.UnitTest/ITestResult.cs b/src/Eto.UnitTest/ITestResult.cs
new file mode 100644
index 0000000..2aa69f5
--- /dev/null
+++ b/src/Eto.UnitTest/ITestResult.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+
+namespace Eto.UnitTest
+{
+ public interface ITestAssertion : INativeObject
+ {
+ string Message { get; }
+ TestStatus Status { get; }
+ string StackTrace { get; }
+ }
+
+ public interface ITestResult : INativeObject
+ {
+ int AssertCount { get; }
+ //int ErrorCount { get; }
+ int FailCount { get; }
+ int WarningCount { get; }
+ int PassCount { get; }
+ int InconclusiveCount { get; }
+ int SkipCount { get; }
+ TestStatus Status { get; }
+ ITest Test { get; }
+ IEnumerable Assersions { get; }
+ IEnumerable Children { get; }
+ TimeSpan Duration { get; }
+ DateTime EndTime { get; }
+ bool HasChildren { get; }
+ string Message { get; }
+ string Name { get; }
+ string Output { get; }
+ DateTime StartTime { get; }
+ }
+}
diff --git a/src/Eto.UnitTest/ITestRunner.cs b/src/Eto.UnitTest/ITestRunner.cs
new file mode 100644
index 0000000..9e86ba8
--- /dev/null
+++ b/src/Eto.UnitTest/ITestRunner.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Eto.UnitTest
+{
+ public interface ITestRunner
+ {
+ bool IsRunning { get; }
+ ITest TestSuite { get; }
+
+ event EventHandler Log;
+ event EventHandler Progress;
+ event EventHandler TestFinished;
+ event EventHandler TestStarted;
+ event EventHandler IsRunningChanged;
+
+ void StopTests();
+
+ Task Load(ITestSource source);
+
+ Task GetTestCount(ITestFilter filter);
+ Task> GetCategories(ITestFilter filter);
+ Task RunAsync(ITestFilter filter);
+ }
+}
diff --git a/src/Eto.UnitTest/ITestRunnerType.cs b/src/Eto.UnitTest/ITestRunnerType.cs
new file mode 100644
index 0000000..e6a4934
--- /dev/null
+++ b/src/Eto.UnitTest/ITestRunnerType.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+
+namespace Eto.UnitTest
+{
+ [AttributeUsage(AttributeTargets.Assembly)]
+ public class TestRunnerTypeAttribute : Attribute
+ {
+ ITestRunnerType _runnerType;
+ public Type Type { get; }
+
+ public ITestRunnerType RunnerType => _runnerType ?? (_runnerType = (ITestRunnerType)Activator.CreateInstance(Type));
+
+ public TestRunnerTypeAttribute(Type type)
+ {
+ Type = type;
+ }
+ }
+
+ public abstract class TestRunnerType : ITestRunnerType
+ where T : ITestRunner, new()
+ {
+ public virtual string Name { get; }
+
+ static bool HasReference(Assembly assembly, string referenceName)
+ {
+ foreach (var reference in assembly.GetReferencedAssemblies())
+ {
+ if (string.Equals(reference.Name, referenceName, StringComparison.Ordinal))
+ return true;
+ }
+ return false;
+ }
+
+ protected virtual string[] RequiredReferences => new string[0];
+
+ public virtual bool CanExecute(ITestSource source)
+ {
+ var assembly = source.GetReflectionAssembly();
+ foreach (var reference in RequiredReferences)
+ {
+ if (!HasReference(assembly, reference))
+ return false;
+ }
+ return true;
+ }
+
+ public ITestRunner CreateRunner() => new T();
+ }
+
+
+ public static class TestRunnerType
+ {
+ static List types;
+
+ public static void AddAssembly(AssemblyName assemblyName)
+ {
+ AddAssembly(Assembly.Load(assemblyName));
+ }
+
+ public static void Add(ITestRunnerType type)
+ {
+ if (types == null)
+ types = new List();
+ types.Add(type);
+ }
+
+ public static void AddAssembly(Assembly assembly)
+ {
+ if (types == null)
+ types = new List();
+ types.AddRange(assembly.GetCustomAttributes().Select(r => r.RunnerType));
+ }
+
+ public static ITestRunnerType Find(ITestSource source)
+ {
+ if (types == null)
+ {
+ foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ AddAssembly(assembly);
+ }
+ }
+
+ foreach (var type in types)
+ {
+ if (type.CanExecute(source))
+ {
+ return type;
+ }
+ }
+ return null;
+ }
+ }
+
+ public interface ITestRunnerType
+ {
+ string Name { get; }
+ bool CanExecute(ITestSource source);
+ ITestRunner CreateRunner();
+ }
+}
diff --git a/src/Eto.UnitTest/MultipleTest.cs b/src/Eto.UnitTest/MultipleTest.cs
new file mode 100644
index 0000000..470e5ca
--- /dev/null
+++ b/src/Eto.UnitTest/MultipleTest.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+
+namespace Eto.UnitTest
+{
+ class MultipleTest : ITest
+ {
+ List _tests;
+ public MultipleTest(IEnumerable tests)
+ {
+ _tests = tests.ToList();
+ }
+
+ public bool IsSuite => true;
+
+ public bool IsAssembly => false;
+
+ public bool IsParameterized => false;
+
+ public string Name => null;
+
+ public ITest Parent => null;
+
+ public bool HasChildren => true;
+
+ public IEnumerable Tests => _tests;
+
+ public IEnumerable Categories => _tests.SelectMany(r => r.Categories).Distinct();
+
+ public Type Type => null;
+
+ public Assembly Assembly => null;
+
+ public string FullName => null;
+
+ public object NativeObject => null;
+ }
+}
diff --git a/src/Eto.UnitTest/MultipleTestResult.cs b/src/Eto.UnitTest/MultipleTestResult.cs
new file mode 100644
index 0000000..32fbefc
--- /dev/null
+++ b/src/Eto.UnitTest/MultipleTestResult.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Eto.UnitTest
+{
+ public class MultipleTestResult : ITestResult
+ {
+ public List Results { get; private set; }
+
+ public MultipleTestResult(ITest test)
+ {
+ Results = new List();
+ Test = test;
+ }
+
+ public int AssertCount => Results.Sum(r => r.AssertCount);
+
+ public IEnumerable Children => Results.SelectMany(r => r.Children);
+
+ public TimeSpan Duration => TimeSpan.FromTicks(Results.Sum(r => r.Duration.Ticks));
+
+ public DateTime EndTime => Results.Max(r => r.EndTime);
+
+ public int FailCount => Results.Sum(r => r.FailCount);
+
+ //public int ErrorCount => Results.Sum(r => r.ErrorCount);
+
+ public string FullName => string.Empty;
+
+ public bool HasChildren => Results.Any(r => r.HasChildren);
+
+ public int InconclusiveCount => Results.Sum(r => r.InconclusiveCount);
+
+ public string Message => string.Join("\n", Results.Select(r => r.Message));
+
+ public string Name => string.Join(", ", Results.Select(r => r.Name));
+
+ public string Output => string.Join("\n", Results.Select(r => r.Output));
+
+ public int PassCount => Results.Sum(r => r.PassCount);
+
+ public int SkipCount => Results.Sum(r => r.SkipCount);
+
+ public DateTime StartTime => Results.Min(r => r.StartTime);
+
+ public ITest Test { get; }
+
+ public int WarningCount => Results.Sum(r => r.WarningCount);
+
+ public TestStatus Status => Results.Max(r => r.Status);
+
+ public object NativeObject => null;
+
+ public IEnumerable Assersions => Results.SelectMany(r => r.Assersions);
+ }
+}
diff --git a/src/Eto.UnitTest/Runners/BaseTestRunner.cs b/src/Eto.UnitTest/Runners/BaseTestRunner.cs
new file mode 100644
index 0000000..99436f9
--- /dev/null
+++ b/src/Eto.UnitTest/Runners/BaseTestRunner.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Eto.UnitTest.Runners
+{
+ public abstract class BaseTestRunner : ITestRunner, IDisposable
+ {
+ public abstract bool IsRunning { get; }
+
+ public abstract ITest TestSuite { get; }
+
+ public event EventHandler Log;
+ public event EventHandler Progress;
+ public event EventHandler TestFinished;
+ public event EventHandler TestStarted;
+ public event EventHandler IsRunningChanged;
+
+ protected virtual void OnLog(UnitTestLogEventArgs e) => Log?.Invoke(this, e);
+ protected virtual void OnProgress(UnitTestProgressEventArgs e) => Progress?.Invoke(this, e);
+ protected virtual void OnTestFinished(UnitTestResultEventArgs e) => TestFinished?.Invoke(this, e);
+ protected virtual void OnTestStarted(UnitTestTestEventArgs e) => TestStarted?.Invoke(this, e);
+ protected virtual void OnIsRunningChanged(EventArgs e) => IsRunningChanged?.Invoke(this, e);
+
+ public abstract Task> GetCategories(ITestFilter filter);
+ public abstract Task GetTestCount(ITestFilter filter);
+ public abstract Task Load(ITestSource source);
+ public abstract Task RunAsync(ITestFilter filter);
+ public abstract void StopTests();
+
+ protected void WriteLog(string message) => OnLog(new UnitTestLogEventArgs(message));
+
+ public void Dispose() => Dispose(true);
+
+ protected virtual void Dispose(bool disposing)
+ {
+ }
+ }
+}
diff --git a/src/Eto.UnitTest/Runners/LoggingTestRunner.cs b/src/Eto.UnitTest/Runners/LoggingTestRunner.cs
new file mode 100644
index 0000000..c1278ab
--- /dev/null
+++ b/src/Eto.UnitTest/Runners/LoggingTestRunner.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Eto.UnitTest.Runners
+{
+ public class LoggingTestRunner : WrappedTestRunner
+ {
+ string lastTestStarted;
+ object lastTestLock = new object();
+ public bool ShowOutput { get; set; }
+ public bool ShowOnlyFailed { get; set; } = true;
+ public bool ShowTotalAtStart { get; set; }
+
+
+ public LoggingTestRunner(ITestRunner inner)
+ : base(inner)
+ {
+ }
+
+ protected override void OnTestStarted(UnitTestTestEventArgs e)
+ {
+ base.OnTestStarted(e);
+ var test = e.Test;
+
+ if (!ShowOnlyFailed && !test.IsSuite)
+ {
+ lock (lastTestLock)
+ {
+ lastTestStarted = test.FullName;
+ WriteLog(test.FullName);
+ }
+ }
+ }
+
+ void WriteTest(ITest test)
+ {
+ var currentTest = test.FullName;
+ if (lastTestStarted != currentTest)
+ {
+ WriteLog(currentTest);
+ lastTestStarted = currentTest;
+ }
+ }
+
+ protected override void OnTestFinished(UnitTestResultEventArgs e)
+ {
+ base.OnTestFinished(e);
+ var result = e.Result;
+
+ if (!result.Test.IsSuite)
+ {
+ lock (lastTestLock)
+ {
+
+ if (ShowOutput && !string.IsNullOrEmpty(result.Output))
+ {
+ WriteTest(result.Test);
+ WriteLog(result.Output);
+ }
+
+ if (result.Status != TestStatus.Passed && result.Status != TestStatus.Skipped)
+ {
+ WriteTest(result.Test);
+ var assertions = result.Assersions;
+ if (assertions != null)
+ {
+ foreach (var assertion in assertions)
+ {
+ if (assertion.Status == TestStatus.Passed)
+ continue;
+ if (!string.IsNullOrEmpty(assertion.StackTrace))
+ WriteLog($"{assertion.Status}: {assertion.Message}\n{assertion.StackTrace}");
+ else
+ WriteLog($"{assertion.Status}: {assertion.Message}");
+ }
+ }
+ else
+ {
+ WriteLog($"{result.Status}: {result.Message}");
+ }
+ }
+ }
+ }
+ }
+
+ public override async Task RunAsync(ITestFilter filter)
+ {
+ WriteLog("Starting tests...");
+
+ if (ShowTotalAtStart)
+ {
+ var testCount = await GetTestCount(filter);
+
+ WriteLog($"Total test count: {testCount}");
+ }
+
+ ITestResult result;
+ try
+ {
+ result = await base.RunAsync(filter);
+ }
+ catch (Exception ex)
+ {
+ WriteLog($"Error running tests: {ex}");
+ return null;
+ }
+
+ WriteLog(result.FailCount > 0 ? "FAILED" : "PASSED");
+ var sb = new StringBuilder();
+ void AppendText(string str)
+ {
+ if (sb.Length > 0)
+ sb.Append(", ");
+ sb.Append(str);
+ }
+ if (result.PassCount > 0)
+ AppendText($"Passed: {result.PassCount}");
+ if (result.FailCount > 0)
+ AppendText($"Failed: {result.FailCount}");
+ if (result.SkipCount > 0)
+ AppendText($"Skipped: {result.FailCount}");
+ //if (result.ErrorCount > 0)
+ // AppendText($"Errors: {result.ErrorCount}");
+ if (result.WarningCount > 0)
+ AppendText($"Warnings: {result.WarningCount}");
+ if (result.InconclusiveCount > 0)
+ AppendText($"Inconclusive: {result.InconclusiveCount}");
+
+ WriteLog($"\t{sb}");
+ WriteLog($"\tDuration: {result.Duration}");
+ return result;
+ }
+ }
+}
diff --git a/src/Eto.UnitTest/Runners/MultipleTestRunner.cs b/src/Eto.UnitTest/Runners/MultipleTestRunner.cs
new file mode 100644
index 0000000..83357fa
--- /dev/null
+++ b/src/Eto.UnitTest/Runners/MultipleTestRunner.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Eto.UnitTest.Runners
+{
+ public class MultipleTestRunner : ProgressTestRunner
+ {
+ ITestRunner _currentRunner;
+ Queue _runnersToTest;
+ ITestFilter _testFilter;
+
+ List Runners { get; } = new List();
+
+ public override async Task> GetCategories(ITestFilter filter)
+ {
+ var categoryList = await Task.WhenAll(Runners.Select(r => r.GetCategories(filter)));
+ return categoryList.SelectMany(r => r).Distinct();
+ }
+
+ public override async Task GetTestCount(ITestFilter filter)
+ {
+ var countList = await Task.WhenAll(Runners.Select(r => r.GetTestCount(filter)));
+ return countList.Sum();
+ }
+ MultipleTest _testSuite;
+
+ public override ITest TestSuite => _testSuite;
+
+ public override void StopTests()
+ {
+ lock (this)
+ {
+ if (IsRunning)
+ {
+ WriteLog("Stopping tests...");
+ _runnersToTest?.Clear();
+ _currentRunner?.StopTests();
+ }
+ }
+ }
+
+ public override async Task Load(ITestSource source)
+ {
+ var runnerType = TestRunnerType.Find(source);
+ if (runnerType != null)
+ {
+ var runner = runnerType.CreateRunner();
+ await runner.Load(source);
+ Runners.Add(runner);
+ }
+ _testSuite = new MultipleTest(Runners.Select(r => r.TestSuite));
+ }
+
+ public void Add(ITestRunner runner)
+ {
+ Runners.Add(runner);
+ _testSuite = new MultipleTest(Runners.Select(r => r.TestSuite));
+ }
+
+ public void Clear()
+ {
+ Runners.Clear();
+ _testSuite = null;
+ }
+
+ protected override async Task RunInternalAsync(ITestFilter filter)
+ {
+ var results = new MultipleTestResult(_testSuite);
+ _testFilter = filter;
+ _runnersToTest = new Queue(Runners.Where(r => _testFilter.Pass((r.TestSuite))));
+
+ OnTestStarted(new UnitTestTestEventArgs(_testSuite));
+ try
+ {
+ while (_runnersToTest.Count > 0)
+ {
+ lock (this)
+ {
+ _currentRunner = _runnersToTest.Dequeue();
+ _currentRunner.Log += currentRunner_Log;
+ _currentRunner.Progress += currentRunner_Progress;
+ _currentRunner.TestStarted += currentRunner_TestStarted;
+ _currentRunner.TestFinished += currentRunner_TestFinished;
+ }
+ var result = await _currentRunner.RunAsync(filter);
+ results.Results.Add(result);
+
+ _currentRunner.Log -= currentRunner_Log;
+ _currentRunner.Progress -= currentRunner_Progress;
+ _currentRunner.TestStarted -= currentRunner_TestStarted;
+ _currentRunner.TestFinished -= currentRunner_TestFinished;
+ }
+ OnTestFinished(new UnitTestResultEventArgs(results));
+
+ }
+ finally
+ {
+ _currentRunner = null;
+ }
+ return results;
+
+ }
+
+ private void currentRunner_TestFinished(object sender, UnitTestResultEventArgs e)
+ {
+ OnTestFinished(e);
+ }
+
+ private void currentRunner_TestStarted(object sender, UnitTestTestEventArgs e)
+ {
+ OnTestStarted(e);
+ }
+
+ private void currentRunner_Progress(object sender, UnitTestProgressEventArgs e)
+ {
+ OnProgress(e);
+ }
+
+ private void currentRunner_Log(object sender, UnitTestLogEventArgs e)
+ {
+ //OnLog(e);
+ }
+ }
+}
diff --git a/src/Eto.UnitTest/Runners/ProgressTestRunner.cs b/src/Eto.UnitTest/Runners/ProgressTestRunner.cs
new file mode 100644
index 0000000..6a65b2f
--- /dev/null
+++ b/src/Eto.UnitTest/Runners/ProgressTestRunner.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Threading.Tasks;
+
+namespace Eto.UnitTest.Runners
+{
+ public abstract class ProgressTestRunner : BaseTestRunner, IDisposable
+ {
+ UnitTestProgressEventArgs _progressArgs;
+
+ bool _isRunning;
+ public override bool IsRunning => _isRunning;
+
+ void SetIsRunning(bool value)
+ {
+ if (_isRunning != value)
+ {
+ _isRunning = value;
+ OnIsRunningChanged(EventArgs.Empty);
+ }
+ }
+
+ protected override void OnTestFinished(UnitTestResultEventArgs e)
+ {
+ base.OnTestFinished(e);
+ var result = e.Result;
+
+ if (!result.Test.IsSuite)
+ {
+ _progressArgs.AddResult(result);
+ OnProgress(_progressArgs);
+ }
+ }
+
+ public override sealed async Task RunAsync(ITestFilter filter)
+ {
+ SetIsRunning(true);
+ try
+ {
+ var testCount = await GetTestCount(filter);
+ _progressArgs = new UnitTestProgressEventArgs(testCount);
+ return await RunInternalAsync(filter);
+ }
+ finally
+ {
+ SetIsRunning(false);
+ }
+ }
+
+ protected abstract Task RunInternalAsync(ITestFilter filter);
+ }
+}
diff --git a/src/Eto.UnitTest/Runners/WrappedTestRunner.cs b/src/Eto.UnitTest/Runners/WrappedTestRunner.cs
new file mode 100644
index 0000000..468122b
--- /dev/null
+++ b/src/Eto.UnitTest/Runners/WrappedTestRunner.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Eto.UnitTest.Runners
+{
+ public class WrappedTestRunner : BaseTestRunner
+ {
+ ITestRunner _inner;
+ public override bool IsRunning => _inner.IsRunning;
+
+ public override ITest TestSuite => _inner.TestSuite;
+
+ public WrappedTestRunner(ITestRunner inner)
+ {
+ _inner = inner;
+ _inner.Log += _inner_Log;
+ _inner.Progress += _inner_Progress;
+ _inner.IsRunningChanged += _inner_IsRunningChanged;
+ _inner.TestFinished += _inner_TestFinished;
+ _inner.TestStarted += _inner_TestStarted;
+ }
+
+ private void _inner_TestStarted(object sender, UnitTestTestEventArgs e) => OnTestStarted(e);
+ private void _inner_TestFinished(object sender, UnitTestResultEventArgs e) => OnTestFinished(e);
+ private void _inner_IsRunningChanged(object sender, EventArgs e) => OnIsRunningChanged(e);
+ private void _inner_Progress(object sender, UnitTestProgressEventArgs e) => OnProgress(e);
+ private void _inner_Log(object sender, UnitTestLogEventArgs e) => OnLog(e);
+
+ public override Task> GetCategories(ITestFilter filter) => _inner.GetCategories(filter);
+ public override Task GetTestCount(ITestFilter filter) => _inner.GetTestCount(filter);
+ public override Task Load(ITestSource source) => _inner.Load(source);
+ public override Task RunAsync(ITestFilter filter) => _inner.RunAsync(filter);
+ public override void StopTests() => _inner.StopTests();
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing && _inner is IDisposable disposable)
+ disposable.Dispose();
+ }
+ }
+}
diff --git a/src/Eto.UnitTest/TestHelpers.cs b/src/Eto.UnitTest/TestHelpers.cs
new file mode 100644
index 0000000..a67333f
--- /dev/null
+++ b/src/Eto.UnitTest/TestHelpers.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+
+namespace Eto.UnitTest
+{
+ public static class TestHelpers
+ {
+ public static IEnumerable GetChildren(this ITest test) => GetChildren(test, true);
+
+ public static IEnumerable GetChildren(this ITest test, bool recursive)
+ {
+ if (test.HasChildren)
+ {
+ foreach (var child in test.Tests)
+ {
+ yield return child;
+ if (recursive)
+ {
+ foreach (var childTest in GetChildren(child, recursive))
+ yield return childTest;
+ }
+ }
+ }
+ }
+
+ public static IEnumerable GetParents(this ITest test)
+ {
+ while (test != null)
+ {
+ yield return test;
+ test = test.Parent;
+ }
+ }
+ }
+}
diff --git a/src/Eto.UnitTest/TestSource.cs b/src/Eto.UnitTest/TestSource.cs
new file mode 100644
index 0000000..5bbc1ce
--- /dev/null
+++ b/src/Eto.UnitTest/TestSource.cs
@@ -0,0 +1,39 @@
+using System;
+using System.IO;
+using System.Reflection;
+
+namespace Eto.UnitTest
+{
+ public interface ITestSource
+ {
+ Assembly Assembly { get; }
+ Assembly GetReflectionAssembly();
+ }
+
+ [Serializable]
+ public class TestSource : ITestSource
+ {
+ [NonSerialized]
+ Assembly _reflectionAssembly;
+ Assembly _assembly;
+
+ public string Path { get; }
+ public Assembly Assembly => _assembly ?? Assembly.LoadFrom(Path);
+ public TestSource(string path)
+ {
+ Path = path;
+ }
+
+ public TestSource(Assembly assembly)
+ {
+ _assembly = assembly;
+ Path = assembly.Location;
+ }
+
+ public Assembly GetReflectionAssembly() => _assembly ?? _reflectionAssembly ?? (_reflectionAssembly = Assembly.ReflectionOnlyLoad(File.ReadAllBytes(Path)));
+
+ public static implicit operator TestSource(Assembly assembly) => new TestSource(assembly);
+
+ public static implicit operator TestSource(string assemblyName) => new TestSource(assemblyName);
+ }
+}
diff --git a/src/Eto.UnitTest/TestStatus.cs b/src/Eto.UnitTest/TestStatus.cs
new file mode 100644
index 0000000..ed4b119
--- /dev/null
+++ b/src/Eto.UnitTest/TestStatus.cs
@@ -0,0 +1,12 @@
+namespace Eto.UnitTest
+{
+ public enum TestStatus
+ {
+ Inconclusive = 0,
+ Skipped = 1,
+ Passed = 2,
+ Warning = 3,
+ Failed = 4,
+ //Error = 5
+ }
+}
diff --git a/src/Eto.UnitTest/UI/AsyncQueue.cs b/src/Eto.UnitTest/UI/AsyncQueue.cs
new file mode 100644
index 0000000..f215991
--- /dev/null
+++ b/src/Eto.UnitTest/UI/AsyncQueue.cs
@@ -0,0 +1,88 @@
+using System;
+using Eto.Forms;
+using System.Collections.Generic;
+
+namespace Eto.UnitTest.UI
+{
+ class AsyncQueue
+ {
+ List actions = new List();
+ Dictionary namedActions = new Dictionary();
+ UITimer timer;
+ double delay = 0.2;
+ bool isQueued;
+
+ public double Delay
+ {
+ get => delay;
+ set
+ {
+ delay = value;
+ if (timer != null)
+ timer.Interval = delay;
+ }
+ }
+
+ public void Add(string name, Action action)
+ {
+ lock (this)
+ {
+ namedActions[name] = action;
+ Start();
+ }
+ }
+
+ public void Add(Action action)
+ {
+ lock (this)
+ {
+ actions.Add(action);
+ Start();
+ }
+ }
+
+ void Start()
+ {
+ if (!isQueued)
+ {
+ isQueued = true;
+ /**
+ Application.Instance.AsyncInvoke(FlushQueue);
+ /**/
+ Application.Instance.AsyncInvoke(StartTimer);
+ /**/
+ }
+ }
+
+ void StartTimer()
+ {
+ if (timer == null)
+ {
+ timer = new UITimer { Interval = delay };
+ timer.Elapsed += Timer_Elapsed;
+ }
+ timer.Start();
+ }
+
+ void Timer_Elapsed(object sender, EventArgs e) => FlushQueue();
+
+ void FlushQueue()
+ {
+ List actionList;
+ lock (this)
+ {
+ actionList = actions;
+ actionList.AddRange(namedActions.Values);
+ namedActions.Clear();
+ actions = new List();
+ isQueued = false;
+ timer?.Stop();
+ }
+
+ foreach (var action in actionList)
+ {
+ action();
+ }
+ }
+ }
+}
diff --git a/src/Eto.UnitTest/UI/BindingExtensions.cs b/src/Eto.UnitTest/UI/BindingExtensions.cs
new file mode 100644
index 0000000..e35f916
--- /dev/null
+++ b/src/Eto.UnitTest/UI/BindingExtensions.cs
@@ -0,0 +1,17 @@
+using Eto.Forms;
+
+namespace Eto.UnitTest.UI
+{
+ static class BindingExtensions
+ {
+ public static IndirectBinding Invoke(this IndirectBinding binding)
+ {
+ return new DelegateBinding