diff --git a/sdk.sln b/sdk.sln index f1ef890d39f9..75023cebd298 100644 --- a/sdk.sln +++ b/sdk.sln @@ -272,6 +272,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dotnet-watch.Tests", "src\T EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Watch.BrowserRefresh.Tests", "src\Tests\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj", "{81ADA3FA-AC26-4149-8CFC-EC7808ECB820}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetWatchTasks", "src\BuiltInTools\DotNetWatchTasks\DotNetWatchTasks.csproj", "{A41DF752-6F21-4036-AD02-DD37B11A2723}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -498,6 +500,10 @@ Global {81ADA3FA-AC26-4149-8CFC-EC7808ECB820}.Debug|Any CPU.Build.0 = Debug|Any CPU {81ADA3FA-AC26-4149-8CFC-EC7808ECB820}.Release|Any CPU.ActiveCfg = Release|Any CPU {81ADA3FA-AC26-4149-8CFC-EC7808ECB820}.Release|Any CPU.Build.0 = Release|Any CPU + {A41DF752-6F21-4036-AD02-DD37B11A2723}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A41DF752-6F21-4036-AD02-DD37B11A2723}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A41DF752-6F21-4036-AD02-DD37B11A2723}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A41DF752-6F21-4036-AD02-DD37B11A2723}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -590,6 +596,7 @@ Global {A82EF2B9-24BC-4569-8FE5-9EF51017F4CB} = {71A9F549-0EB6-41F9-BC16-4A6C5007FC91} {CCE1A328-9CFE-44D3-B68F-FE84A039ACEA} = {580D1AE7-AA8F-4912-8B76-105594E00B3B} {81ADA3FA-AC26-4149-8CFC-EC7808ECB820} = {580D1AE7-AA8F-4912-8B76-105594E00B3B} + {A41DF752-6F21-4036-AD02-DD37B11A2723} = {71A9F549-0EB6-41F9-BC16-4A6C5007FC91} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FB8F26CE-4DE6-433F-B32A-79183020BBD6} diff --git a/src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js b/src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js index d957e285bd20..b03a14601a67 100644 --- a/src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js +++ b/src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js @@ -7,17 +7,84 @@ setTimeout(function () { console.debug(ex); return; } + + let waiting = false; + connection.onmessage = function (message) { if (message.data === 'Reload') { console.debug('Server is ready. Reloading...'); location.reload(); } else if (message.data === 'Wait') { + if (waiting) { + return; + } + waiting = true; console.debug('File changes detected. Waiting for application to rebuild.'); - const t = document.title; const r = ['☱', '☲', '☴']; let i = 0; - setInterval(function () { document.title = r[i++ % r.length] + ' ' + t; }, 240); + const glyphs = ['☱', '☲', '☴']; + const title = document.title; + let i = 0; + setInterval(function () { document.title = glyphs[i++ % glyphs.length] + ' ' + title; }, 240); + } else { + const parsed = JSON.parse(message.data); + if (parsed.type == 'UpdateStaticFile') { + const path = parsed.path; + if (path && path.endsWith('.css')) { + updateCssByPath(path); + } else { + console.debug(`File change detected to css file ${path}. Reloading page...`); + location.reload(); + return; + } + } } } + connection.onerror = function (event) { console.debug('dotnet-watch reload socket error.', event) } connection.onclose = function () { console.debug('dotnet-watch reload socket closed.') } connection.onopen = function () { console.debug('dotnet-watch reload socket connected.') } + + function updateCssByPath(path) { + const styleElement = document.querySelector(`link[href^="${path}"]`) || + document.querySelector(`link[href^="${document.baseURI}${path}"]`); + + if (!styleElement || !styleElement.parentNode) { + console.debug('Unable to find a stylesheet to update. Updating all css.'); + updateAllLocalCss(); + } + + updateCssElement(styleElement); + } + + function updateAllLocalCss() { + [...document.querySelectorAll('link')] + .filter(l => l.baseURI === document.baseURI) + .forEach(e => updateCssElement(e)); + } + + function updateCssElement(styleElement) { + if (styleElement.loading) { + // A file change notification may be triggered for the same file before the browser + // finishes processing a previous update. In this case, it's easiest to ignore later updates + return; + } + + const newElement = styleElement.cloneNode(); + const href = styleElement.href; + newElement.href = href.split('?', 1)[0] + `?nonce=${Date.now()}`; + + styleElement.loading = true; + newElement.loading = true; + newElement.addEventListener('load', function () { + newElement.loading = false; + styleElement.remove(); + }); + + styleElement.parentNode.insertBefore(newElement, styleElement.nextSibling); + } + + function updateScopedCss() { + [...document.querySelectorAll('link')] + .filter(l => l.baseURI === document.baseURI && l.href && l.href.indexOf('.styles.css') !== -1) + .forEach(e => updateCssElement(e)); + } }, 500); diff --git a/src/BuiltInTools/DotNetWatchTasks/DotNetWatchTasks.csproj b/src/BuiltInTools/DotNetWatchTasks/DotNetWatchTasks.csproj new file mode 100644 index 000000000000..a60c731caaf9 --- /dev/null +++ b/src/BuiltInTools/DotNetWatchTasks/DotNetWatchTasks.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + + + + + + + + + + + + diff --git a/src/BuiltInTools/DotNetWatchTasks/FileSetSerializer.cs b/src/BuiltInTools/DotNetWatchTasks/FileSetSerializer.cs new file mode 100644 index 000000000000..d8669fa9ee3d --- /dev/null +++ b/src/BuiltInTools/DotNetWatchTasks/FileSetSerializer.cs @@ -0,0 +1,71 @@ +// 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.Runtime.Serialization.Json; +using System.Text; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.DotNet.Watcher.Internal; + +namespace DotNetWatchTasks +{ + public class FileSetSerializer : Task + { + public ITaskItem[] WatchFiles { get; set; } + + public bool IsNetCoreApp31OrNewer { get; set; } + + public ITaskItem OutputPath { get; set; } + + public string[] PackageIds { get; set; } + + public override bool Execute() + { + var projectItems = new Dictionary(StringComparer.OrdinalIgnoreCase); + var fileSetResult = new MSBuildFileSetResult + { + IsNetCoreApp31OrNewer = IsNetCoreApp31OrNewer, + Projects = projectItems + }; + + foreach (var item in WatchFiles) + { + var fullPath = item.GetMetadata("FullPath"); + var staticWebAssetPath = item.GetMetadata("StaticWebAssetPath"); + var projectFullPath = item.GetMetadata("ProjectFullPath"); + + if (!projectItems.TryGetValue(projectFullPath, out var project)) + { + projectItems[projectFullPath] = project = new ProjectItems(); + } + + if (string.IsNullOrEmpty(staticWebAssetPath)) + { + project.Files.Add(fullPath); + } + else + { + project.StaticFiles.Add(new StaticFileItem + { + FilePath = fullPath, + StaticWebAssetPath = staticWebAssetPath, + }); + } + } + + var serializer = new DataContractJsonSerializer(fileSetResult.GetType(), new DataContractJsonSerializerSettings + { + UseSimpleDictionaryFormat = true, + }); + + using var fileStream = File.Create(OutputPath.ItemSpec); + using var writer = JsonReaderWriterFactory.CreateJsonWriter(fileStream, Encoding.UTF8, ownsStream: false, indent: true); + serializer.WriteObject(writer, fileSetResult); + + return !Log.HasLoggedErrors; + } + } +} diff --git a/src/BuiltInTools/dotnet-watch/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/BrowserRefreshServer.cs index 7ff3952d28b2..2c7bc52bd73d 100644 --- a/src/BuiltInTools/dotnet-watch/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/BrowserRefreshServer.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using System.Net.WebSockets; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; @@ -20,6 +21,8 @@ namespace Microsoft.DotNet.Watcher.Tools { public class BrowserRefreshServer : IAsyncDisposable { + private readonly byte[] ReloadMessage = Encoding.UTF8.GetBytes("Reload"); + private readonly byte[] WaitMessage = Encoding.UTF8.GetBytes("Wait"); private readonly IReporter _reporter; private readonly TaskCompletionSource _taskCompletionSource; private IHost _refreshServer; @@ -73,7 +76,7 @@ private async Task WebSocketRequest(HttpContext context) await _taskCompletionSource.Task; } - public async Task SendMessage(byte[] messageBytes, CancellationToken cancellationToken = default) + public async ValueTask SendMessage(ReadOnlyMemory messageBytes, CancellationToken cancellationToken = default) { if (_webSocket == null || _webSocket.CloseStatus.HasValue) { @@ -105,5 +108,9 @@ public async ValueTask DisposeAsync() _taskCompletionSource.TrySetResult(); } + + public ValueTask ReloadAsync(CancellationToken cancellationToken) => SendMessage(ReloadMessage, cancellationToken); + + public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) => SendMessage(WaitMessage, cancellationToken); } } diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatch.targets b/src/BuiltInTools/dotnet-watch/DotNetWatch.targets index 26316b06e190..cd496d6968ba 100644 --- a/src/BuiltInTools/dotnet-watch/DotNetWatch.targets +++ b/src/BuiltInTools/dotnet-watch/DotNetWatch.targets @@ -7,6 +7,8 @@ Main target called by dotnet-watch. It gathers MSBuild items and writes them to a file. ========================================================================= --> + + @@ -17,14 +19,10 @@ them to a file. <_IsMicrosoftNETCoreApp31OrNewer Condition="'$(_IsMicrosoftNETCoreApp31OrNewer)' == ''">false - - <_WatchListLine Include="$(_IsMicrosoftNETCoreApp31OrNewer)" /> - <_WatchListLine Include="%(Watch.FullPath)" /> - - - + + + <_WatchProjects Include="%(ProjectReference.Identity)" Condition="'%(ProjectReference.Watch)' != 'false'" /> @@ -77,6 +90,7 @@ Returns: @(Watch) BuildInParallel="true"> + diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatchContext.cs b/src/BuiltInTools/dotnet-watch/DotNetWatchContext.cs index 16b5e453faf4..2fe09ada7d22 100644 --- a/src/BuiltInTools/dotnet-watch/DotNetWatchContext.cs +++ b/src/BuiltInTools/dotnet-watch/DotNetWatchContext.cs @@ -11,14 +11,16 @@ public class DotNetWatchContext public ProcessSpec ProcessSpec { get; set; } - public IFileSet FileSet { get; set; } + public FileSet FileSet { get; set; } public int Iteration { get; set; } - public string ChangedFile { get; set; } + public FileItem? ChangedFile { get; set; } public bool RequiresMSBuildRevaluation { get; set; } public bool SuppressMSBuildIncrementalism { get; set; } + + public BrowserRefreshServer BrowserRefreshServer { get; set; } } } diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatchOptions.cs b/src/BuiltInTools/dotnet-watch/DotNetWatchOptions.cs new file mode 100644 index 000000000000..043ab69da518 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/DotNetWatchOptions.cs @@ -0,0 +1,30 @@ +// 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; + +namespace Microsoft.DotNet.Watcher +{ + public record DotNetWatchOptions( + bool SuppressHandlingStaticContentFiles, + bool SuppressMSBuildIncrementalism, + bool SuppressLaunchBrowser, + bool SuppressBrowserRefresh, + bool RunningAsTest) + { + public static DotNetWatchOptions Default { get; } = new DotNetWatchOptions + ( + SuppressHandlingStaticContentFiles: IsEnvironmentSet("DOTNET_WATCH_SUPPRESS_STATIC_FILE_HANDLING"), + SuppressMSBuildIncrementalism: IsEnvironmentSet("DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM"), + SuppressLaunchBrowser: IsEnvironmentSet("DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"), + SuppressBrowserRefresh: IsEnvironmentSet("DOTNET_WATCH_SUPPRESS_BROWSER_REFRESH"), + RunningAsTest: IsEnvironmentSet("__DOTNET_WATCH_RUNNING_AS_TEST") + ); + + private static bool IsEnvironmentSet(string key) + { + var envValue = Environment.GetEnvironmentVariable(key); + return envValue == "1" || envValue == "true"; + } + } +} diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs index f8c00d71f659..d920d3da4ad6 100644 --- a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs @@ -2,8 +2,8 @@ // 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.Globalization; -using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -17,20 +17,24 @@ public class DotNetWatcher : IAsyncDisposable { private readonly IReporter _reporter; private readonly ProcessRunner _processRunner; + private readonly DotNetWatchOptions _dotnetWatchOptions; + private readonly FileChangeHandler _fileChangeHandler; private readonly IWatchFilter[] _filters; - public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory) + public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory, DotNetWatchOptions dotNetWatchOptions) { Ensure.NotNull(reporter, nameof(reporter)); _reporter = reporter; _processRunner = new ProcessRunner(reporter); + _dotnetWatchOptions = dotNetWatchOptions; + _fileChangeHandler = new FileChangeHandler(reporter); _filters = new IWatchFilter[] { new MSBuildEvaluationFilter(fileSetFactory), new NoRestoreFilter(), - new LaunchBrowserFilter(), + new LaunchBrowserFilter(dotNetWatchOptions), }; } @@ -43,13 +47,12 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella cancelledTaskSource); 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", + SuppressMSBuildIncrementalism = _dotnetWatchOptions.SuppressMSBuildIncrementalism, }; if (context.SuppressMSBuildIncrementalism) @@ -92,15 +95,31 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella currentRunCancellationSource.Token)) using (var fileSetWatcher = new FileSetWatcher(fileSet, _reporter)) { - var fileSetTask = fileSetWatcher.GetChangedFileAsync(combinedCancellationSource.Token); var processTask = _processRunner.RunAsync(processSpec, combinedCancellationSource.Token); - var args = string.Join(" ", processSpec.Arguments); _reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: {args}"); _reporter.Output("Started"); - var finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task); + Task fileSetTask; + Task finishedTask; + + while (true) + { + fileSetTask = fileSetWatcher.GetChangedFileAsync(combinedCancellationSource.Token); + finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task); + + if (finishedTask == fileSetTask + && fileSetTask.Result is FileItem fileItem && + await _fileChangeHandler.TryHandleFileAction(context, fileItem, combinedCancellationSource.Token)) + { + // We're able to handle the file change event without doing a full-rebuild. + } + else + { + break; + } + } // Regardless of the which task finished first, make sure everything is cancelled // and wait for dotnet to exit. We don't want orphan processes @@ -124,7 +143,6 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella return; } - context.ChangedFile = fileSetTask.Result; if (finishedTask == processTask) { // Process exited. Redo evaludation @@ -132,10 +150,12 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella // Now wait for a file to change before restarting process context.ChangedFile = await fileSetWatcher.GetChangedFileAsync(cancellationToken, () => _reporter.Warn("Waiting for a file to change before restarting dotnet...")); } - - if (!string.IsNullOrEmpty(fileSetTask.Result)) + else { - _reporter.Output($"File changed: {fileSetTask.Result}"); + Debug.Assert(finishedTask == fileSetTask); + var changedFile = fileSetTask.Result; + context.ChangedFile = changedFile; + _reporter.Output($"File changed: {changedFile.Value.FilePath}"); } } } diff --git a/src/BuiltInTools/dotnet-watch/FileChangeHandler.cs b/src/BuiltInTools/dotnet-watch/FileChangeHandler.cs new file mode 100644 index 000000000000..476f7f2993eb --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/FileChangeHandler.cs @@ -0,0 +1,50 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public class FileChangeHandler + { + private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + private readonly IReporter _reporter; + + public FileChangeHandler(IReporter reporter) + { + _reporter = reporter; + } + + internal async ValueTask TryHandleFileAction(DotNetWatchContext context, FileItem file, CancellationToken cancellationToken) + { + if (!file.IsStaticFile || context.BrowserRefreshServer is null) + { + return false; + } + + _reporter.Verbose($"Handling file change event for static content {file.FilePath}."); + await HandleBrowserRefresh(context.BrowserRefreshServer, file, cancellationToken); + return true; + } + + private static async Task HandleBrowserRefresh(BrowserRefreshServer browserRefreshServer, FileItem fileItem, CancellationToken cancellationToken) + { + var message = JsonSerializer.SerializeToUtf8Bytes(new UpdateStaticFileMessage { Path = fileItem.StaticWebAssetPath }, JsonSerializerOptions); + await browserRefreshServer.SendMessage(message, cancellationToken); + } + + private readonly struct UpdateStaticFileMessage + { + public string Type => "UpdateStaticFile"; + + public string Path { get; init; } + } + } +} diff --git a/src/BuiltInTools/dotnet-watch/FileItem.cs b/src/BuiltInTools/dotnet-watch/FileItem.cs new file mode 100644 index 000000000000..23e18ce3899c --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/FileItem.cs @@ -0,0 +1,16 @@ +// 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. + +namespace Microsoft.DotNet.Watcher +{ + public readonly struct FileItem + { + public string FilePath { get; init; } + + public string ProjectPath { get; init; } + + public bool IsStaticFile { get; init; } + + public string StaticWebAssetPath { get; init; } + } +} diff --git a/src/BuiltInTools/dotnet-watch/FileSet.cs b/src/BuiltInTools/dotnet-watch/FileSet.cs new file mode 100644 index 000000000000..28ef122a9a13 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/FileSet.cs @@ -0,0 +1,36 @@ +// 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; +using System.Collections.Generic; + +namespace Microsoft.DotNet.Watcher +{ + public class FileSet : IEnumerable + { + private readonly Dictionary _files; + + public FileSet(bool isNetCoreApp31OrNewer, IEnumerable files) + { + IsNetCoreApp31OrNewer = isNetCoreApp31OrNewer; + _files = new Dictionary(StringComparer.Ordinal); + foreach (var item in files) + { + _files[item.FilePath] = item; + } + } + + public bool TryGetValue(string filePath, out FileItem fileItem) => _files.TryGetValue(filePath, out fileItem); + + public int Count => _files.Count; + + public bool IsNetCoreApp31OrNewer { get; } + + public static readonly FileSet Empty = new FileSet(false, Array.Empty()); + + public IEnumerator GetEnumerator() => _files.Values.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/BuiltInTools/dotnet-watch/IFileSet.cs b/src/BuiltInTools/dotnet-watch/IFileSet.cs deleted file mode 100644 index 177dc6d00ee5..000000000000 --- a/src/BuiltInTools/dotnet-watch/IFileSet.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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.Collections.Generic; - -namespace Microsoft.DotNet.Watcher -{ - public interface IFileSet : IEnumerable - { - bool IsNetCoreApp31OrNewer { get; } - - bool Contains(string filePath); - } -} diff --git a/src/BuiltInTools/dotnet-watch/IFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/IFileSetFactory.cs index 6a70c06a4cd8..1cad758ec2b3 100644 --- a/src/BuiltInTools/dotnet-watch/IFileSetFactory.cs +++ b/src/BuiltInTools/dotnet-watch/IFileSetFactory.cs @@ -8,6 +8,6 @@ namespace Microsoft.DotNet.Watcher { public interface IFileSetFactory { - Task CreateAsync(CancellationToken cancellationToken); + Task CreateAsync(CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileSet.cs b/src/BuiltInTools/dotnet-watch/Internal/FileSet.cs deleted file mode 100644 index 3ca17027db51..000000000000 --- a/src/BuiltInTools/dotnet-watch/Internal/FileSet.cs +++ /dev/null @@ -1,31 +0,0 @@ -// 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; -using System.Collections.Generic; - -namespace Microsoft.DotNet.Watcher.Internal -{ - public class FileSet : IFileSet - { - private readonly HashSet _files; - - public FileSet(bool isNetCoreApp31OrNewer, IEnumerable files) - { - IsNetCoreApp31OrNewer = isNetCoreApp31OrNewer; - _files = new HashSet(files, StringComparer.OrdinalIgnoreCase); - } - - public bool Contains(string filePath) => _files.Contains(filePath); - - public int Count => _files.Count; - - public bool IsNetCoreApp31OrNewer { get; } - - public static IFileSet Empty = new FileSet(false, Array.Empty()); - - public IEnumerator GetEnumerator() => _files.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator(); - } -} diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileSetWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileSetWatcher.cs index 55ad0e4b10d7..b33cfe7d3ad9 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/FileSetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/FileSetWatcher.cs @@ -12,9 +12,9 @@ namespace Microsoft.DotNet.Watcher.Internal public class FileSetWatcher : IDisposable { private readonly FileWatcher _fileWatcher; - private readonly IFileSet _fileSet; + private readonly FileSet _fileSet; - public FileSetWatcher(IFileSet fileSet, IReporter reporter) + public FileSetWatcher(FileSet fileSet, IReporter reporter) { Ensure.NotNull(fileSet, nameof(fileSet)); @@ -22,23 +22,23 @@ public FileSetWatcher(IFileSet fileSet, IReporter reporter) _fileWatcher = new FileWatcher(reporter); } - public async Task GetChangedFileAsync(CancellationToken cancellationToken, Action startedWatching) + public async Task GetChangedFileAsync(CancellationToken cancellationToken, Action startedWatching) { foreach (var file in _fileSet) { - _fileWatcher.WatchDirectory(Path.GetDirectoryName(file)); + _fileWatcher.WatchDirectory(Path.GetDirectoryName(file.FilePath)); } - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(); cancellationToken.Register(() => tcs.TrySetResult(null)); - Action callback = path => + void callback(string path) { - if (_fileSet.Contains(path)) + if (_fileSet.TryGetValue(path, out var fileItem)) { - tcs.TrySetResult(path); + tcs.TrySetResult(fileItem); } - }; + } _fileWatcher.OnFileChange += callback; startedWatching(); @@ -48,10 +48,9 @@ public async Task GetChangedFileAsync(CancellationToken cancellationToke return changedFile; } - - public Task GetChangedFileAsync(CancellationToken cancellationToken) + public Task GetChangedFileAsync(CancellationToken cancellationToken) { - return GetChangedFileAsync(cancellationToken, () => {}); + return GetChangedFileAsync(cancellationToken, () => { }); } public void Dispose() diff --git a/src/BuiltInTools/dotnet-watch/Internal/MSBuildFileSetResult.cs b/src/BuiltInTools/dotnet-watch/Internal/MSBuildFileSetResult.cs new file mode 100644 index 000000000000..0d128b7d8fa9 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Internal/MSBuildFileSetResult.cs @@ -0,0 +1,28 @@ +// 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.Collections.Generic; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class MSBuildFileSetResult + { + public bool IsNetCoreApp31OrNewer { get; set; } + + public Dictionary Projects { get; set; } + } + + public class ProjectItems + { + public List Files { get; set; } = new(); + + public List StaticFiles { get; set; } = new(); + } + + public class StaticFileItem + { + public string FilePath { get; set; } + + public string StaticWebAssetPath { get; set; } + } +} diff --git a/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs index 189857065524..de77228028ab 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.DotNet.Cli.Utils; @@ -21,6 +22,7 @@ public class MsBuildFileSetFactory : IFileSetFactory private readonly string _muxerPath; private readonly IReporter _reporter; + private readonly DotNetWatchOptions _dotNetWatchOptions; private readonly string _projectFile; private readonly OutputSink _outputSink; private readonly ProcessRunner _processRunner; @@ -29,15 +31,17 @@ public class MsBuildFileSetFactory : IFileSetFactory public MsBuildFileSetFactory( IReporter reporter, + DotNetWatchOptions dotNetWatchOptions, string projectFile, bool waitOnError, bool trace) - : this(new Muxer().MuxerPath, reporter, projectFile, new OutputSink(), waitOnError, trace) + : this(dotNetWatchOptions, new Muxer().MuxerPath, reporter, projectFile, new OutputSink(), waitOnError, trace) { } // output sink is for testing internal MsBuildFileSetFactory( + DotNetWatchOptions dotNetWatchOptions, string muxerPath, IReporter reporter, string projectFile, @@ -51,14 +55,16 @@ internal MsBuildFileSetFactory( _muxerPath = muxerPath; _reporter = reporter; + _dotNetWatchOptions = dotNetWatchOptions; _projectFile = projectFile; _outputSink = outputSink; _processRunner = new ProcessRunner(reporter); _buildFlags = InitializeArgs(FindTargetsFile(), trace); + _waitOnError = waitOnError; } - public async Task CreateAsync(CancellationToken cancellationToken) + public async Task CreateAsync(CancellationToken cancellationToken) { var watchList = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); try @@ -70,17 +76,26 @@ public async Task CreateAsync(CancellationToken cancellationToken) cancellationToken.ThrowIfCancellationRequested(); var capture = _outputSink.StartCapture(); + var arguments = new List + { + "msbuild", + "/nologo", + _projectFile, + $"/p:_DotNetWatchListFile={watchList}", + }; + + if (_dotNetWatchOptions.SuppressHandlingStaticContentFiles) + { + arguments.Add("/p:DotNetWatchContentFiles=false"); + } + + arguments.AddRange(_buildFlags); + var processSpec = new ProcessSpec { Executable = _muxerPath, WorkingDirectory = projectDir, - Arguments = new[] - { - "msbuild", - "/nologo", - _projectFile, - $"/p:_DotNetWatchListFile={watchList}", - }.Concat(_buildFlags), + Arguments = arguments, OutputCapture = capture }; @@ -90,27 +105,51 @@ public async Task CreateAsync(CancellationToken cancellationToken) if (exitCode == 0 && File.Exists(watchList)) { - var lines = File.ReadAllLines(watchList); - var isNetCoreApp31OrNewer = lines.FirstOrDefault() == "true"; + using var watchFile = File.OpenRead(watchList); + var result = await JsonSerializer.DeserializeAsync(watchFile, cancellationToken: cancellationToken); + + var fileItems = new List(); + foreach (var project in result.Projects) + { + var value = project.Value; + var fileCount = value.Files.Count; + + for (var i = 0; i < fileCount; i++) + { + fileItems.Add(new FileItem + { + FilePath = value.Files[i], + ProjectPath = project.Key, + }); + } + + var staticItemsCount = value.StaticFiles.Count; + for (var i = 0; i < staticItemsCount; i++) + { + var item = value.StaticFiles[i]; + fileItems.Add(new FileItem + { + FilePath = item.FilePath, + ProjectPath = project.Key, + IsStaticFile = true, + StaticWebAssetPath = item.StaticWebAssetPath, + }); + } + } - var fileset = new FileSet( - isNetCoreApp31OrNewer, - lines.Skip(1) - .Select(l => l?.Trim()) - .Where(l => !string.IsNullOrEmpty(l))); - _reporter.Verbose($"Watching {fileset.Count} file(s) for changes"); + _reporter.Verbose($"Watching {fileItems.Count} file(s) for changes"); #if DEBUG - foreach (var file in fileset) + foreach (var file in fileItems) { - _reporter.Verbose($" -> {file}"); + _reporter.Verbose($" -> {file.FilePath} {(file.IsStaticFile ? file.StaticWebAssetPath : null)}"); } - Debug.Assert(fileset.All(Path.IsPathRooted), "All files should be rooted paths"); + Debug.Assert(fileItems.All(f => Path.IsPathRooted(f.FilePath)), "All files should be rooted paths"); #endif - return fileset; + return new FileSet(result.IsNetCoreApp31OrNewer, fileItems); } _reporter.Error($"Error(s) finding watch items project file '{Path.GetFileName(_projectFile)}'"); @@ -133,7 +172,7 @@ public async Task CreateAsync(CancellationToken cancellationToken) { _reporter.Warn("Fix the error to continue or press Ctrl+C to exit."); - var fileSet = new FileSet(false, new[] { _projectFile }); + var fileSet = new FileSet(false, new[] { new FileItem { FilePath = _projectFile } }); using (var watcher = new FileSetWatcher(fileSet, _reporter)) { @@ -180,6 +219,8 @@ private string FindTargetsFile() var assemblyDir = Path.GetDirectoryName(typeof(MsBuildFileSetFactory).Assembly.Location); var searchPaths = new[] { + Path.Combine(AppContext.BaseDirectory, "assets"), + Path.Combine(assemblyDir, "assets"), AppContext.BaseDirectory, assemblyDir, }; diff --git a/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs b/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs index 713a40bd4435..ee9d31497587 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs @@ -67,7 +67,7 @@ public async Task RunAsync(ProcessSpec processSpec, CancellationToken cance stopwatch.Start(); process.Start(); - _reporter.Verbose($"Started '{processSpec.Executable}' with process id {process.Id}"); + _reporter.Verbose($"Started '{processSpec.Executable}' '{process.StartInfo.Arguments}' with process id {process.Id}"); if (readOutput) { diff --git a/src/BuiltInTools/dotnet-watch/LaunchBrowserFilter.cs b/src/BuiltInTools/dotnet-watch/LaunchBrowserFilter.cs index 2d6a83af6255..ef1648ad540f 100644 --- a/src/BuiltInTools/dotnet-watch/LaunchBrowserFilter.cs +++ b/src/BuiltInTools/dotnet-watch/LaunchBrowserFilter.cs @@ -5,8 +5,6 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Runtime.InteropServices; -using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; @@ -17,8 +15,6 @@ namespace Microsoft.DotNet.Watcher.Tools { public sealed class LaunchBrowserFilter : IWatchFilter, IAsyncDisposable { - private readonly byte[] ReloadMessage = Encoding.UTF8.GetBytes("Reload"); - private readonly byte[] WaitMessage = Encoding.UTF8.GetBytes("Wait"); private static readonly Regex NowListeningRegex = new Regex(@"^\s*Now listening on: (?.*)$", RegexOptions.None | RegexOptions.Compiled, TimeSpan.FromSeconds(10)); private readonly bool _runningInTest; private readonly bool _suppressLaunchBrowser; @@ -32,15 +28,12 @@ public sealed class LaunchBrowserFilter : IWatchFilter, IAsyncDisposable private string _launchPath; private CancellationToken _cancellationToken; - public LaunchBrowserFilter() + public LaunchBrowserFilter(DotNetWatchOptions dotNetWatchOptions) { - var suppressLaunchBrowser = Environment.GetEnvironmentVariable("DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"); - _suppressLaunchBrowser = (suppressLaunchBrowser == "1" || suppressLaunchBrowser == "true"); + _suppressLaunchBrowser = dotNetWatchOptions.SuppressLaunchBrowser; + _suppressBrowserRefresh = dotNetWatchOptions.SuppressBrowserRefresh; + _runningInTest = dotNetWatchOptions.RunningAsTest; - var suppressBrowserRefresh = Environment.GetEnvironmentVariable("DOTNET_WATCH_SUPPRESS_BROWSER_REFRESH"); - _suppressBrowserRefresh = (suppressBrowserRefresh == "1" || suppressBrowserRefresh == "true"); - - _runningInTest = Environment.GetEnvironmentVariable("__DOTNET_WATCH_RUNNING_AS_TEST") == "true"; _browserPath = Environment.GetEnvironmentVariable("DOTNET_WATCH_BROWSER_PATH"); } @@ -68,6 +61,7 @@ public async ValueTask ProcessAsync(DotNetWatchContext context, CancellationToke if (!_suppressBrowserRefresh) { _refreshServer = new BrowserRefreshServer(context.Reporter); + context.BrowserRefreshServer = _refreshServer; var serverUrl = await _refreshServer.StartAsync(cancellationToken); context.Reporter.Verbose($"Refresh server running at {serverUrl}."); @@ -82,20 +76,10 @@ public async ValueTask ProcessAsync(DotNetWatchContext context, CancellationToke else if (!_suppressBrowserRefresh) { // We've detected a change. Notify the browser. - await SendMessage(WaitMessage, cancellationToken); + await (_refreshServer?.SendWaitMessageAsync(cancellationToken) ?? default); } } - private Task SendMessage(byte[] message, CancellationToken cancellationToken) - { - if (_refreshServer is null) - { - return Task.CompletedTask; - } - - return _refreshServer.SendMessage(message, cancellationToken); - } - private void OnOutput(object sender, DataReceivedEventArgs eventArgs) { if (string.IsNullOrEmpty(eventArgs.Data)) @@ -140,7 +124,7 @@ private void OnOutput(object sender, DataReceivedEventArgs eventArgs) else { _reporter.Verbose("Reloading browser."); - _ = SendMessage(ReloadMessage, _cancellationToken); + _ = _refreshServer?.ReloadAsync(_cancellationToken); } } } @@ -235,4 +219,4 @@ public async ValueTask DisposeAsync() } } } -} \ No newline at end of file +} diff --git a/src/BuiltInTools/dotnet-watch/MSBuildEvaluationFilter.cs b/src/BuiltInTools/dotnet-watch/MSBuildEvaluationFilter.cs index 82cda1dea62c..46d3c025fec4 100644 --- a/src/BuiltInTools/dotnet-watch/MSBuildEvaluationFilter.cs +++ b/src/BuiltInTools/dotnet-watch/MSBuildEvaluationFilter.cs @@ -56,7 +56,7 @@ public async ValueTask ProcessAsync(DotNetWatchContext context, CancellationToke private bool RequiresMSBuildRevaluation(DotNetWatchContext context) { var changedFile = context.ChangedFile; - if (!string.IsNullOrEmpty(changedFile) && IsMsBuildFileExtension(changedFile)) + if (changedFile != null && IsMsBuildFileExtension(changedFile.Value.FilePath)) { return true; } @@ -85,9 +85,9 @@ private bool RequiresMSBuildRevaluation(DotNetWatchContext context) var msbuildFiles = new List<(string fileName, DateTime lastModifiedUtc)>(); foreach (var file in context.FileSet) { - if (!string.IsNullOrEmpty(file) && IsMsBuildFileExtension(file)) + if (!string.IsNullOrEmpty(file.FilePath) && IsMsBuildFileExtension(file.FilePath)) { - msbuildFiles.Add((file, GetLastWriteTimeUtcSafely(file))); + msbuildFiles.Add((file.FilePath, GetLastWriteTimeUtcSafely(file.FilePath))); } } diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index 5d89f72c357e..5d0d4c732871 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.cs @@ -223,7 +223,10 @@ private async Task MainInternalAsync( return 1; } + var watchOptions = DotNetWatchOptions.Default; + var fileSetFactory = new MsBuildFileSetFactory(reporter, + watchOptions, projectFile, waitOnError: true, trace: false); @@ -243,7 +246,7 @@ private async Task MainInternalAsync( _reporter.Output("Polling file watcher is enabled"); } - await using var watcher = new DotNetWatcher(reporter, fileSetFactory); + await using var watcher = new DotNetWatcher(reporter, fileSetFactory, watchOptions); await watcher.WatchAsync(processInfo, cancellationToken); return 0; @@ -266,7 +269,9 @@ private async Task ListFilesAsync( return 1; } - var fileSetFactory = new MsBuildFileSetFactory(reporter, + var fileSetFactory = new MsBuildFileSetFactory( + reporter, + DotNetWatchOptions.Default, projectFile, waitOnError: false, trace: false); @@ -279,7 +284,7 @@ private async Task ListFilesAsync( foreach (var file in files) { - _console.Out.WriteLine(file); + _console.Out.WriteLine(file.FilePath); } return 0; diff --git a/src/BuiltInTools/dotnet-watch/README.md b/src/BuiltInTools/dotnet-watch/README.md index 08c8786fc2ee..922d5128c67b 100644 --- a/src/BuiltInTools/dotnet-watch/README.md +++ b/src/BuiltInTools/dotnet-watch/README.md @@ -32,11 +32,13 @@ Some configuration options can be passed to `dotnet watch` through environment v | 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. | | DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER | `dotnet watch run` will attempt to launch browsers for web apps with `launchBrowser` configured in `launchSettings.json`. If set to "1" or "true", this behavior is suppressed. | | DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM | `dotnet watch run` will attempt to refresh browsers when it detects file changes. If set to "1" or "true", this behavior is suppressed. This behavior is also suppressed if DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER is set. | +| DOTNET_WATCH_SUPPRESS_STATIC_FILE_HANDLING | If set to "1", or "true", `dotnet watch` will not perform special handling for static content file + ### MSBuild dotnet-watch can be configured from the MSBuild project file being watched. -#### Watch items +**Watch items** dotnet-watch will watch all items in the **Watch** item group. By default, this group inclues all items in **Compile** and **EmbeddedResource**. @@ -76,7 +78,7 @@ dotnet-watch will ignore project references with the `Watch="false"` attribute. ``` -#### Advanced configuration +**Advanced configuration** dotnet-watch performs a design-time build to find items to watch. When this build is run, dotnet-watch will set the property `DotNetWatchBuild=true`. @@ -88,6 +90,7 @@ Example: ``` + ## Contribution Follow the contribution steps for the dotnet SDK: /documentation/project-docs/developer-guide.md. If developing from Visual Studio, open the dotnet-watch.slnf. diff --git a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj index 7db469703cde..2f9220d30eac 100644 --- a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj +++ b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj @@ -27,14 +27,13 @@ ReferenceOutputAssembly="false" SkipGetTargetFrameworkProperties="true" UndefineProperties="TargetFramework;TargetFrameworks" /> - - - - - - + + diff --git a/src/BuiltInTools/dotnet-watch/dotnet-watch.slnf b/src/BuiltInTools/dotnet-watch/dotnet-watch.slnf index 9a2e422ae688..d5e3affaeda2 100644 --- a/src/BuiltInTools/dotnet-watch/dotnet-watch.slnf +++ b/src/BuiltInTools/dotnet-watch/dotnet-watch.slnf @@ -5,6 +5,7 @@ "src\\Tests\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj", "src\\Tests\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj", "src\\Tests\\dotnet-watch.Tests\\dotnet-watch.Tests.csproj", + "src\\Cli\\Microsoft.DotNet.Cli.Utils\\Microsoft.DotNet.Cli.Utils.csproj", "src\\BuiltInTools\\BrowserRefresh\\Microsoft.AspNetCore.Watch.BrowserRefresh.csproj", "src\\BuiltInTools\\dotnet-watch\\dotnet-watch.csproj" ] diff --git a/src/Layout/redist/targets/GenerateLayout.targets b/src/Layout/redist/targets/GenerateLayout.targets index 5d3881b61ee6..20b3aac0186c 100644 --- a/src/Layout/redist/targets/GenerateLayout.targets +++ b/src/Layout/redist/targets/GenerateLayout.targets @@ -129,6 +129,7 @@ + + (NullReporter.Singleton); + byte[] writtenBytes = null; + server.Setup(s => s.SendMessage(It.IsAny(), It.IsAny())) + .Callback((byte[] bytes, CancellationToken cts) => + { + writtenBytes = bytes; + }); + var fileContentHandler = new FileChangeHandler(NullReporter.Singleton); + var context = new DotNetWatchContext + { + BrowserRefreshServer = server.Object, + }; + var file = new FileItem { FilePath = "Test.css", IsStaticFile = true, StaticWebAssetPath = "content/Test.css" }; + + // Act + var result = await fileContentHandler.TryHandleFileAction(context, file, default); + + // Assert + Assert.True(result); + Assert.NotNull(writtenBytes); + var deserialized = JsonSerializer.Deserialize(writtenBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + Assert.Equal("UpdateStaticFile", deserialized.Type); + Assert.Equal("content/Test.css", deserialized.Path); + } + + [Fact] + public async ValueTask TryHandleFileAction_CausesBrowserRefreshForNonCssFile() + { + // Arrange + var server = new Mock(NullReporter.Singleton); + byte[] writtenBytes = null; + server.Setup(s => s.SendMessage(It.IsAny(), It.IsAny())) + .Callback((byte[] bytes, CancellationToken cts) => + { + writtenBytes = bytes; + }); + var fileContentHandler = new FileChangeHandler(NullReporter.Singleton); + var context = new DotNetWatchContext + { + BrowserRefreshServer = server.Object, + }; + var file = new FileItem { FilePath = "Test.js", IsStaticFile = true, StaticWebAssetPath = "Test.js" }; + + // Act + var result = await fileContentHandler.TryHandleFileAction(context, file, default); + + // Assert + Assert.True(result); + Assert.NotNull(writtenBytes); + var deserialized = JsonSerializer.Deserialize(writtenBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + Assert.Equal("UpdateStaticFile", deserialized.Type); + Assert.Equal("content/Test.js", deserialized.Path); + } + + private record UpdateStaticFileMessage(string Type, string Path); + + } +} diff --git a/src/Tests/dotnet-watch.Tests/MSBuildEvaluationFilterTest.cs b/src/Tests/dotnet-watch.Tests/MSBuildEvaluationFilterTest.cs index 5933b74556f3..7f8338ded426 100644 --- a/src/Tests/dotnet-watch.Tests/MSBuildEvaluationFilterTest.cs +++ b/src/Tests/dotnet-watch.Tests/MSBuildEvaluationFilterTest.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Microsoft.DotNet.Watcher.Internal; using Moq; using Xunit; @@ -14,7 +13,7 @@ namespace Microsoft.DotNet.Watcher.Tools public class MSBuildEvaluationFilterTest { private readonly IFileSetFactory _fileSetFactory = Mock.Of( - f => f.CreateAsync(It.IsAny()) == Task.FromResult(FileSet.Empty)); + f => f.CreateAsync(It.IsAny()) == Task.FromResult(FileSet.Empty)); [Fact] public async Task ProcessAsync_EvaluatesFileSetIfProjFileChanges() @@ -29,7 +28,7 @@ public async Task ProcessAsync_EvaluatesFileSetIfProjFileChanges() await filter.ProcessAsync(context, default); context.Iteration++; - context.ChangedFile = "Test.csproj"; + context.ChangedFile = new FileItem { FilePath = "Test.csproj" }; context.RequiresMSBuildRevaluation = false; // Act @@ -52,7 +51,7 @@ public async Task ProcessAsync_DoesNotEvaluateFileSetIfNonProjFileChanges() await filter.ProcessAsync(context, default); context.Iteration++; - context.ChangedFile = "Controller.cs"; + context.ChangedFile = new FileItem { FilePath = "Controller.cs" }; context.RequiresMSBuildRevaluation = false; // Act @@ -77,7 +76,7 @@ public async Task ProcessAsync_EvaluateFileSetOnEveryChangeIfOptimizationIsSuppr await filter.ProcessAsync(context, default); context.Iteration++; - context.ChangedFile = "Controller.cs"; + context.ChangedFile = new FileItem { FilePath = "Controller.cs" }; context.RequiresMSBuildRevaluation = false; // Act @@ -96,8 +95,8 @@ public async Task ProcessAsync_SetsEvaluationRequired_IfMSBuildFileChanges_ButIs // concurrent edits. MSBuildEvaluationFilter uses timestamps to additionally track changes to these files. // Arrange - var fileSet = new FileSet(false, new[] { "Controlller.cs", "Proj.csproj" }); - var fileSetFactory = Mock.Of(f => f.CreateAsync(It.IsAny()) == Task.FromResult(fileSet)); + var fileSet = new FileSet(false, new[] { new FileItem { FilePath = "Controlller.cs" }, new FileItem { FilePath = "Proj.csproj" } }); + var fileSetFactory = Mock.Of(f => f.CreateAsync(It.IsAny()) == Task.FromResult(fileSet)); var filter = new TestableMSBuildEvaluationFilter(fileSetFactory) { @@ -114,7 +113,7 @@ public async Task ProcessAsync_SetsEvaluationRequired_IfMSBuildFileChanges_ButIs await filter.ProcessAsync(context, default); context.RequiresMSBuildRevaluation = false; - context.ChangedFile = "Controller.cs"; + context.ChangedFile = new FileItem { FilePath = "Controller.cs" }; context.Iteration++; filter.Timestamps["Proj.csproj"] = new DateTime(1007); diff --git a/src/Tests/dotnet-watch.Tests/MsBuildFileSetFactoryTest.cs b/src/Tests/dotnet-watch.Tests/MsBuildFileSetFactoryTest.cs index 801af97ad67e..09da85040348 100644 --- a/src/Tests/dotnet-watch.Tests/MsBuildFileSetFactoryTest.cs +++ b/src/Tests/dotnet-watch.Tests/MsBuildFileSetFactoryTest.cs @@ -165,6 +165,83 @@ public async Task MultiTfm() ); } + [Fact] + public async Task IncludesContentFiles() + { + var testDir = _testAssets.CreateTestDirectory(); + + var project = WriteFile(testDir, Path.Combine("Project1.csproj"), +@" + + netstandard2.1 + +"); + WriteFile(testDir, Path.Combine("Program.cs")); + + WriteFile(testDir, Path.Combine("wwwroot", "css", "app.css")); + WriteFile(testDir, Path.Combine("wwwroot", "js", "site.js")); + WriteFile(testDir, Path.Combine("wwwroot", "favicon.ico")); + + var fileset = await GetFileSet(project); + + AssertEx.EqualFileList( + testDir.Path, + new[] + { + "Project1.csproj", + "Program.cs", + "wwwroot/css/app.css", + "wwwroot/js/site.js", + "wwwroot/favicon.ico", + }, + fileset + ); + } + + [Fact] + public async Task IncludesContentFilesFromRCL() + { + var testDir = _testAssets.CreateTestDirectory(); + WriteFile(testDir, Path.Combine("RCL1", "RCL1.csproj"), +@" + + netcoreapp5.0 + + +"); + WriteFile(testDir, Path.Combine("RCL1", "wwwroot", "css", "app.css")); + WriteFile(testDir, Path.Combine("RCL1", "wwwroot", "js", "site.js")); + WriteFile(testDir, Path.Combine("RCL1", "wwwroot", "favicon.ico")); + + var projectPath = WriteFile(testDir, Path.Combine("Project1", "Project1.csproj"), +@" + + netstandard2.1 + + + + +"); + WriteFile(testDir, Path.Combine("Project1", "Program.cs")); + + + var fileset = await GetFileSet(projectPath); + + AssertEx.EqualFileList( + testDir.Path, + new[] + { + "Project1/Project1.csproj", + "Project1/Program.cs", + "RCL1/RCL1.csproj", + "RCL1/wwwroot/css/app.css", + "RCL1/wwwroot/js/site.js", + "RCL1/wwwroot/favicon.ico", + }, + fileset + ); + } + [Fact] public async Task ProjectReferences_OneLevel() { @@ -229,6 +306,8 @@ public async Task TransitiveProjectReferences_TwoLevels() }, fileset ); + + Assert.All(fileset, f => Assert.False(f.IsStaticFile, $"File {f.FilePath} should not be a static file.")); } [Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/29213")] @@ -248,7 +327,8 @@ public async Task ProjectReferences_Graph() var projectA = Path.Combine(testDirectory, "A", "A.csproj"); var output = new OutputSink(); - var filesetFactory = new MsBuildFileSetFactory(DotNetHostPath, _reporter, projectA, output, waitOnError: false, trace: true); + var options = GetWatchOptions(); + var filesetFactory = new MsBuildFileSetFactory(options, DotNetHostPath, _reporter, projectA, output, waitOnError: false, trace: true); var fileset = await GetFileSet(filesetFactory); @@ -274,26 +354,46 @@ public async Task ProjectReferences_Graph() ); } - private Task GetFileSet(TestAsset target) + private Task GetFileSet(TestAsset target) + { + var projectPath = GetTestProjectPath(target); + return GetFileSet(projectPath); + } + + private Task GetFileSet(string projectPath) { - string projectPath = GetTestProjectPath(target); - return GetFileSet(new MsBuildFileSetFactory(DotNetHostPath, _reporter, projectPath, new OutputSink(), waitOnError: false, trace: false)); + DotNetWatchOptions options = GetWatchOptions(); + return GetFileSet(new MsBuildFileSetFactory(options, DotNetHostPath, _reporter, projectPath, new OutputSink(), waitOnError: false, trace: false)); } + private static DotNetWatchOptions GetWatchOptions() => + new DotNetWatchOptions(false, false, false, false, false); + private static string GetTestProjectPath(TestAsset target) => Path.Combine(GetTestProjectDirectory(target), target.TestProject.Name + ".csproj"); - private async Task GetFileSet(MsBuildFileSetFactory filesetFactory) + private async Task GetFileSet(MsBuildFileSetFactory filesetFactory) { return await filesetFactory .CreateAsync(CancellationToken.None) .TimeoutAfter(TimeSpan.FromSeconds(30)); } - private static void WriteFile(TestAsset testAsset, string name, string contents = "") + private static string WriteFile(TestAsset testAsset, string name, string contents = "") { var path = Path.Combine(GetTestProjectDirectory(testAsset), name); Directory.CreateDirectory(Path.GetDirectoryName(path)); File.WriteAllText(path, contents); + + return path; + } + + private static string WriteFile(TestDirectory testAsset, string name, string contents = "") + { + var path = Path.Combine(testAsset.Path, name); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, contents); + + return path; } private static string GetTestProjectDirectory(TestAsset testAsset) diff --git a/src/Tests/dotnet-watch.Tests/NoRestoreFilterTest.cs b/src/Tests/dotnet-watch.Tests/NoRestoreFilterTest.cs index 067671570791..0d3a9eff4e3a 100644 --- a/src/Tests/dotnet-watch.Tests/NoRestoreFilterTest.cs +++ b/src/Tests/dotnet-watch.Tests/NoRestoreFilterTest.cs @@ -47,7 +47,7 @@ public async Task ProcessAsync_LeavesArgumentsUnchangedIfMsBuildRevaluationIsReq }; await filter.ProcessAsync(context, default); - context.ChangedFile = "Test.proj"; + context.ChangedFile = new FileItem { FilePath = "Test.proj" }; context.RequiresMSBuildRevaluation = true; context.Iteration++; @@ -75,7 +75,7 @@ public async Task ProcessAsync_LeavesArgumentsUnchangedIfOptimizationIsSuppresse }; await filter.ProcessAsync(context, default); - context.ChangedFile = "Program.cs"; + context.ChangedFile = new FileItem { FilePath = "Program.cs" }; context.Iteration++; // Act @@ -101,7 +101,7 @@ public async Task ProcessAsync_AddsNoRestoreSwitch() }; await filter.ProcessAsync(context, default); - context.ChangedFile = "Program.cs"; + context.ChangedFile = new FileItem { FilePath = "Program.cs" }; context.Iteration++; // Act @@ -127,7 +127,7 @@ public async Task ProcessAsync_AddsNoRestoreSwitch_WithAdditionalArguments() }; await filter.ProcessAsync(context, default); - context.ChangedFile = "Program.cs"; + context.ChangedFile = new FileItem { FilePath = "Program.cs" }; context.Iteration++; // Act @@ -153,7 +153,7 @@ public async Task ProcessAsync_AddsNoRestoreSwitch_ForTestCommand() }; await filter.ProcessAsync(context, default); - context.ChangedFile = "Program.cs"; + context.ChangedFile = new FileItem { FilePath = "Program.cs" }; context.Iteration++; // Act @@ -180,7 +180,7 @@ public async Task ProcessAsync_DoesNotModifyArgumentsForUnknownCommands() }; await filter.ProcessAsync(context, default); - context.ChangedFile = "Program.cs"; + context.ChangedFile = new FileItem { FilePath = "Program.cs" }; context.Iteration++; // Act diff --git a/src/Tests/dotnet-watch.Tests/Utilities/AssertEx.cs b/src/Tests/dotnet-watch.Tests/Utilities/AssertEx.cs index 4b48224ba30a..191a510a467c 100644 --- a/src/Tests/dotnet-watch.Tests/Utilities/AssertEx.cs +++ b/src/Tests/dotnet-watch.Tests/Utilities/AssertEx.cs @@ -8,8 +8,11 @@ namespace Microsoft.DotNet.Watcher.Tools { - internal static class AssertEx + public static class AssertEx { + public static void EqualFileList(string root, IEnumerable expectedFiles, FileSet actualFiles) + => EqualFileList(root, expectedFiles, actualFiles.Select(f => f.FilePath)); + public static void EqualFileList(string root, IEnumerable expectedFiles, IEnumerable actualFiles) { var expected = expectedFiles.Select(p => Path.Combine(root, p)); @@ -24,10 +27,41 @@ public static void EqualFileList(IEnumerable expectedFiles, IEnumerable< if (!expected.SetEquals(actual)) { throw new AssertActualExpectedException( - expected: "\n" + string.Join("\n", expected), - actual: "\n" + string.Join("\n", actual), + expected: "\n" + string.Join("\n", expected.OrderBy(p => p)), + actual: "\n" + string.Join("\n", actual.OrderBy(p => p)), userMessage: "File sets should be equal"); } } + + public static void EqualFileList(FileSet expectedFiles, FileSet actualFiles) + { + if (expectedFiles.Count != actualFiles.Count) + { + throw new AssertCollectionCountException(expectedFiles.Count, actualFiles.Count); + } + + foreach (var expected in expectedFiles) + { + var actual = actualFiles.FirstOrDefault(f => Normalize(expected.FilePath) == Normalize(f.FilePath)); + + if (actual.FilePath is null) + { + throw new AssertActualExpectedException( + expected: $"Expected to find {expected.FilePath}.", + actual: "\n" + string.Join("\n", actualFiles.Select(f => f.FilePath)), + userMessage: "File sets should be equal."); + } + + if (expected.IsStaticFile != actual.IsStaticFile || expected.StaticWebAssetPath != actual.StaticWebAssetPath) + { + throw new AssertActualExpectedException( + expected: $"FileKind: {expected.IsStaticFile} StaticWebAssetPath {expected.StaticWebAssetPath}", + actual: $"FileKind: {actual.IsStaticFile} StaticWebAssetPath {actual.StaticWebAssetPath}", + userMessage: "Flle sets should be equal."); + } + } + + static string Normalize(string file) => file.Replace('\\', '/'); + } } } diff --git a/src/Tests/dotnet-watch.Tests/dotnet-watch.Tests.csproj b/src/Tests/dotnet-watch.Tests/dotnet-watch.Tests.csproj index 46b1cfa8162d..c126799ec11f 100644 --- a/src/Tests/dotnet-watch.Tests/dotnet-watch.Tests.csproj +++ b/src/Tests/dotnet-watch.Tests/dotnet-watch.Tests.csproj @@ -7,6 +7,7 @@ +