diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatchOptions.cs b/src/BuiltInTools/dotnet-watch/DotNetWatchOptions.cs index 043ab69da518..4785a369f9bf 100644 --- a/src/BuiltInTools/dotnet-watch/DotNetWatchOptions.cs +++ b/src/BuiltInTools/dotnet-watch/DotNetWatchOptions.cs @@ -10,6 +10,7 @@ public record DotNetWatchOptions( bool SuppressMSBuildIncrementalism, bool SuppressLaunchBrowser, bool SuppressBrowserRefresh, + bool SuppressEmojis, bool RunningAsTest) { public static DotNetWatchOptions Default { get; } = new DotNetWatchOptions @@ -18,6 +19,7 @@ public record DotNetWatchOptions( SuppressMSBuildIncrementalism: IsEnvironmentSet("DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM"), SuppressLaunchBrowser: IsEnvironmentSet("DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"), SuppressBrowserRefresh: IsEnvironmentSet("DOTNET_WATCH_SUPPRESS_BROWSER_REFRESH"), + SuppressEmojis: IsEnvironmentSet("DOTNET_WATCH_SUPPRESS_EMOJIS"), RunningAsTest: IsEnvironmentSet("__DOTNET_WATCH_RUNNING_AS_TEST") ); diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs index 25f00a83490d..638af6d0ee3b 100644 --- a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/DotNetWatcher.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; @@ -93,7 +93,7 @@ public async Task WatchAsync(DotNetWatchContext context, CancellationToken cance var args = string.Join(" ", processSpec.Arguments); _reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: {args}"); - _reporter.Output("Started"); + _reporter.Output("Started", emoji: "🚀"); Task fileSetTask; Task finishedTask; @@ -141,7 +141,7 @@ await _staticFileHandler.TryHandleFileChange(context, fileItem, combinedCancella // Process exited. Redo evaludation context.RequiresMSBuildRevaluation = true; // 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...")); + context.ChangedFile = await fileSetWatcher.GetChangedFileAsync(cancellationToken, () => _reporter.Warn("Waiting for a file to change before restarting dotnet...", emoji: "⏳")); } else { diff --git a/src/BuiltInTools/dotnet-watch/Filters/DotNetBuildFilter.cs b/src/BuiltInTools/dotnet-watch/Filters/DotNetBuildFilter.cs index d4d4a5999cc6..f6755b0ca3d8 100644 --- a/src/BuiltInTools/dotnet-watch/Filters/DotNetBuildFilter.cs +++ b/src/BuiltInTools/dotnet-watch/Filters/DotNetBuildFilter.cs @@ -38,7 +38,7 @@ public async ValueTask ProcessAsync(DotNetWatchContext context, CancellationToke WorkingDirectory = context.ProcessSpec.WorkingDirectory, }; - _reporter.Output("Building..."); + _reporter.Output("Building...", emoji: "🔧"); var exitCode = await _processRunner.RunAsync(processSpec, cancellationToken); context.FileSet = await _fileSetFactory.CreateAsync(cancellationToken); if (exitCode == 0) @@ -48,7 +48,7 @@ public async ValueTask ProcessAsync(DotNetWatchContext context, CancellationToke // If the build fails, we'll retry until we have a successful build. using var fileSetWatcher = new FileSetWatcher(context.FileSet, _reporter); - await fileSetWatcher.GetChangedFileAsync(cancellationToken, () => _reporter.Warn("Waiting for a file to change before restarting dotnet...")); + await fileSetWatcher.GetChangedFileAsync(cancellationToken, () => _reporter.Warn("Waiting for a file to change before restarting dotnet...", emoji: "⏳")); } } } diff --git a/src/BuiltInTools/dotnet-watch/Filters/LaunchBrowserFilter.cs b/src/BuiltInTools/dotnet-watch/Filters/LaunchBrowserFilter.cs index 34a99927763b..f5914ab63a2d 100644 --- a/src/BuiltInTools/dotnet-watch/Filters/LaunchBrowserFilter.cs +++ b/src/BuiltInTools/dotnet-watch/Filters/LaunchBrowserFilter.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; @@ -97,7 +97,7 @@ private void OnOutput(object sender, DataReceivedEventArgs eventArgs) // From emperical observation, it's noted that failing to launch a browser results in either Process.Start returning a null-value // or for the process to have immediately exited. // We can use this to provide a helpful message. - _reporter.Output($"Unable to launch the browser. Navigate to {launchUrl}"); + _reporter.Output($"Unable to launch the browser. Navigate to {launchUrl}", emoji: "🌐"); } } else if (_watchContext?.BrowserRefreshServer is { } browserRefresh) diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index 545ef1f75298..81e8d1b09316 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.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. #nullable enable @@ -171,7 +171,7 @@ public async ValueTask TryHandleFileChange(DotNetWatchContext context, Fil HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.CompilationHandler); if (applyState) { - _reporter.Output($"Hot reload of changes succeeded."); + _reporter.Output($"Hot reload of changes succeeded.", emoji: "🔥"); } return applyState; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationWorkspaceProvider.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationWorkspaceProvider.cs index 04e42e4d0d86..75f83d39a508 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationWorkspaceProvider.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationWorkspaceProvider.cs @@ -82,7 +82,7 @@ private static async Task> GetHotReloadCapabilitiesAsync( try { var capabilities = await hotReloadCapabilitiesTask; - reporter.Verbose($"Hot reload capabilities: {string.Join(" ", capabilities)}."); + reporter.Verbose($"Hot reload capabilities: {string.Join(" ", capabilities)}.", emoji: "🔥"); return capabilities; } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadProfileReader.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadProfileReader.cs index 093835e2b621..10a4469299ca 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadProfileReader.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadProfileReader.cs @@ -35,11 +35,11 @@ public static HotReloadProfile InferHotReloadProfile(ProjectGraph projectGraph, // We saw a previous project that was AspNetCore. This must he a blazor hosted app. if (aspnetCoreProject is not null && aspnetCoreProject != currentNode.ProjectInstance) { - reporter.Verbose($"HotReloadProfile: BlazorHosted. {aspnetCoreProject.FullPath} references BlazorWebAssembly project {currentNode.ProjectInstance.FullPath}."); + reporter.Verbose($"HotReloadProfile: BlazorHosted. {aspnetCoreProject.FullPath} references BlazorWebAssembly project {currentNode.ProjectInstance.FullPath}.", emoji: "🔥"); return HotReloadProfile.BlazorHosted; } - reporter.Verbose("HotReloadProfile: BlazorWebAssembly."); + reporter.Verbose("HotReloadProfile: BlazorWebAssembly.", emoji: "🔥"); return HotReloadProfile.BlazorWebAssembly; } } @@ -50,7 +50,7 @@ public static HotReloadProfile InferHotReloadProfile(ProjectGraph projectGraph, } } - reporter.Verbose("HotReloadProfile: Default."); + reporter.Verbose("HotReloadProfile: Default.", emoji: "🔥"); return HotReloadProfile.Default; } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/RudeEditDialog.cs b/src/BuiltInTools/dotnet-watch/HotReload/RudeEditDialog.cs new file mode 100644 index 000000000000..3c51e837c7e7 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/HotReload/RudeEditDialog.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public class RudeEditDialog + { + private readonly IReporter _reporter; + private readonly IRequester _requester; + private readonly IConsole _console; + private bool? _restartImmediatelySessionPreference; // Session preference + + public RudeEditDialog(IReporter reporter, IRequester requester, IConsole console) + { + _reporter = reporter; + _requester = requester; + _console = console; + + var alwaysRestart = Environment.GetEnvironmentVariable("DOTNET_WATCH_RESTART_ON_RUDE_EDIT"); + + if (alwaysRestart == "1" || string.Equals(alwaysRestart, "true", StringComparison.OrdinalIgnoreCase)) + { + _reporter.Verbose($"DOTNET_WATCH_RESTART_ON_RUDE_EDIT = '{alwaysRestart}'. Restarting without prompt."); + _restartImmediatelySessionPreference = true; + } + } + + public async Task EvaluateAsync(CancellationToken cancellationToken) + { + if (_restartImmediatelySessionPreference.HasValue) + { + await GetRudeEditResult(_restartImmediatelySessionPreference.Value, cancellationToken); + return; + } + + var key = await _requester.GetKeyAsync( + "Do you want to restart your app - Yes (y) / No (n) / Always (a) / Never (v)?", + KeyPressed, + cancellationToken); + + switch (key) + { + case ConsoleKey.Escape: + case ConsoleKey.Y: + await GetRudeEditResult(restartImmediately: true, cancellationToken); + return; + case ConsoleKey.N: + await GetRudeEditResult(restartImmediately: false, cancellationToken); + return; + case ConsoleKey.A: + _restartImmediatelySessionPreference = true; + await GetRudeEditResult(restartImmediately: true, cancellationToken); + return; + case ConsoleKey.V: + _restartImmediatelySessionPreference = false; + await GetRudeEditResult(restartImmediately: false, cancellationToken); + return; + } + + bool KeyPressed(ConsoleKey key) + { + return key is ConsoleKey.Y or ConsoleKey.N or ConsoleKey.A or ConsoleKey.V; + } + } + + private Task GetRudeEditResult(bool restartImmediately, CancellationToken cancellationToken) + { + if (restartImmediately) + { + return Task.CompletedTask; + } + + _reporter.Output("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", emoji: "🔥"); + + return Task.Delay(-1, cancellationToken); + } + } +} diff --git a/src/BuiltInTools/dotnet-watch/HotReload/RudeEditPreference.cs b/src/BuiltInTools/dotnet-watch/HotReload/RudeEditPreference.cs deleted file mode 100644 index 8f2e02a8bd1e..000000000000 --- a/src/BuiltInTools/dotnet-watch/HotReload/RudeEditPreference.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. -// - -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Tools.Internal; - -namespace Microsoft.DotNet.Watcher.Tools -{ - public class RudeEditDialog - { - private readonly IReporter _reporter; - private readonly IConsole _console; - private bool? _restartImmediatelySessionPreference; // Session preference - - public RudeEditDialog(IReporter reporter, IConsole console) - { - _reporter = reporter; - _console = console; - - var alwaysRestart = Environment.GetEnvironmentVariable("DOTNET_WATCH_RESTART_ON_RUDE_EDIT"); - - if (alwaysRestart == "1" || string.Equals(alwaysRestart, "true", StringComparison.OrdinalIgnoreCase)) - { - _reporter.Verbose($"DOTNET_WATCH_RESTART_ON_RUDE_EDIT = '{alwaysRestart}'. Restarting without prompt."); - _restartImmediatelySessionPreference = true; - } - } - - public async Task EvaluateAsync(CancellationToken cancellationToken) - { - if (_restartImmediatelySessionPreference.HasValue) - { - await GetRudeEditResult(_restartImmediatelySessionPreference.Value, cancellationToken); - return; - } - - while (true) - { - _reporter.Output("Do you want to restart your app - Yes (y) / No (n) / Always (a) / Never (v)?"); - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _console.KeyPressed += KeyPressed; - ConsoleKeyInfo key; - try - { - key = await tcs.Task.WaitAsync(cancellationToken); - } - finally - { - _console.KeyPressed -= KeyPressed; - } - - switch (key.Key) - { - case ConsoleKey.Y: - await GetRudeEditResult(restartImmediately: true, cancellationToken); - return; - case ConsoleKey.N: - await GetRudeEditResult(restartImmediately: false, cancellationToken); - return; - case ConsoleKey.A: - _restartImmediatelySessionPreference = true; - await GetRudeEditResult(restartImmediately: true, cancellationToken); - return; - case ConsoleKey.V: - _restartImmediatelySessionPreference = false; - await GetRudeEditResult(restartImmediately: false, cancellationToken); - return; - } - - void KeyPressed(ConsoleKeyInfo key) - { - if (key.Key is ConsoleKey.Y or ConsoleKey.N or ConsoleKey.A or ConsoleKey.V) - { - tcs.TrySetResult(key); - } - } - } - } - - private Task GetRudeEditResult(bool restartImmediately, CancellationToken cancellationToken) - { - if (restartImmediately) - { - return Task.CompletedTask; - } - - _reporter.Output("Hot reload suspended. To continue hot reload, press \"Ctrl + R\"."); - - return Task.Delay(-1, cancellationToken); - } - } -} diff --git a/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs index c28e90a08718..8361ec0e58b9 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.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. #nullable enable @@ -40,7 +40,7 @@ public async ValueTask TryHandleFileChange(DotNetWatchContext context, Fil return false; } await HandleBrowserRefresh(context.BrowserRefreshServer, file, cancellationToken); - _reporter.Output("Hot reload of scoped css succeeded."); + _reporter.Output("Hot reload of scoped css succeeded.", emoji: "🔥"); HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.ScopedCssHandler); return true; } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs index a90b2f420938..0c229f86110a 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.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.Collections.Generic; @@ -34,7 +34,7 @@ public async ValueTask TryHandleFileChange(DotNetWatchContext context, Fil _reporter.Verbose($"Handling file change event for static content {file.FilePath}."); await HandleBrowserRefresh(context.BrowserRefreshServer, file, cancellationToken); HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.StaticHandler); - _reporter.Output("Hot reload of static file succeeded."); + _reporter.Output("Hot reload of static file succeeded.", emoji: "🔥"); return true; } diff --git a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs index aae67c7697d2..9a43c0dea36e 100644 --- a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.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; @@ -24,15 +24,19 @@ public class HotReloadDotNetWatcher : IAsyncDisposable private readonly DotNetWatchOptions _dotNetWatchOptions; private readonly IWatchFilter[] _filters; private readonly RudeEditDialog _rudeEditDialog; + private readonly string _workingDirectory; - public HotReloadDotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory, DotNetWatchOptions dotNetWatchOptions, IConsole console) + public HotReloadDotNetWatcher(IReporter reporter, IRequester requester, IFileSetFactory fileSetFactory, DotNetWatchOptions dotNetWatchOptions, IConsole console, string workingDirectory) { Ensure.NotNull(reporter, nameof(reporter)); + Ensure.NotNull(requester, nameof(requester)); + Ensure.NotNullOrEmpty(workingDirectory, nameof(workingDirectory)); _reporter = reporter; _processRunner = new ProcessRunner(reporter); _dotNetWatchOptions = dotNetWatchOptions; _console = console; + _workingDirectory = workingDirectory; _filters = new IWatchFilter[] { @@ -40,7 +44,7 @@ public HotReloadDotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory new LaunchBrowserFilter(dotNetWatchOptions), new BrowserRefreshFilter(dotNetWatchOptions, _reporter), }; - _rudeEditDialog = new(reporter, _console); + _rudeEditDialog = new(reporter, requester, _console); } public async Task WatchAsync(DotNetWatchContext context, CancellationToken cancellationToken) @@ -48,7 +52,8 @@ public async Task WatchAsync(DotNetWatchContext context, CancellationToken cance var processSpec = context.ProcessSpec; _reporter.Output("Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload. " + - "Press \"Ctrl + R\" to restart."); + Environment.NewLine + + $" {(_dotNetWatchOptions.SuppressEmojis ? string.Empty : "💡")} Press \"Ctrl + R\" to restart.", emoji: "🔥"); var forceReload = new CancellationTokenSource(); @@ -112,7 +117,7 @@ public async Task WatchAsync(DotNetWatchContext context, CancellationToken cance var args = string.Join(" ", processSpec.Arguments); _reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: {args}"); - _reporter.Output("Started"); + _reporter.Output("Started", emoji: "🚀"); Task fileSetTask; Task finishedTask; @@ -131,7 +136,7 @@ public async Task WatchAsync(DotNetWatchContext context, CancellationToken cance { if (MayRequireRecompilation(context, fileItems) is { } newFile) { - _reporter.Output($"New file: {newFile.FilePath}. Rebuilding the application."); + _reporter.Output($"New file: {GetRelativeFilePath(newFile.FilePath)}. Rebuilding the application."); break; } else if (fileItems.All(f => f.IsNewFile)) @@ -149,17 +154,17 @@ public async Task WatchAsync(DotNetWatchContext context, CancellationToken cance if (fileItems.Length == 1) { - _reporter.Output($"File changed: {fileItems[0].FilePath}."); + _reporter.Output($"File changed: {GetRelativeFilePath(fileItems[0].FilePath)}."); } else { - _reporter.Output($"Files changed: {string.Join(", ", fileItems.Select(f => f.FilePath))}"); + _reporter.Output($"Files changed: {string.Join(", ", fileItems.Select(f => GetRelativeFilePath(f.FilePath)))}"); } var start = Stopwatch.GetTimestamp(); if (await hotReload.TryHandleFileChange(context, fileItems, combinedCancellationSource.Token)) { var totalTime = TimeSpan.FromTicks(Stopwatch.GetTimestamp() - start); - _reporter.Verbose($"Hot reload change handled in {totalTime.TotalMilliseconds}ms."); + _reporter.Verbose($"Hot reload change handled in {totalTime.TotalMilliseconds}ms.", emoji: "🔥"); } else { @@ -190,7 +195,7 @@ public async Task WatchAsync(DotNetWatchContext context, CancellationToken cance if (finishedTask == processTask) { // Now wait for a file to change before restarting process - _reporter.Warn("Waiting for a file to change before restarting dotnet..."); + _reporter.Warn("Waiting for a file to change before restarting dotnet...", emoji: "⏳"); await fileSetWatcher.GetChangedFileAsync(cancellationToken, forceWaitForNewUpdate: true); } else @@ -209,7 +214,7 @@ public async Task WatchAsync(DotNetWatchContext context, CancellationToken cance if (forceReload.IsCancellationRequested) { _console.Clear(); - _reporter.Output("Restart requested."); + _reporter.Output("Restart requested.", emoji: "🔄"); } } } @@ -294,6 +299,19 @@ private static void ConfigureExecutable(DotNetWatchContext context, ProcessSpec } } + private string GetRelativeFilePath(string path) + { + var relativePath = path; + if (path.StartsWith(_workingDirectory, StringComparison.Ordinal) && path.Length > _workingDirectory.Length) + { + relativePath = path.Substring(_workingDirectory.Length); + + return $".{(relativePath.StartsWith(Path.DirectorySeparatorChar) ? string.Empty : Path.DirectorySeparatorChar)}{relativePath}"; + } + + return relativePath; + } + public async ValueTask DisposeAsync() { foreach (var filter in _filters) diff --git a/src/BuiltInTools/dotnet-watch/Internal/ConsoleReporter.cs b/src/BuiltInTools/dotnet-watch/Internal/ConsoleReporter.cs index e1cc8022bc9a..61576dba9154 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/ConsoleReporter.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/ConsoleReporter.cs @@ -1,8 +1,11 @@ -// 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.Collections.Generic; using System.IO; +using System.Threading; +using System.Threading.Tasks; namespace Microsoft.Extensions.Tools.Internal { @@ -15,26 +18,32 @@ public class ConsoleReporter : IReporter private readonly object _writeLock = new object(); public ConsoleReporter(IConsole console) - : this(console, verbose: false, quiet: false) + : this(console, verbose: false, quiet: false, suppressEmojis: false) { } - public ConsoleReporter(IConsole console, bool verbose, bool quiet) + public ConsoleReporter(IConsole console, bool verbose, bool quiet, bool suppressEmojis) { Ensure.NotNull(console, nameof(console)); Console = console; IsVerbose = verbose; IsQuiet = quiet; + SuppressEmojis = suppressEmojis; } protected IConsole Console { get; } public bool IsVerbose { get; set; } public bool IsQuiet { get; set; } + public bool SuppressEmojis { get; set; } - protected virtual void WriteLine(TextWriter writer, string message, ConsoleColor? color) + private void WriteLine(TextWriter writer, string message, ConsoleColor? color, string emoji) { lock (_writeLock) { + Console.ForegroundColor = ConsoleColor.DarkGray; + writer.Write($"dotnet watch {(SuppressEmojis ? ":" : emoji)} "); + Console.ResetColor(); + if (color.HasValue) { Console.ForegroundColor = color.Value; @@ -49,28 +58,34 @@ protected virtual void WriteLine(TextWriter writer, string message, ConsoleColor } } - public virtual void Error(string message) - => WriteLine(Console.Error, message, ConsoleColor.Red); - public virtual void Warn(string message) - => WriteLine(Console.Out, message, ConsoleColor.Yellow); + public virtual void Error(string message, string emoji = "❌") + { + WriteLine(Console.Error, message, ConsoleColor.Red, emoji); + } - public virtual void Output(string message) + public virtual void Warn(string message, string emoji = "⌚") + { + WriteLine(Console.Out, message, ConsoleColor.Yellow, emoji); + } + + public virtual void Output(string message, string emoji = "⌚") { if (IsQuiet) { return; } - WriteLine(Console.Out, message, color: null); + + WriteLine(Console.Out, message, color: null, emoji); } - public virtual void Verbose(string message) + public virtual void Verbose(string message, string emoji = "⌚") { if (!IsVerbose) { return; } - WriteLine(Console.Out, message, ConsoleColor.DarkGray); + WriteLine(Console.Out, message, ConsoleColor.DarkGray, emoji); } } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/ConsoleRequester.cs b/src/BuiltInTools/dotnet-watch/Internal/ConsoleRequester.cs new file mode 100644 index 000000000000..b5c41a04ca07 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Internal/ConsoleRequester.cs @@ -0,0 +1,93 @@ +// 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.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Tools.Internal +{ + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class ConsoleRequester : IRequester + { + private readonly object _writeLock = new object(); + + public ConsoleRequester(IConsole console, bool quiet, bool suppressEmojis) + { + Ensure.NotNull(console, nameof(console)); + + Console = console; + IsQuiet = quiet; + SuppressEmojis = suppressEmojis; + } + + private IConsole Console { get; } + private bool IsQuiet { get; set; } + private bool SuppressEmojis { get; set; } + + public async Task GetKeyAsync(string prompt, Func validateInput, CancellationToken cancellationToken) + { + if (IsQuiet) + { + return ConsoleKey.Escape; + } + + var questionMark = SuppressEmojis ? "?" : "❔"; + while (true) + { + WriteLine($" {questionMark} {prompt}"); + + lock (_writeLock) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Out.Write($" {questionMark} "); + Console.ResetColor(); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Console.KeyPressed += KeyPressed; + try + { + return await tcs.Task.WaitAsync(cancellationToken); + } + catch (ArgumentException) + { + // Prompt again for valid input + } + finally + { + Console.KeyPressed -= KeyPressed; + } + + void KeyPressed(ConsoleKeyInfo key) + { + if (validateInput(key.Key)) + { + WriteLine(key.KeyChar.ToString()); + tcs.TrySetResult(key.Key); + } + else + { + WriteLine(key.KeyChar.ToString(), ConsoleColor.DarkRed); + tcs.TrySetException(new ArgumentException($"Invalid key {key.KeyChar} entered.")); + } + } + } + + void WriteLine(string message, ConsoleColor color = ConsoleColor.DarkGray) + { + lock (_writeLock) + { + Console.ForegroundColor = color; + Console.Out.WriteLine(message); + Console.ResetColor(); + } + } + } + } +} diff --git a/src/BuiltInTools/dotnet-watch/Internal/IReporter.cs b/src/BuiltInTools/dotnet-watch/Internal/IReporter.cs index 4dfdca81a5d9..027561059b47 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/IReporter.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/IReporter.cs @@ -10,9 +10,9 @@ namespace Microsoft.Extensions.Tools.Internal public interface IReporter { public bool IsVerbose => false; - void Verbose(string message); - void Output(string message); - void Warn(string message); - void Error(string message); + void Verbose(string message, string emoji = "⌚"); + void Output(string message, string emoji = "⌚"); + void Warn(string message, string emoji = "⌚"); + void Error(string message, string emoji = "❌"); } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/IRequester.cs b/src/BuiltInTools/dotnet-watch/Internal/IRequester.cs new file mode 100644 index 000000000000..94cd4044a7b2 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Internal/IRequester.cs @@ -0,0 +1,17 @@ +// 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.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Tools.Internal +{ + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public interface IRequester + { + Task GetKeyAsync(string prompt, Func validateInput, CancellationToken cancellationToken); + } +} diff --git a/src/BuiltInTools/dotnet-watch/Internal/NullReporter.cs b/src/BuiltInTools/dotnet-watch/Internal/NullReporter.cs index 0903797b67eb..bbc5cb000c6b 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/NullReporter.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/NullReporter.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. namespace Microsoft.Extensions.Tools.Internal @@ -14,16 +14,16 @@ private NullReporter() public static IReporter Singleton { get; } = new NullReporter(); - public void Verbose(string message) + public void Verbose(string message, string emoji = "⌚") { } - public void Output(string message) + public void Output(string message, string emoji = "⌚") { } - public void Warn(string message) + public void Warn(string message, string emoji = "⌚") { } - public void Error(string message) + public void Error(string message, string emoji = "❌") { } } } diff --git a/src/BuiltInTools/dotnet-watch/Internal/PhysicalConsole.cs b/src/BuiltInTools/dotnet-watch/Internal/PhysicalConsole.cs index 7bc2c04aed87..8ea2026e9bfc 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/PhysicalConsole.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/PhysicalConsole.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text; using System.Threading.Tasks; namespace Microsoft.Extensions.Tools.Internal @@ -18,6 +19,7 @@ public class PhysicalConsole : IConsole private PhysicalConsole() { + Console.OutputEncoding = Encoding.UTF8; Console.CancelKeyPress += (o, e) => { CancelKeyPress?.Invoke(o, e); diff --git a/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs b/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs index f2b772ca03ad..c139101d77b1 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.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. #nullable enable @@ -71,7 +71,7 @@ public async Task RunAsync(ProcessSpec processSpec, CancellationToken cance stopwatch.Start(); process.Start(); - _reporter.Verbose($"Started '{processSpec.Executable}' '{process.StartInfo.Arguments}' with process id {process.Id}"); + _reporter.Verbose($"Started '{processSpec.Executable}' '{process.StartInfo.Arguments}' with process id {process.Id}", emoji: "🚀"); if (readOutput) { diff --git a/src/BuiltInTools/dotnet-watch/PrefixConsoleReporter.cs b/src/BuiltInTools/dotnet-watch/PrefixConsoleReporter.cs deleted file mode 100644 index eddbaf36edd0..000000000000 --- a/src/BuiltInTools/dotnet-watch/PrefixConsoleReporter.cs +++ /dev/null @@ -1,35 +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.IO; -using System.CommandLine; -using Microsoft.Extensions.Tools.Internal; - -namespace Microsoft.DotNet.Watcher -{ - public class PrefixConsoleReporter : ConsoleReporter - { - private object _lock = new object(); - - private readonly string _prefix; - - public PrefixConsoleReporter(string prefix, Extensions.Tools.Internal.IConsole console, bool verbose, bool quiet) - : base(console, verbose, quiet) - { - _prefix = prefix; - } - - protected override void WriteLine(TextWriter writer, string message, ConsoleColor? color) - { - lock (_lock) - { - Console.ForegroundColor = ConsoleColor.DarkGray; - writer.Write(_prefix); - Console.ResetColor(); - - base.WriteLine(writer, message, color); - } - } - } -} diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index 246f07e76291..445a510c9de3 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.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; @@ -38,6 +38,10 @@ public class Program : IDisposable dotnet-watch sets this variable to '1' and increments by one each time a file is changed and the command is restarted. + DOTNET_WATCH_SUPPRESS_EMOJIS + When set to '1' or 'true', dotnet-watch will not show emojis in the + console output. + Remarks: The special option '--' is used to delimit the end of the options and the beginning of arguments that will be passed to the child dotnet process. @@ -59,6 +63,7 @@ dotnet watch test private readonly string _workingDirectory; private readonly CancellationTokenSource _cts; private IReporter _reporter; + private IRequester _requester; public Program(IConsole console, string workingDirectory) { @@ -82,7 +87,10 @@ public Program(IConsole console, string workingDirectory) _workingDirectory = workingDirectory; _cts = new CancellationTokenSource(); console.CancelKeyPress += OnCancelKeyPress; - _reporter = CreateReporter(verbose: true, quiet: false, console: _console); + + var suppressEmojis = ShouldSuppressEmojis(); + _reporter = CreateReporter(verbose: true, quiet: false, console: _console, suppressEmojis); + _requester = new ConsoleRequester(_console, quiet: false, suppressEmojis); // Register listeners that load Roslyn-related assemblies from the `Rosyln/bincore` directory. RegisterAssemblyResolutionEvents(sdkRootDirectory); @@ -181,7 +189,9 @@ internal static RootCommand CreateRootCommand(Func private async Task HandleWatch(CommandLineOptions options) { // update reporter as configured by options - _reporter = CreateReporter(options.Verbose, options.Quiet, _console); + var suppressEmojis = ShouldSuppressEmojis(); + _reporter = CreateReporter(options.Verbose, options.Quiet, _console, suppressEmojis); + _requester = new ConsoleRequester(_console, quiet: options.Quiet, suppressEmojis); try { @@ -198,7 +208,7 @@ private async Task HandleWatch(CommandLineOptions options) } else { - return await MainInternalAsync(_reporter, options, _cts.Token); + return await MainInternalAsync(options, _cts.Token); } } catch (Exception ex) @@ -222,13 +232,13 @@ private void OnCancelKeyPress(object sender, ConsoleCancelEventArgs args) if (args.Cancel) { - _reporter.Output("Shutdown requested. Press Ctrl+C again to force exit."); + _reporter.Output("Shutdown requested. Press Ctrl+C again to force exit.", emoji: "🛑"); } _cts.Cancel(); } - private async Task MainInternalAsync(IReporter reporter, CommandLineOptions options, CancellationToken cancellationToken) + private async Task MainInternalAsync(CommandLineOptions options, CancellationToken cancellationToken) { // TODO multiple projects should be easy enough to add here string projectFile; @@ -238,7 +248,7 @@ private async Task MainInternalAsync(IReporter reporter, CommandLineOptions } catch (FileNotFoundException ex) { - reporter.Error(ex.Message); + _reporter.Error(ex.Message); return 1; } @@ -257,7 +267,7 @@ private async Task MainInternalAsync(IReporter reporter, CommandLineOptions var watchOptions = DotNetWatchOptions.Default; - var fileSetFactory = new MsBuildFileSetFactory(reporter, + var fileSetFactory = new MsBuildFileSetFactory(_reporter, watchOptions, projectFile, waitOnError: true, @@ -278,7 +288,7 @@ private async Task MainInternalAsync(IReporter reporter, CommandLineOptions _reporter.Output("Polling file watcher is enabled"); } - var defaultProfile = LaunchSettingsProfile.ReadDefaultProfile(processInfo.WorkingDirectory, reporter) ?? new(); + var defaultProfile = LaunchSettingsProfile.ReadDefaultProfile(processInfo.WorkingDirectory, _reporter) ?? new(); var context = new DotNetWatchContext { @@ -298,7 +308,7 @@ private async Task MainInternalAsync(IReporter reporter, CommandLineOptions // a) watch was invoked with no args or with exactly one arg - the run command e.g. `dotnet watch` or `dotnet watch run` // b) The launch profile supports hot-reload based watching. // The watcher will complain if users configure this for runtimes that would not support it. - await using var watcher = new HotReloadDotNetWatcher(reporter, fileSetFactory, watchOptions, _console); + await using var watcher = new HotReloadDotNetWatcher(_reporter, _requester, fileSetFactory, watchOptions, _console, _workingDirectory); await watcher.WatchAsync(context, cancellationToken); } else @@ -307,7 +317,7 @@ private async Task MainInternalAsync(IReporter reporter, CommandLineOptions // We'll use the presence of a profile to decide if we're going to use the hot-reload based watching. // The watcher will complain if users configure this for runtimes that would not support it. - await using var watcher = new DotNetWatcher(reporter, fileSetFactory, watchOptions); + await using var watcher = new DotNetWatcher(_reporter, fileSetFactory, watchOptions); await watcher.WatchAsync(context, cancellationToken); } @@ -386,8 +396,8 @@ private async Task ListFilesAsync( return 0; } - private static IReporter CreateReporter(bool verbose, bool quiet, IConsole console) - => new PrefixConsoleReporter("watch : ", console, verbose || IsGlobalVerbose(), quiet); + private static IReporter CreateReporter(bool verbose, bool quiet, IConsole console, bool suppressEmojis) + => new ConsoleReporter(console, verbose || IsGlobalVerbose(), quiet, suppressEmojis); private static bool IsGlobalVerbose() { @@ -401,6 +411,13 @@ public void Dispose() _cts.Dispose(); } + private static bool ShouldSuppressEmojis() + { + var suppressEmojisEnvironmentVariable = Environment.GetEnvironmentVariable("DOTNET_WATCH_SUPPRESS_EMOJIS"); + var suppressEmojis = suppressEmojisEnvironmentVariable == "1" || string.Equals(suppressEmojisEnvironmentVariable, "true", StringComparison.OrdinalIgnoreCase); + return suppressEmojis; + } + private static void RegisterAssemblyResolutionEvents(string sdkRootDirectory) { var roslynPath = Path.Combine(sdkRootDirectory, "Roslyn", "bincore"); diff --git a/src/Tests/dotnet-watch.Tests/BrowserLaunchTests.cs b/src/Tests/dotnet-watch.Tests/BrowserLaunchTests.cs index 7d9dd73afa61..f479dcf9dd88 100644 --- a/src/Tests/dotnet-watch.Tests/BrowserLaunchTests.cs +++ b/src/Tests/dotnet-watch.Tests/BrowserLaunchTests.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; @@ -25,7 +25,7 @@ public BrowserLaunchTests(ITestOutputHelper logger) [Fact] public async Task LaunchesBrowserOnStart() { - var expected = "watch : Launching browser: https://localhost:5001/"; + var expected = "dotnet watch ⌚ Launching browser: https://localhost:5001/"; var testAsset = _testAssetsManager.CopyTestAsset(AppName) .WithSource() .Path; @@ -43,7 +43,7 @@ public async Task LaunchesBrowserOnStart() [Fact] public async Task UsesBrowserSpecifiedInEnvironment() { - var launchBrowserMessage = "watch : Launching browser: mycustombrowser.bat https://localhost:5001/"; + var launchBrowserMessage = "dotnet watch ⌚ Launching browser: mycustombrowser.bat https://localhost:5001/"; var testAsset = _testAssetsManager.CopyTestAsset(AppName) .WithSource() .Path; diff --git a/src/Tests/dotnet-watch.Tests/CommandLineOptionsTests.cs b/src/Tests/dotnet-watch.Tests/CommandLineOptionsTests.cs index 2cb8cbd7c019..92edfcfb717d 100644 --- a/src/Tests/dotnet-watch.Tests/CommandLineOptionsTests.cs +++ b/src/Tests/dotnet-watch.Tests/CommandLineOptionsTests.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.CommandLine; @@ -73,7 +73,7 @@ public async Task CannotHaveQuietAndVerbose() public async Task ShortFormForProjectArgumentPrintsWarning() { var reporter = new Mock(); - reporter.Setup(r => r.Warn(Resources.Warning_ProjectAbbreviationDeprecated)).Verifiable(); + reporter.Setup(r => r.Warn(Resources.Warning_ProjectAbbreviationDeprecated, It.IsAny())).Verifiable(); CommandLineOptions options = null; var rootCommand = Program.CreateRootCommand(c => { options = c; return Task.FromResult(0); }, reporter.Object); @@ -93,7 +93,7 @@ public async Task LongFormForProjectArgumentWorks() await rootCommand.InvokeAsync(new[] { "--project", "MyProject.csproj" }, _console); - reporter.Verify(r => r.Warn(It.IsAny()), Times.Never()); + reporter.Verify(r => r.Warn(It.IsAny(), It.IsAny()), Times.Never()); Assert.NotNull(options); Assert.Equal("MyProject.csproj", options.Project); } diff --git a/src/Tests/dotnet-watch.Tests/ConsoleReporterTests.cs b/src/Tests/dotnet-watch.Tests/ConsoleReporterTests.cs index 3c731bf36d6a..c338ffd38f0f 100644 --- a/src/Tests/dotnet-watch.Tests/ConsoleReporterTests.cs +++ b/src/Tests/dotnet-watch.Tests/ConsoleReporterTests.cs @@ -12,28 +12,59 @@ public class ReporterTests { private static readonly string EOL = Environment.NewLine; - [Fact] - public void WritesToStandardStreams() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void WritesToStandardStreams(bool suppressEmojis) { var testConsole = new TestConsole(); - var reporter = new ConsoleReporter(testConsole, verbose: true, quiet: false); + var reporter = new ConsoleReporter(testConsole, verbose: true, quiet: false, suppressEmojis: suppressEmojis); + var dotnetWatchDefaultPrefix = $"dotnet watch {(suppressEmojis ? ":" : "⌚")} "; // stdout reporter.Verbose("verbose"); - Assert.Equal("verbose" + EOL, testConsole.GetOutput()); + Assert.Equal($"{dotnetWatchDefaultPrefix}verbose" + EOL, testConsole.GetOutput()); testConsole.Clear(); reporter.Output("out"); - Assert.Equal("out" + EOL, testConsole.GetOutput()); + Assert.Equal($"{dotnetWatchDefaultPrefix}out" + EOL, testConsole.GetOutput()); testConsole.Clear(); reporter.Warn("warn"); - Assert.Equal("warn" + EOL, testConsole.GetOutput()); + Assert.Equal($"{dotnetWatchDefaultPrefix}warn" + EOL, testConsole.GetOutput()); testConsole.Clear(); // stderr reporter.Error("error"); - Assert.Equal("error" + EOL, testConsole.GetError()); + Assert.Equal($"dotnet watch {(suppressEmojis ? ":" : "❌")} error" + EOL, testConsole.GetError()); + testConsole.Clear(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void WritesToStandardStreamsWithCustomEmojis(bool suppressEmojis) + { + var testConsole = new TestConsole(); + var reporter = new ConsoleReporter(testConsole, verbose: true, quiet: false, suppressEmojis: suppressEmojis); + var dotnetWatchDefaultPrefix = $"dotnet watch {(suppressEmojis ? ":" : "😄")}"; + + // stdout + reporter.Verbose("verbose", emoji: "😄"); + Assert.Equal($"{dotnetWatchDefaultPrefix} verbose" + EOL, testConsole.GetOutput()); + testConsole.Clear(); + + reporter.Output("out", emoji: "😄"); + Assert.Equal($"{dotnetWatchDefaultPrefix} out" + EOL, testConsole.GetOutput()); + testConsole.Clear(); + + reporter.Warn("warn", emoji: "😄"); + Assert.Equal($"{dotnetWatchDefaultPrefix} warn" + EOL, testConsole.GetOutput()); + testConsole.Clear(); + + // stderr + reporter.Error("error", emoji: "😄"); + Assert.Equal($"{dotnetWatchDefaultPrefix} error" + EOL, testConsole.GetError()); testConsole.Clear(); } diff --git a/src/Tests/dotnet-watch.Tests/DotNetWatcherTests.cs b/src/Tests/dotnet-watch.Tests/DotNetWatcherTests.cs index e1eb948f67e8..16f1f87cd9b9 100644 --- a/src/Tests/dotnet-watch.Tests/DotNetWatcherTests.cs +++ b/src/Tests/dotnet-watch.Tests/DotNetWatcherTests.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; @@ -82,7 +82,7 @@ public async Task RunsWithNoRestoreOnOrdinaryFileChanges() 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"; + const string messagePrefix = "dotnet 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())); @@ -111,7 +111,7 @@ public async Task RunsWithRestoreIfCsprojChanges() 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"; + const string messagePrefix = "dotnet 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())); @@ -143,7 +143,7 @@ public async Task Run_WithHotReloadEnabled_ReadsLaunchSettings() await app.StartWatcherAsync(); - await app.Process.GetOutputLineAsync("Environment: Development", TimeSpan.FromSeconds(10)); + await app.Process.GetOutputLineAsyncWithConsoleHistoryAsync("Environment: Development", TimeSpan.FromSeconds(10)); } [CoreMSBuildOnlyFact] @@ -166,7 +166,7 @@ public async Task Run_WithHotReloadEnabled_ReadsLaunchSettings_WhenUsingProjectO await app.StartWatcherAsync(); - await app.Process.GetOutputLineAsync("Environment: Development", TimeSpan.FromSeconds(10)); + await app.Process.GetOutputLineAsyncWithConsoleHistoryAsync("Environment: Development", TimeSpan.FromSeconds(10)); } } } diff --git a/src/Tests/dotnet-watch.Tests/GlobbingAppTests.cs b/src/Tests/dotnet-watch.Tests/GlobbingAppTests.cs index b6e42ab0c043..d6cbafa2db79 100644 --- a/src/Tests/dotnet-watch.Tests/GlobbingAppTests.cs +++ b/src/Tests/dotnet-watch.Tests/GlobbingAppTests.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; @@ -47,6 +47,7 @@ public async Task ChangeCompiledFile(bool usePollingWatcher) var programCs = File.ReadAllText(fileToChange); File.WriteAllText(fileToChange, programCs); + await app.HasFileChanged().TimeoutAfter(DefaultTimeout); await app.HasRestarted().TimeoutAfter(DefaultTimeout); types = await GetCompiledAppDefinedTypes(app).TimeoutAfter(DefaultTimeout); Assert.Equal(2, types); @@ -128,9 +129,9 @@ public async Task ChangeExcludedFile() var changedFile = Path.Combine(app.SourceDirectory, "exclude", "Baz.cs"); File.WriteAllText(changedFile, ""); - var restart = app.HasRestarted(); - var finished = await Task.WhenAny(Task.Delay(TimeSpan.FromSeconds(5)), restart); - Assert.NotSame(restart, finished); + var fileChanged = app.HasFileChanged(); + var finished = await Task.WhenAny(Task.Delay(TimeSpan.FromSeconds(5)), fileChanged); + Assert.NotSame(fileChanged, finished); } [Fact] diff --git a/src/Tests/dotnet-watch.Tests/MsBuildFileSetFactoryTest.cs b/src/Tests/dotnet-watch.Tests/MsBuildFileSetFactoryTest.cs index 09da85040348..44fab3db43e8 100644 --- a/src/Tests/dotnet-watch.Tests/MsBuildFileSetFactoryTest.cs +++ b/src/Tests/dotnet-watch.Tests/MsBuildFileSetFactoryTest.cs @@ -367,7 +367,7 @@ private Task GetFileSet(string projectPath) } private static DotNetWatchOptions GetWatchOptions() => - new DotNetWatchOptions(false, false, false, false, false); + new DotNetWatchOptions(false, false, false, false, false, false); private static string GetTestProjectPath(TestAsset target) => Path.Combine(GetTestProjectDirectory(target), target.TestProject.Name + ".csproj"); diff --git a/src/Tests/dotnet-watch.Tests/Utilities/AwaitableProcess.cs b/src/Tests/dotnet-watch.Tests/Utilities/AwaitableProcess.cs index 184560d02f63..c47180f88723 100644 --- a/src/Tests/dotnet-watch.Tests/Utilities/AwaitableProcess.cs +++ b/src/Tests/dotnet-watch.Tests/Utilities/AwaitableProcess.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Text; using System.Threading; using System.Threading.Tasks; using System.Threading.Tasks.Dataflow; @@ -51,6 +52,8 @@ public void Start() var processStartInfo = _spec.GetProcessStartInfo(); processStartInfo.RedirectStandardOutput = true; processStartInfo.RedirectStandardError = true; + processStartInfo.StandardOutputEncoding = Encoding.UTF8; + processStartInfo.StandardErrorEncoding = Encoding.UTF8; processStartInfo.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "true"; _process = new Process @@ -71,6 +74,18 @@ public void Start() WriteTestOutput($"{DateTime.Now}: process started: '{_process.StartInfo.FileName} {_process.StartInfo.Arguments}'"); } + public Task GetOutputLineAsyncWithConsoleHistoryAsync(string message, TimeSpan timeout) + { + if (_lines.Contains(message)) + { + WriteTestOutput($"Found [msg == '{message}'] in console history."); + return Task.FromResult(message); + } + + WriteTestOutput($"Did not find [msg == '{message}'] in console history."); + return GetOutputLineAsync(message, timeout); + } + public async Task GetOutputLineAsync(string message, TimeSpan timeout) { WriteTestOutput($"Waiting for output line [msg == '{message}']. Will wait for {timeout.TotalSeconds} sec."); diff --git a/src/Tests/dotnet-watch.Tests/Utilities/TestReporter.cs b/src/Tests/dotnet-watch.Tests/Utilities/TestReporter.cs index cc807f01a8d6..59fedf664ea9 100644 --- a/src/Tests/dotnet-watch.Tests/Utilities/TestReporter.cs +++ b/src/Tests/dotnet-watch.Tests/Utilities/TestReporter.cs @@ -14,24 +14,24 @@ public TestReporter(ITestOutputHelper output) _output = output; } - public void Verbose(string message) + public void Verbose(string message, string emoji = "⌚") { - _output.WriteLine("verbose: " + message); + _output.WriteLine($"verbose {emoji} " + message); } - public void Output(string message) + public void Output(string message, string emoji = "⌚") { - _output.WriteLine("output: " + message); + _output.WriteLine($"output {emoji} " + message); } - public void Warn(string message) + public void Warn(string message, string emoji = "⌚") { - _output.WriteLine("warn: " + message); + _output.WriteLine($"warn {emoji} " + message); } - public void Error(string message) + public void Error(string message, string emoji = "❌") { - _output.WriteLine("error: " + message); + _output.WriteLine($"error {emoji} " + message); } } } diff --git a/src/Tests/dotnet-watch.Tests/Utilities/WatchableApp.cs b/src/Tests/dotnet-watch.Tests/Utilities/WatchableApp.cs index 415f9b802219..9a3dc306bdd8 100644 --- a/src/Tests/dotnet-watch.Tests/Utilities/WatchableApp.cs +++ b/src/Tests/dotnet-watch.Tests/Utilities/WatchableApp.cs @@ -1,10 +1,11 @@ -// 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.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; +using System.Text; using System.Threading.Tasks; using Microsoft.NET.TestFramework.Assertions; using Microsoft.NET.TestFramework.Commands; @@ -18,8 +19,10 @@ internal sealed class WatchableApp : IDisposable private const string StartedMessage = "Started"; private const string ExitingMessage = "Exiting"; - private const string WatchExitedMessage = "watch : Exited"; - private const string WaitingForFileChangeMessage = "watch : Waiting for a file to change"; + private const string WatchStartedMessage = "dotnet watch 🚀 Started"; + private const string WatchExitedMessage = "dotnet watch ⌚ Exited"; + private const string WaitingForFileChangeMessage = "dotnet watch ⏳ Waiting for a file to change"; + private const string WatchFileChanged = "dotnet watch ⌚ File changed:"; private readonly ITestOutputHelper _logger; private bool _prepared; @@ -57,6 +60,11 @@ public Task IsWaitingForFileChange() return Process.GetOutputLineStartsWithAsync(WaitingForFileChangeMessage, DefaultMessageTimeOut); } + public Task HasFileChanged() + { + return Process.GetOutputLineStartsWithAsync(WatchFileChanged, DefaultMessageTimeOut); + } + public bool UsePollingWatcher { get; set; } public async Task GetProcessIdentifier() @@ -117,7 +125,7 @@ public async Task StartWatcherAsync(string[] arguments, [CallerMemberName] strin // Make this timeout long because it depends much on the MSBuild compilation speed. // Slow machines may take a bit to compile and boot test apps - await Process.GetOutputLineAsync(StartedMessage, TimeSpan.FromMinutes(2)); + await Process.GetOutputLineAsync(WatchStartedMessage, TimeSpan.FromMinutes(2)); } public void Dispose()