From 13f0cedb64186b0939cdb56e215019485ebb6eb0 Mon Sep 17 00:00:00 2001 From: Fredric Silberberg Date: Fri, 2 Jun 2017 16:18:53 -0700 Subject: [PATCH 1/4] Fix whitespace --- xunit.runner.wpf/MainWindow.xaml | 4 ++-- xunit.runner.wpf/ViewModel/MainViewModel.cs | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/xunit.runner.wpf/MainWindow.xaml b/xunit.runner.wpf/MainWindow.xaml index 7f45ada..ddf8ca0 100644 --- a/xunit.runner.wpf/MainWindow.xaml +++ b/xunit.runner.wpf/MainWindow.xaml @@ -256,7 +256,7 @@ @@ -271,7 +271,7 @@ diff --git a/xunit.runner.wpf/ViewModel/MainViewModel.cs b/xunit.runner.wpf/ViewModel/MainViewModel.cs index 55959dd..768baaa 100644 --- a/xunit.runner.wpf/ViewModel/MainViewModel.cs +++ b/xunit.runner.wpf/ViewModel/MainViewModel.cs @@ -17,7 +17,6 @@ using Microsoft.Win32; using Microsoft.WindowsAPICodePack.Taskbar; using Xunit.Runner.Data; using Xunit.Runner.Wpf.Persistence; -using System.Collections; namespace Xunit.Runner.Wpf.ViewModel { @@ -458,10 +457,10 @@ namespace Xunit.Runner.Wpf.ViewModel } /// - /// Reloading an assembly could have changed the traits. There is no easy way - /// to selectively edit this list (traits can cross assembly boundaries). Just + /// Reloading an assembly could have changed the traits. There is no easy way + /// to selectively edit this list (traits can cross assembly boundaries). Just /// do a full reload instead. - /// way to + /// way to /// private void RebuildTraits() { From e41071ddb03322da82c27c21015306e66273ff6f Mon Sep 17 00:00:00 2001 From: Fredric Silberberg Date: Fri, 2 Jun 2017 16:19:08 -0700 Subject: [PATCH 2/4] Added autoreloading of test assemblies --- xunit.runner.wpf/ITestAssemblyWatcher.cs | 29 +++ xunit.runner.wpf/Impl/TestAssemblyWatcher.cs | 206 +++++++++++++++++++ xunit.runner.wpf/MainWindow.xaml | 2 + xunit.runner.wpf/Persistence/Settings.cs | 23 +++ xunit.runner.wpf/ViewModel/MainViewModel.cs | 55 ++++- xunit.runner.wpf/xunit.runner.wpf.csproj | 2 + 6 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 xunit.runner.wpf/ITestAssemblyWatcher.cs create mode 100644 xunit.runner.wpf/Impl/TestAssemblyWatcher.cs diff --git a/xunit.runner.wpf/ITestAssemblyWatcher.cs b/xunit.runner.wpf/ITestAssemblyWatcher.cs new file mode 100644 index 0000000..7ebc8cf --- /dev/null +++ b/xunit.runner.wpf/ITestAssemblyWatcher.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace Xunit.Runner.Wpf +{ + internal interface ITestAssemblyWatcher + { + /// + /// Adds a new assembly to list of assemblies to be autoreloaded. + /// + void AddAssembly(string assemblyFileName); + + /// + /// Removes an assembly from the list of assemblies ot be autoreloaded. + /// + void RemoveAssembly(string assemblyFileName); + + /// + /// Enables watching of all assemblies. + /// + /// Action to perform when a file change is detected + void EnableWatch(Func, bool> reloader); + + /// + /// Disables watching of all assemblies + /// + void DisableWatch(); + } +} diff --git a/xunit.runner.wpf/Impl/TestAssemblyWatcher.cs b/xunit.runner.wpf/Impl/TestAssemblyWatcher.cs new file mode 100644 index 0000000..612e2eb --- /dev/null +++ b/xunit.runner.wpf/Impl/TestAssemblyWatcher.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Threading; + +namespace Xunit.Runner.Wpf.Impl +{ + internal sealed class TestAssemblyWatcher : ITestAssemblyWatcher + { + private readonly object sync = new object(); + private readonly IDictionary watchedAssemblies = new Dictionary(); + private readonly Dispatcher dispatcher; + private bool isEnabled = false; + private ReloadDebouncer debouncer; + + public TestAssemblyWatcher(Dispatcher dispatcher) + { + this.dispatcher = dispatcher; + } + + public void AddAssembly(string assemblyFileName) + { + // Assumptions about adding and removing assemblies are broken if this isn't true + Debug.Assert(string.Equals(assemblyFileName, Path.GetFullPath(assemblyFileName), StringComparison.Ordinal)); + + lock (sync) + { + if (watchedAssemblies.ContainsKey(assemblyFileName)) + { + // Already watching this assembly, nothing to do but return + return; + } + + FileSystemWatcher watcher = new FileSystemWatcher + { + Path = Path.GetDirectoryName(assemblyFileName), + NotifyFilter = NotifyFilters.CreationTime | NotifyFilters.LastWrite, + Filter = Path.GetFileName(assemblyFileName) + }; + + watcher.Changed += new FileSystemEventHandler(OnChanged); + watcher.Created += new FileSystemEventHandler(OnChanged); + + watchedAssemblies[assemblyFileName] = watcher; + + if (isEnabled) + { + watcher.EnableRaisingEvents = true; + } + } + } + + public void RemoveAssembly(string assemblyFileName) + { + lock (sync) + { + if (watchedAssemblies.ContainsKey(assemblyFileName)) + { + watchedAssemblies[assemblyFileName].Dispose(); + watchedAssemblies.Remove(assemblyFileName); + } + } + } + + public void EnableWatch(Func, bool> reloader) + { + lock (sync) + { + isEnabled = true; + + foreach (var watcher in watchedAssemblies.Values) + { + watcher.EnableRaisingEvents = true; + } + + this.debouncer = new ReloadDebouncer(dispatcher, reloader); + } + } + + public void DisableWatch() + { + lock (sync) + { + isEnabled = false; + + foreach (var watcher in watchedAssemblies.Values) + { + watcher.EnableRaisingEvents = false; + } + + this.debouncer?.Cancel(); + this.debouncer = null; + } + } + + private void OnChanged(object source, FileSystemEventArgs args) + { + debouncer?.AddAssembly(args.FullPath); + } + + /// + /// Because, during a build of a number of projects many file system events will be triggered for potentially many + /// test assemblies, we need to batch our update requests. This class will do this, waiting for 100 ms after receiving + /// a new reload request to send the reload requests. This timer resets every time a reload request is received. Note + /// that if you continuously rebuild, this will technicially never finish batching and nothing will reload, but this + /// assumes that file events will stop at some point. + /// + /// If the reloader returns false, meaning that the reload was not kicked off successfully, we back off for a full second + /// before reattempting to queue the updates. + /// + private class ReloadDebouncer + { + private readonly object sync = new object(); + private readonly Dispatcher dispatcher; + private readonly Func, bool> reloader; + + private ISet assembliesToReload = new HashSet(); + private bool newAssemblyAdded = false; + private bool running = false; + private bool cancelled = false; + + public ReloadDebouncer(Dispatcher dispatcher, Func, bool> reloader) + { + this.dispatcher = dispatcher; + this.reloader = reloader; + } + + public void AddAssembly(string assembly) + { + lock (sync) + { + assembliesToReload.Add(assembly); + + if (!Start()) + { + newAssemblyAdded = true; + } + } + } + + public void Cancel() + { + running = false; + } + + private bool Start() + { + if (running) + { + return false; + } + + running = true; + Task.Run((Action)Debounce); + return true; + } + + private async void Debounce() + { + bool backOff = false; + + do + { + await Task.Delay(backOff ? 1000 : 100); + backOff = false; + + lock (sync) + { + void Reset() + { + assembliesToReload = new HashSet(); + running = false; + } + + if (cancelled) + { + Reset(); + return; + } + + // New assemblies added, so we need to wait again + if (newAssemblyAdded) + { + newAssemblyAdded = false; + continue; + } + + // No new assemblies added, time to alert and exit + if (!dispatcher.Invoke(() => reloader(assembliesToReload))) + { + // If the reloader returned false, it's still busy from the last reload request or other user action. + // Back off for a full second to give it time, then continue as previous + backOff = true; + continue; + } + Reset(); + } + + } while (running); + } + } + } +} diff --git a/xunit.runner.wpf/MainWindow.xaml b/xunit.runner.wpf/MainWindow.xaml index ddf8ca0..9b44790 100644 --- a/xunit.runner.wpf/MainWindow.xaml +++ b/xunit.runner.wpf/MainWindow.xaml @@ -61,6 +61,8 @@ + + diff --git a/xunit.runner.wpf/Persistence/Settings.cs b/xunit.runner.wpf/Persistence/Settings.cs index 9474805..717a688 100644 --- a/xunit.runner.wpf/Persistence/Settings.cs +++ b/xunit.runner.wpf/Persistence/Settings.cs @@ -14,16 +14,19 @@ namespace Xunit.Runner.Wpf.Persistence private const string RecentAssemblyElementName = "recent_assembly"; private const string SettingsElementName = "settings"; private const string VersionAttributeName = "version"; + private const string AutoReloadAssembliesElementName = "auto_reload_assemblies"; private const int MaxRecentAssemblies = 10; private static readonly Version s_latestVersion = new Version(1, 0, 0, 0); private List recentAssemblies; + private bool autoReloadAssemblies; private Settings() { recentAssemblies = new List(); + autoReloadAssemblies = true; } public void AddRecentAssembly(string filePath) @@ -44,6 +47,13 @@ namespace Xunit.Runner.Wpf.Persistence } } + public void ToggleAutoReloadAssemblies() + { + autoReloadAssemblies = !autoReloadAssemblies; + } + + public bool GetAutoReloadAssemblies() => autoReloadAssemblies; + public ImmutableArray GetRecentAssemblies() { return this.recentAssemblies.ToImmutableArray(); @@ -66,6 +76,8 @@ namespace Xunit.Runner.Wpf.Persistence xml.Add(recentAssembliesElement); } + xml.Add(new XElement(AutoReloadAssembliesElementName, autoReloadAssemblies)); + xml.Save(xmlFile); } } @@ -103,6 +115,17 @@ namespace Xunit.Runner.Wpf.Persistence } } + var autoReloadAssembliesElement = xml.Element(AutoReloadAssembliesElementName); + if (autoReloadAssembliesElement != null) + { + if (!bool.TryParse(autoReloadAssembliesElement.Value, out var autoReloadAssemblies)) + { + autoReloadAssemblies = true; + } + + settings.autoReloadAssemblies = autoReloadAssemblies; + } + return settings; } } diff --git a/xunit.runner.wpf/ViewModel/MainViewModel.cs b/xunit.runner.wpf/ViewModel/MainViewModel.cs index 768baaa..4151699 100644 --- a/xunit.runner.wpf/ViewModel/MainViewModel.cs +++ b/xunit.runner.wpf/ViewModel/MainViewModel.cs @@ -25,6 +25,7 @@ namespace Xunit.Runner.Wpf.ViewModel private readonly Settings settings; private readonly ITestUtil testUtil; + private readonly ITestAssemblyWatcher assemblyWatcher; private readonly HashSet allTestCaseUniqueIDs = new HashSet(); private readonly ObservableCollection allTestCases = new ObservableCollection(); private readonly TraitCollectionView traitCollectionView = new TraitCollectionView(); @@ -33,10 +34,21 @@ namespace Xunit.Runner.Wpf.ViewModel private CancellationTokenSource cancellationTokenSource; private bool isBusy; private SearchQuery searchQuery = new SearchQuery(); + private bool autoReloadAssemblies; public ObservableCollection Assemblies { get; } = new ObservableCollection(); public FilteredCollectionView FilteredTestCases { get; } public ObservableCollection Traits => this.traitCollectionView.Collection; + public bool AutoReloadAssemblies + { + get => autoReloadAssemblies; + set + { + var oldVal = autoReloadAssemblies; + autoReloadAssemblies = value; + RaisePropertyChanged(nameof(AutoReloadAssemblies), oldVal, autoReloadAssemblies); + } + } public ObservableCollection RecentAssemblies { get; } = new ObservableCollection(); @@ -55,6 +67,7 @@ namespace Xunit.Runner.Wpf.ViewModel public ICommand AssemblyReloadAllCommand { get; } public ICommand AssemblyRemoveCommand { get; } public ICommand AssemblyRemoveAllCommand { get; } + public ICommand AutoReloadAssembliesCommand { get; } public CommandBindingCollection CommandBindings { get; } @@ -70,13 +83,14 @@ namespace Xunit.Runner.Wpf.ViewModel CommandBindings = CreateCommandBindings(); this.testUtil = new Xunit.Runner.Wpf.Impl.RemoteTestUtil(Dispatcher.CurrentDispatcher); + this.assemblyWatcher = new Impl.TestAssemblyWatcher(Dispatcher.CurrentDispatcher); this.TestCasesCaption = "Test Cases (0)"; this.FilteredTestCases = new FilteredCollectionView( allTestCases, TestCaseMatches, searchQuery, TestComparer.Instance); this.FilteredTestCases.CollectionChanged += TestCases_CollectionChanged; - + this.ExitCommand = new RelayCommand(OnExecuteExit); this.WindowLoadedCommand = new RelayCommand(OnExecuteWindowLoaded); this.WindowClosingCommand = new RelayCommand(OnExecuteWindowClosing); @@ -89,8 +103,10 @@ namespace Xunit.Runner.Wpf.ViewModel this.AssemblyReloadAllCommand = new RelayCommand(OnExecuteAssemblyReloadAll); this.AssemblyRemoveCommand = new RelayCommand(OnExecuteAssemblyRemove, CanExecuteAssemblyRemove); this.AssemblyRemoveAllCommand = new RelayCommand(OnExecuteAssemblyRemoveAll); + this.AutoReloadAssembliesCommand = new RelayCommand(OnToggleAutoReloadAssemblies); RebuildRecentAssembliesMenu(); + AutoReloadAssemblies = this.settings.GetAutoReloadAssemblies(); } private void RebuildRecentAssembliesMenu() @@ -391,12 +407,26 @@ namespace Xunit.Runner.Wpf.ViewModel foreach (var assemblyViewModel in newAssemblyViewModels) { assemblyViewModel.State = AssemblyState.Ready; + assemblyWatcher.AddAssembly(assemblyViewModel.FileName); } RebuildRecentAssembliesMenu(); } } + public bool ReloadAssemblies(IEnumerable assemblies) + { + if (IsBusy) + { + return false; + } + + var testAssemblies = Assemblies.Where(assembly => assemblies.Contains(assembly.FileName)); + Application.Current.Dispatcher.InvokeAsync(() => ReloadAssemblies(testAssemblies)); + + return true; + } + private async Task ReloadAssemblies(IEnumerable assemblies) { try @@ -764,6 +794,29 @@ namespace Xunit.Runner.Wpf.ViewModel { RemoveAssemblies(Assemblies.ToArray()); } + private void OnToggleAutoReloadAssemblies() + { + ToggleReloadAssemblies(); + } + + private void ToggleReloadAssemblies() + { + this.settings.ToggleAutoReloadAssemblies(); + AutoReloadAssemblies = this.settings.GetAutoReloadAssemblies(); + UpdateAutoReloadStatus(); + } + + private void UpdateAutoReloadStatus() + { + if (AutoReloadAssemblies) + { + assemblyWatcher.EnableWatch(ReloadAssemblies); + } + else + { + assemblyWatcher.DisableWatch(); + } + } public bool FilterPassedTests { diff --git a/xunit.runner.wpf/xunit.runner.wpf.csproj b/xunit.runner.wpf/xunit.runner.wpf.csproj index a187a51..01ad152 100644 --- a/xunit.runner.wpf/xunit.runner.wpf.csproj +++ b/xunit.runner.wpf/xunit.runner.wpf.csproj @@ -107,6 +107,8 @@ + + From 686404bb7f42d326bc04e43e152d7564fb6ca49a Mon Sep 17 00:00:00 2001 From: Fredric Silberberg Date: Fri, 2 Jun 2017 16:25:17 -0700 Subject: [PATCH 3/4] Remove from watch when removed from the program. --- xunit.runner.wpf/ViewModel/MainViewModel.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/xunit.runner.wpf/ViewModel/MainViewModel.cs b/xunit.runner.wpf/ViewModel/MainViewModel.cs index 4151699..a549553 100644 --- a/xunit.runner.wpf/ViewModel/MainViewModel.cs +++ b/xunit.runner.wpf/ViewModel/MainViewModel.cs @@ -462,6 +462,7 @@ namespace Xunit.Runner.Wpf.ViewModel { foreach (var assembly in assemblies.ToList()) { + assemblyWatcher.RemoveAssembly(assembly.FileName); RemoveAssemblyTestCases(assembly.FileName); Assemblies.Remove(assembly); } From 39c5a33d56803d245705d60ada3a3b2d458be2f8 Mon Sep 17 00:00:00 2001 From: Fredric Silberberg Date: Fri, 2 Jun 2017 16:40:48 -0700 Subject: [PATCH 4/4] Use Microsoft.Net.Compilers, update all base system libraries --- SampleTestAssembly/SampleTestAssembly.csproj | 7 +++++++ SampleTestAssembly/packages.config | 1 + xunit.runner.data/xunit.runner.data.csproj | 5 +++++ xunit.runner.worker/packages.config | 1 + .../xunit.runner.worker.csproj | 9 +++++++++ xunit.runner.wpf/packages.config | 19 ++++++++++--------- xunit.runner.wpf/xunit.runner.wpf.csproj | 8 +++++--- 7 files changed, 38 insertions(+), 12 deletions(-) diff --git a/SampleTestAssembly/SampleTestAssembly.csproj b/SampleTestAssembly/SampleTestAssembly.csproj index 88b95d7..ed3c179 100644 --- a/SampleTestAssembly/SampleTestAssembly.csproj +++ b/SampleTestAssembly/SampleTestAssembly.csproj @@ -1,5 +1,6 @@  + Debug @@ -65,6 +66,12 @@ + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + +