diff --git a/src/Tools/dotnet-watch/README.md b/src/Tools/dotnet-watch/README.md index ff7102a92e..d3944206fa 100644 --- a/src/Tools/dotnet-watch/README.md +++ b/src/Tools/dotnet-watch/README.md @@ -29,6 +29,7 @@ Some configuration options can be passed to `dotnet watch` through environment v | Variable | Effect | | ---------------------------------------------- | -------------------------------------------------------- | | DOTNET_USE_POLLING_FILE_WATCHER | If set to "1" or "true", `dotnet watch` will use a polling file watcher instead of CoreFx's `FileSystemWatcher`. Used when watching files on network shares or Docker mounted volumes. | +| DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM | By default, `dotnet watch` optimizes the build by avoiding certain operations such as running restore or re-evaluating the set of watched files on every file change. If set to "1" or "true", these optimizations are disabled. | ### MSBuild diff --git a/src/Tools/dotnet-watch/src/DotNetWatchContext.cs b/src/Tools/dotnet-watch/src/DotNetWatchContext.cs new file mode 100644 index 0000000000..16b5e453fa --- /dev/null +++ b/src/Tools/dotnet-watch/src/DotNetWatchContext.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public class DotNetWatchContext + { + public IReporter Reporter { get; set; } = NullReporter.Singleton; + + public ProcessSpec ProcessSpec { get; set; } + + public IFileSet FileSet { get; set; } + + public int Iteration { get; set; } + + public string ChangedFile { get; set; } + + public bool RequiresMSBuildRevaluation { get; set; } + + public bool SuppressMSBuildIncrementalism { get; set; } + } +} diff --git a/src/Tools/dotnet-watch/src/DotNetWatcher.cs b/src/Tools/dotnet-watch/src/DotNetWatcher.cs index 38b457298b..76b5a3adc0 100644 --- a/src/Tools/dotnet-watch/src/DotNetWatcher.cs +++ b/src/Tools/dotnet-watch/src/DotNetWatcher.cs @@ -3,9 +3,12 @@ using System; using System.Globalization; +using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.DotNet.Watcher.Internal; +using Microsoft.DotNet.Watcher.Tools; using Microsoft.Extensions.CommandLineUtils; using Microsoft.Extensions.Tools.Internal; @@ -15,33 +18,63 @@ namespace Microsoft.DotNet.Watcher { private readonly IReporter _reporter; private readonly ProcessRunner _processRunner; + private readonly IWatchFilter[] _filters; - public DotNetWatcher(IReporter reporter) + public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory) { Ensure.NotNull(reporter, nameof(reporter)); _reporter = reporter; _processRunner = new ProcessRunner(reporter); + + _filters = new IWatchFilter[] + { + new MSBuildEvaluationFilter(fileSetFactory), + new NoRestoreFilter(), + }; } - public async Task WatchAsync(ProcessSpec processSpec, IFileSetFactory fileSetFactory, - CancellationToken cancellationToken) + public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancellationToken) { Ensure.NotNull(processSpec, nameof(processSpec)); - var cancelledTaskSource = new TaskCompletionSource(); - cancellationToken.Register(state => ((TaskCompletionSource) state).TrySetResult(null), + var cancelledTaskSource = new TaskCompletionSource(); + cancellationToken.Register(state => ((TaskCompletionSource)state).TrySetResult(), cancelledTaskSource); - var iteration = 1; + var initialArguments = processSpec.Arguments.ToArray(); + var suppressMSBuildIncrementalism = Environment.GetEnvironmentVariable("DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM"); + var context = new DotNetWatchContext + { + Iteration = -1, + ProcessSpec = processSpec, + Reporter = _reporter, + SuppressMSBuildIncrementalism = suppressMSBuildIncrementalism == "1" || suppressMSBuildIncrementalism == "true", + }; + + if (context.SuppressMSBuildIncrementalism) + { + _reporter.Verbose("MSBuild incremental optimizations suppressed."); + } while (true) { - processSpec.EnvironmentVariables["DOTNET_WATCH_ITERATION"] = iteration.ToString(CultureInfo.InvariantCulture); - iteration++; + context.Iteration++; - var fileSet = await fileSetFactory.CreateAsync(cancellationToken); + // Reset arguments + processSpec.Arguments = initialArguments; + for (var i = 0; i < _filters.Length; i++) + { + await _filters[i].ProcessAsync(context, cancellationToken); + } + + // Reset for next run + context.RequiresMSBuildRevaluation = false; + + processSpec.EnvironmentVariables["DOTNET_WATCH_ITERATION"] = (context.Iteration + 1).ToString(CultureInfo.InvariantCulture); + + var fileSet = context.FileSet; if (fileSet == null) { _reporter.Error("Failed to find a list of files to watch"); @@ -91,10 +124,13 @@ namespace Microsoft.DotNet.Watcher return; } + context.ChangedFile = fileSetTask.Result; if (finishedTask == processTask) { + // Process exited. Redo evaludation + context.RequiresMSBuildRevaluation = true; // Now wait for a file to change before restarting process - await fileSetWatcher.GetChangedFileAsync(cancellationToken, () => _reporter.Warn("Waiting for a file to change before restarting dotnet...")); + context.ChangedFile = await fileSetWatcher.GetChangedFileAsync(cancellationToken, () => _reporter.Warn("Waiting for a file to change before restarting dotnet...")); } if (!string.IsNullOrEmpty(fileSetTask.Result)) diff --git a/src/Tools/dotnet-watch/src/IWatchFilter.cs b/src/Tools/dotnet-watch/src/IWatchFilter.cs new file mode 100644 index 0000000000..1dcfa39392 --- /dev/null +++ b/src/Tools/dotnet-watch/src/IWatchFilter.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public interface IWatchFilter + { + ValueTask ProcessAsync(DotNetWatchContext context, CancellationToken cancellationToken); + } +} diff --git a/src/Tools/dotnet-watch/src/Internal/FileSet.cs b/src/Tools/dotnet-watch/src/Internal/FileSet.cs index 736ce43677..7fdf8748e9 100644 --- a/src/Tools/dotnet-watch/src/Internal/FileSet.cs +++ b/src/Tools/dotnet-watch/src/Internal/FileSet.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -20,6 +20,8 @@ namespace Microsoft.DotNet.Watcher.Internal public int Count => _files.Count; + public static IFileSet Empty = new FileSet(Array.Empty()); + public IEnumerator GetEnumerator() => _files.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator(); } diff --git a/src/Tools/dotnet-watch/src/MSBuildEvaluationFilter.cs b/src/Tools/dotnet-watch/src/MSBuildEvaluationFilter.cs new file mode 100644 index 0000000000..82cda1dea6 --- /dev/null +++ b/src/Tools/dotnet-watch/src/MSBuildEvaluationFilter.cs @@ -0,0 +1,124 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public class MSBuildEvaluationFilter : IWatchFilter + { + // File types that require an MSBuild re-evaluation + private static readonly string[] _msBuildFileExtensions = new[] + { + ".csproj", ".props", ".targets", ".fsproj", ".vbproj", ".vcxproj", + }; + private static readonly int[] _msBuildFileExtensionHashes = _msBuildFileExtensions + .Select(e => e.GetHashCode(StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + private readonly IFileSetFactory _factory; + + private List<(string fileName, DateTime lastWriteTimeUtc)> _msbuildFileTimestamps; + + public MSBuildEvaluationFilter(IFileSetFactory factory) + { + _factory = factory; + } + + public async ValueTask ProcessAsync(DotNetWatchContext context, CancellationToken cancellationToken) + { + if (context.SuppressMSBuildIncrementalism) + { + context.RequiresMSBuildRevaluation = true; + context.FileSet = await _factory.CreateAsync(cancellationToken); + return; + } + + if (context.Iteration == 0 || RequiresMSBuildRevaluation(context)) + { + context.RequiresMSBuildRevaluation = true; + } + + if (context.RequiresMSBuildRevaluation) + { + context.Reporter.Verbose("Evaluating dotnet-watch file set."); + + context.FileSet = await _factory.CreateAsync(cancellationToken); + _msbuildFileTimestamps = GetMSBuildFileTimeStamps(context); + } + } + + private bool RequiresMSBuildRevaluation(DotNetWatchContext context) + { + var changedFile = context.ChangedFile; + if (!string.IsNullOrEmpty(changedFile) && IsMsBuildFileExtension(changedFile)) + { + return true; + } + + // The filewatcher may miss changes to files. For msbuild files, we can verify that they haven't been modified + // since the previous iteration. + // We do not have a way to identify renames or new additions that the file watcher did not pick up, + // without performing an evaluation. We will start off by keeping it simple and comparing the timestamps + // of known MSBuild files from previous run. This should cover the vast majority of cases. + + foreach (var (file, lastWriteTimeUtc) in _msbuildFileTimestamps) + { + if (GetLastWriteTimeUtcSafely(file) != lastWriteTimeUtc) + { + context.Reporter.Verbose($"Re-evaluation needed due to changes in {file}."); + + return true; + } + } + + return false; + } + + private List<(string fileName, DateTime lastModifiedUtc)> GetMSBuildFileTimeStamps(DotNetWatchContext context) + { + var msbuildFiles = new List<(string fileName, DateTime lastModifiedUtc)>(); + foreach (var file in context.FileSet) + { + if (!string.IsNullOrEmpty(file) && IsMsBuildFileExtension(file)) + { + msbuildFiles.Add((file, GetLastWriteTimeUtcSafely(file))); + } + } + + return msbuildFiles; + } + + protected virtual DateTime GetLastWriteTimeUtcSafely(string file) + { + try + { + return File.GetLastWriteTimeUtc(file); + } + catch + { + return DateTime.UtcNow; + } + } + + static bool IsMsBuildFileExtension(string fileName) + { + var extension = Path.GetExtension(fileName.AsSpan()); + var hashCode = string.GetHashCode(extension, StringComparison.OrdinalIgnoreCase); + for (var i = 0; i < _msBuildFileExtensionHashes.Length; i++) + { + if (_msBuildFileExtensionHashes[i] == hashCode && extension.Equals(_msBuildFileExtensions[i], StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + } +} diff --git a/src/Tools/dotnet-watch/src/NoRestoreFilter.cs b/src/Tools/dotnet-watch/src/NoRestoreFilter.cs new file mode 100644 index 0000000000..c33a117bcd --- /dev/null +++ b/src/Tools/dotnet-watch/src/NoRestoreFilter.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public sealed class NoRestoreFilter : IWatchFilter + { + private bool _canUseNoRestore; + private string[] _noRestoreArguments; + + public ValueTask ProcessAsync(DotNetWatchContext context, CancellationToken cancellationToken) + { + if (context.SuppressMSBuildIncrementalism) + { + return default; + } + + if (context.Iteration == 0) + { + var arguments = context.ProcessSpec.Arguments; + _canUseNoRestore = CanUseNoRestore(arguments, context.Reporter); + if (_canUseNoRestore) + { + // Create run --no-restore + _noRestoreArguments = arguments.Take(1).Append("--no-restore").Concat(arguments.Skip(1)).ToArray(); + context.Reporter.Verbose($"No restore arguments: {string.Join(" ", _noRestoreArguments)}"); + } + } + else if (_canUseNoRestore) + { + if (context.RequiresMSBuildRevaluation) + { + context.Reporter.Verbose("Cannot use --no-restore since msbuild project files have changed."); + } + else + { + context.Reporter.Verbose("Modifying command to use --no-restore"); + context.ProcessSpec.Arguments = _noRestoreArguments; + } + } + + return default; + } + + private static bool CanUseNoRestore(IEnumerable arguments, IReporter reporter) + { + // For some well-known dotnet commands, we can pass in the --no-restore switch to avoid unnecessary restores between iterations. + // For now we'll support the "run" and "test" commands. + if (arguments.Any(a => string.Equals(a, "--no-restore", StringComparison.Ordinal))) + { + // Did the user already configure a --no-restore? + return false; + } + + var dotnetCommand = arguments.FirstOrDefault(); + if (string.Equals(dotnetCommand, "run", StringComparison.Ordinal) || string.Equals(dotnetCommand, "test", StringComparison.Ordinal)) + { + reporter.Verbose("Watch command can be configured to use --no-restore."); + return true; + } + else + { + reporter.Verbose($"Watch command will not use --no-restore. Unsupport dotnet-command '{dotnetCommand}'."); + return false; + } + } + } +} diff --git a/src/Tools/dotnet-watch/src/Program.cs b/src/Tools/dotnet-watch/src/Program.cs index f27ffd878f..8d0903bfc9 100644 --- a/src/Tools/dotnet-watch/src/Program.cs +++ b/src/Tools/dotnet-watch/src/Program.cs @@ -162,8 +162,8 @@ namespace Microsoft.DotNet.Watcher _reporter.Output("Polling file watcher is enabled"); } - await new DotNetWatcher(reporter) - .WatchAsync(processInfo, fileSetFactory, cancellationToken); + await new DotNetWatcher(reporter, fileSetFactory) + .WatchAsync(processInfo, cancellationToken); return 0; } diff --git a/src/Tools/dotnet-watch/test/DotNetWatcherTests.cs b/src/Tools/dotnet-watch/test/DotNetWatcherTests.cs index f085a358ae..a2bb54a93b 100644 --- a/src/Tools/dotnet-watch/test/DotNetWatcherTests.cs +++ b/src/Tools/dotnet-watch/test/DotNetWatcherTests.cs @@ -2,10 +2,9 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.IO; using System.Globalization; +using System.IO; using System.Threading.Tasks; -using Microsoft.AspNetCore.Testing; using Xunit; using Xunit.Abstractions; @@ -54,6 +53,51 @@ namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests } } + [Fact] + public async Task RunsWithNoRestoreOnOrdinaryFileChanges() + { + _app.DotnetWatchArgs.Add("--verbose"); + + await _app.StartWatcherAsync(arguments: new[] { "wait" }); + var source = Path.Combine(_app.SourceDirectory, "Program.cs"); + const string messagePrefix = "watch : Running dotnet with the following arguments: run"; + + // Verify that the first run does not use --no-restore + Assert.Contains(_app.Process.Output, p => string.Equals(messagePrefix + " -- wait", p.Trim())); + + for (var i = 0; i < 3; i++) + { + File.SetLastWriteTime(source, DateTime.Now); + var message = await _app.Process.GetOutputLineStartsWithAsync(messagePrefix, TimeSpan.FromMinutes(2)); + + Assert.Equal(messagePrefix + " --no-restore -- wait", message.Trim()); + } + } + + [Fact] + public async Task RunsWithRestoreIfCsprojChanges() + { + _app.DotnetWatchArgs.Add("--verbose"); + + await _app.StartWatcherAsync(arguments: new[] { "wait" }); + var source = Path.Combine(_app.SourceDirectory, "KitchenSink.csproj"); + const string messagePrefix = "watch : Running dotnet with the following arguments: run"; + + // Verify that the first run does not use --no-restore + Assert.Contains(_app.Process.Output, p => string.Equals(messagePrefix + " -- wait", p.Trim())); + + File.SetLastWriteTime(source, DateTime.Now); + var message = await _app.Process.GetOutputLineStartsWithAsync(messagePrefix, TimeSpan.FromMinutes(2)); + + // csproj changed. Do not expect a --no-restore + Assert.Equal(messagePrefix + " -- wait", message.Trim()); + + // regular file changed after csproj changes. Should use --no-restore + File.SetLastWriteTime(Path.Combine(_app.SourceDirectory, "Program.cs"), DateTime.Now); + message = await _app.Process.GetOutputLineStartsWithAsync(messagePrefix, TimeSpan.FromMinutes(2)); + Assert.Equal(messagePrefix + " --no-restore -- wait", message.Trim()); + } + public void Dispose() { _app.Dispose(); diff --git a/src/Tools/dotnet-watch/test/MSBuildEvaluationFilterTest.cs b/src/Tools/dotnet-watch/test/MSBuildEvaluationFilterTest.cs new file mode 100644 index 0000000000..dbc89c3494 --- /dev/null +++ b/src/Tools/dotnet-watch/test/MSBuildEvaluationFilterTest.cs @@ -0,0 +1,142 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.Watcher.Internal; +using Moq; +using Xunit; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public class MSBuildEvaluationFilterTest + { + private readonly IFileSetFactory _fileSetFactory = Mock.Of( + f => f.CreateAsync(It.IsAny()) == Task.FromResult(new FileSet(Enumerable.Empty()))); + + [Fact] + public async Task ProcessAsync_EvaluatesFileSetIfProjFileChanges() + { + // Arrange + var filter = new MSBuildEvaluationFilter(_fileSetFactory); + var context = new DotNetWatchContext + { + Iteration = 0, + }; + + await filter.ProcessAsync(context, default); + + context.Iteration++; + context.ChangedFile = "Test.csproj"; + context.RequiresMSBuildRevaluation = false; + + // Act + await filter.ProcessAsync(context, default); + + // Assert + Assert.True(context.RequiresMSBuildRevaluation); + } + + [Fact] + public async Task ProcessAsync_DoesNotEvaluateFileSetIfNonProjFileChanges() + { + // Arrange + var filter = new MSBuildEvaluationFilter(_fileSetFactory); + var context = new DotNetWatchContext + { + Iteration = 0, + }; + + await filter.ProcessAsync(context, default); + + context.Iteration++; + context.ChangedFile = "Controller.cs"; + context.RequiresMSBuildRevaluation = false; + + // Act + await filter.ProcessAsync(context, default); + + // Assert + Assert.False(context.RequiresMSBuildRevaluation); + Mock.Get(_fileSetFactory).Verify(v => v.CreateAsync(It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ProcessAsync_EvaluateFileSetOnEveryChangeIfOptimizationIsSuppressed() + { + // Arrange + var filter = new MSBuildEvaluationFilter(_fileSetFactory); + var context = new DotNetWatchContext + { + Iteration = 0, + SuppressMSBuildIncrementalism = true, + }; + + await filter.ProcessAsync(context, default); + + context.Iteration++; + context.ChangedFile = "Controller.cs"; + context.RequiresMSBuildRevaluation = false; + + // Act + await filter.ProcessAsync(context, default); + + // Assert + Assert.True(context.RequiresMSBuildRevaluation); + Mock.Get(_fileSetFactory).Verify(v => v.CreateAsync(It.IsAny()), Times.Exactly(2)); + + } + + [Fact] + public async Task ProcessAsync_SetsEvaluationRequired_IfMSBuildFileChanges_ButIsNotChangedFile() + { + // There's a chance that the watcher does not correctly report edits to msbuild files on + // concurrent edits. MSBuildEvaluationFilter uses timestamps to additionally track changes to these files. + + // Arrange + var fileSet = new FileSet(new[] { "Controlller.cs", "Proj.csproj" }); + var fileSetFactory = Mock.Of(f => f.CreateAsync(It.IsAny()) == Task.FromResult(fileSet)); + + var filter = new TestableMSBuildEvaluationFilter(fileSetFactory) + { + Timestamps = + { + ["Controller.cs"] = new DateTime(1000), + ["Proj.csproj"] = new DateTime(1000), + } + }; + var context = new DotNetWatchContext + { + Iteration = 0, + }; + + await filter.ProcessAsync(context, default); + context.RequiresMSBuildRevaluation = false; + context.ChangedFile = "Controller.cs"; + context.Iteration++; + filter.Timestamps["Proj.csproj"] = new DateTime(1007); + + // Act + await filter.ProcessAsync(context, default); + + // Assert + Assert.True(context.RequiresMSBuildRevaluation); + } + + public class TestableMSBuildEvaluationFilter : MSBuildEvaluationFilter + { + public TestableMSBuildEvaluationFilter(IFileSetFactory factory) + : base(factory) + { + } + + public Dictionary Timestamps { get; } = new Dictionary(); + + protected override DateTime GetLastWriteTimeUtcSafely(string file) => Timestamps[file]; + } + } +} diff --git a/src/Tools/dotnet-watch/test/NoRestoreFilterTest.cs b/src/Tools/dotnet-watch/test/NoRestoreFilterTest.cs new file mode 100644 index 0000000000..0676715707 --- /dev/null +++ b/src/Tools/dotnet-watch/test/NoRestoreFilterTest.cs @@ -0,0 +1,193 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public class NoRestoreFilterTest + { + private readonly string[] _arguments = new[] { "run" }; + + [Fact] + public async Task ProcessAsync_LeavesArgumentsUnchangedOnFirstRun() + { + // Arrange + var filter = new NoRestoreFilter(); + + var context = new DotNetWatchContext + { + ProcessSpec = new ProcessSpec + { + Arguments = _arguments, + } + }; + + // Act + await filter.ProcessAsync(context, default); + + // Assert + Assert.Same(_arguments, context.ProcessSpec.Arguments); + } + + [Fact] + public async Task ProcessAsync_LeavesArgumentsUnchangedIfMsBuildRevaluationIsRequired() + { + // Arrange + var filter = new NoRestoreFilter(); + + var context = new DotNetWatchContext + { + Iteration = 0, + ProcessSpec = new ProcessSpec + { + Arguments = _arguments, + } + }; + await filter.ProcessAsync(context, default); + + context.ChangedFile = "Test.proj"; + context.RequiresMSBuildRevaluation = true; + context.Iteration++; + + // Act + await filter.ProcessAsync(context, default); + + // Assert + Assert.Same(_arguments, context.ProcessSpec.Arguments); + } + + [Fact] + public async Task ProcessAsync_LeavesArgumentsUnchangedIfOptimizationIsSuppressed() + { + // Arrange + var filter = new NoRestoreFilter(); + + var context = new DotNetWatchContext + { + Iteration = 0, + ProcessSpec = new ProcessSpec + { + Arguments = _arguments, + }, + SuppressMSBuildIncrementalism = true, + }; + await filter.ProcessAsync(context, default); + + context.ChangedFile = "Program.cs"; + context.Iteration++; + + // Act + await filter.ProcessAsync(context, default); + + // Assert + Assert.Same(_arguments, context.ProcessSpec.Arguments); + } + + [Fact] + public async Task ProcessAsync_AddsNoRestoreSwitch() + { + // Arrange + var filter = new NoRestoreFilter(); + + var context = new DotNetWatchContext + { + Iteration = 0, + ProcessSpec = new ProcessSpec + { + Arguments = _arguments, + } + }; + await filter.ProcessAsync(context, default); + + context.ChangedFile = "Program.cs"; + context.Iteration++; + + // Act + await filter.ProcessAsync(context, default); + + // Assert + Assert.Equal(new[] { "run", "--no-restore" }, context.ProcessSpec.Arguments); + } + + [Fact] + public async Task ProcessAsync_AddsNoRestoreSwitch_WithAdditionalArguments() + { + // Arrange + var filter = new NoRestoreFilter(); + + var context = new DotNetWatchContext + { + Iteration = 0, + ProcessSpec = new ProcessSpec + { + Arguments = new[] { "run", "-f", "net5.0", "--", "foo=bar" }, + } + }; + await filter.ProcessAsync(context, default); + + context.ChangedFile = "Program.cs"; + context.Iteration++; + + // Act + await filter.ProcessAsync(context, default); + + // Assert + Assert.Equal(new[] { "run", "--no-restore", "-f", "net5.0", "--", "foo=bar" }, context.ProcessSpec.Arguments); + } + + [Fact] + public async Task ProcessAsync_AddsNoRestoreSwitch_ForTestCommand() + { + // Arrange + var filter = new NoRestoreFilter(); + + var context = new DotNetWatchContext + { + Iteration = 0, + ProcessSpec = new ProcessSpec + { + Arguments = new[] { "test", "--filter SomeFilter" }, + } + }; + await filter.ProcessAsync(context, default); + + context.ChangedFile = "Program.cs"; + context.Iteration++; + + // Act + await filter.ProcessAsync(context, default); + + // Assert + Assert.Equal(new[] { "test", "--no-restore", "--filter SomeFilter" }, context.ProcessSpec.Arguments); + } + + [Fact] + public async Task ProcessAsync_DoesNotModifyArgumentsForUnknownCommands() + { + // Arrange + var filter = new NoRestoreFilter(); + var arguments = new[] { "ef", "database", "update" }; + + var context = new DotNetWatchContext + { + Iteration = 0, + ProcessSpec = new ProcessSpec + { + Arguments = arguments, + } + }; + await filter.ProcessAsync(context, default); + + context.ChangedFile = "Program.cs"; + context.Iteration++; + + // Act + await filter.ProcessAsync(context, default); + + // Assert + Assert.Same(arguments, context.ProcessSpec.Arguments); + } + } +} diff --git a/src/Tools/dotnet-watch/test/Scenario/WatchableApp.cs b/src/Tools/dotnet-watch/test/Scenario/WatchableApp.cs index eeae109bf9..d66993757e 100644 --- a/src/Tools/dotnet-watch/test/Scenario/WatchableApp.cs +++ b/src/Tools/dotnet-watch/test/Scenario/WatchableApp.cs @@ -39,6 +39,8 @@ namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests public AwaitableProcess Process { get; protected set; } + public List DotnetWatchArgs { get; } = new List(); + public string SourceDirectory { get; } public Task HasRestarted() @@ -86,6 +88,7 @@ namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests { Scenario.DotNetWatchPath, }; + args.AddRange(DotnetWatchArgs); args.AddRange(arguments); var dotnetPath = "dotnet"; diff --git a/src/Tools/dotnet-watch/test/TestProjects/KitchenSink/Program.cs b/src/Tools/dotnet-watch/test/TestProjects/KitchenSink/Program.cs index ab2edae7a7..329c4930a2 100644 --- a/src/Tools/dotnet-watch/test/TestProjects/KitchenSink/Program.cs +++ b/src/Tools/dotnet-watch/test/TestProjects/KitchenSink/Program.cs @@ -1,8 +1,9 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Diagnostics; +using System.Threading; namespace KitchenSink { @@ -15,6 +16,12 @@ namespace KitchenSink Console.WriteLine($"Process identifier = {Process.GetCurrentProcess().Id}, {Process.GetCurrentProcess().StartTime:hh:mm:ss.FF}"); Console.WriteLine("DOTNET_WATCH = " + Environment.GetEnvironmentVariable("DOTNET_WATCH")); Console.WriteLine("DOTNET_WATCH_ITERATION = " + Environment.GetEnvironmentVariable("DOTNET_WATCH_ITERATION")); + + if (args.Length > 0 && args[0] == "wait") + { + Console.WriteLine("Waiting for process to be terminated."); + Thread.Sleep(Timeout.Infinite); + } } } }