diff --git a/Directory.Build.targets b/Directory.Build.targets index f8d73e5ff744..464f65cfee17 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -73,7 +73,7 @@ $(MicrosoftAspNetCoreAppRefPackageVersion) - ${SupportedRuntimeIdentifiers} + $(SupportedRuntimeIdentifiers) $(MicrosoftAspNetCoreAppRefPackageVersion) $(MicrosoftAspNetCoreAppRefPackageVersion) diff --git a/sdk.slnx b/sdk.slnx index 0246a040f447..dc0633c42d02 100644 --- a/sdk.slnx +++ b/sdk.slnx @@ -312,6 +312,7 @@ + diff --git a/src/BuiltInTools/HotReloadClient/HotReloadClients.cs b/src/BuiltInTools/HotReloadClient/HotReloadClients.cs index bd0a4be6799c..bef9b5244cff 100644 --- a/src/BuiltInTools/HotReloadClient/HotReloadClients.cs +++ b/src/BuiltInTools/HotReloadClient/HotReloadClients.cs @@ -173,7 +173,7 @@ public async Task ApplyStaticAssetUpdatesAsync(IEnumerable<(string filePath, str #endif content = ImmutableCollectionsMarshal.AsImmutableArray(blob); } - catch (Exception e) + catch (Exception e) when (e is not OperationCanceledException) { ClientLogger.LogError("Failed to read file {FilePath}: {Message}", filePath, e.Message); continue; diff --git a/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs b/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs index a74e5097a6d5..f1672112866f 100644 --- a/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs +++ b/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs @@ -30,4 +30,5 @@ public static void Log(this ILogger logger, LogEvent logEvent, params object[] a public static readonly LogEvent UpdatingDiagnostics = Create(LogLevel.Debug, "Updating diagnostics."); public static readonly LogEvent SendingStaticAssetUpdateRequest = Create(LogLevel.Debug, "Sending static asset update request to connected browsers: '{0}'."); public static readonly LogEvent RefreshServerRunningAt = Create(LogLevel.Debug, "Refresh server running at {0}."); + public static readonly LogEvent ConnectedToRefreshServer = Create(LogLevel.Debug, "Connected to refresh server."); } diff --git a/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs b/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs index eda15d200f96..9c313bb02b50 100644 --- a/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs +++ b/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs @@ -163,7 +163,7 @@ public async Task WaitForClientConnectionAsync(CancellationToken cancellationTok }, progressCancellationSource.Token); // Work around lack of Task.WaitAsync(cancellationToken) on .NET Framework: - cancellationToken.Register(() => _browserConnected.SetCanceled()); + cancellationToken.Register(() => _browserConnected.TrySetCanceled()); try { diff --git a/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs b/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs index 507595eb89d1..498c26110089 100644 --- a/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs +++ b/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs @@ -37,7 +37,7 @@ public BrowserConnection(WebSocket clientSocket, string? sharedSecret, ILoggerFa ServerLogger = loggerFactory.CreateLogger(ServerLogComponentName, displayName); AgentLogger = loggerFactory.CreateLogger(AgentLogComponentName, displayName); - ServerLogger.LogDebug("Connected to referesh server."); + ServerLogger.Log(LogEvents.ConnectedToRefreshServer); } public void Dispose() diff --git a/src/BuiltInTools/dotnet-watch.slnf b/src/BuiltInTools/dotnet-watch.slnf index 09d529c61e9c..b7454eb8869a 100644 --- a/src/BuiltInTools/dotnet-watch.slnf +++ b/src/BuiltInTools/dotnet-watch.slnf @@ -18,10 +18,11 @@ "src\\BuiltInTools\\HotReloadClient\\Microsoft.DotNet.HotReload.Client.shproj", "src\\BuiltInTools\\dotnet-watch\\dotnet-watch.csproj", "test\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj", + "test\\Microsoft.DotNet.HotReload.Client.Tests\\Microsoft.DotNet.HotReload.Client.Tests.csproj", "test\\Microsoft.Extensions.DotNetDeltaApplier.Tests\\Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj", "test\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj", "test\\Microsoft.WebTools.AspireService.Tests\\Microsoft.WebTools.AspireService.Tests.csproj", - "test\\Microsoft.DotNet.HotReload.Client.Tests\\Microsoft.DotNet.HotReload.Client.Tests.csproj", + "test\\dotnet-watch-test-browser\\dotnet-watch-test-browser.csproj", "test\\dotnet-watch.Tests\\dotnet-watch.Tests.csproj" ] } diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs index 1c345803c215..c841191e0208 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs @@ -4,13 +4,14 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; -internal sealed class BrowserLauncher(ILogger logger, EnvironmentOptions environmentOptions) +internal sealed class BrowserLauncher(ILogger logger, IProcessOutputReporter processOutputReporter, EnvironmentOptions environmentOptions) { // interlocked private ImmutableHashSet _browserLaunchAttempted = []; @@ -61,18 +62,13 @@ public static string GetLaunchUrl(string? profileLaunchUrl, string outputLaunchU private void LaunchBrowser(string launchUrl, AbstractBrowserRefreshServer? server) { - var fileName = launchUrl; + var (fileName, arg, useShellExecute) = environmentOptions.BrowserPath is { } browserPath + ? (browserPath, launchUrl, false) + : (launchUrl, null, true); - var args = string.Empty; - if (environmentOptions.BrowserPath is { } browserPath) - { - args = fileName; - fileName = browserPath; - } + logger.Log(MessageDescriptor.LaunchingBrowser, fileName, arg); - logger.LogDebug("Launching browser: {FileName} {Args}", fileName, args); - - if (environmentOptions.TestFlags != TestFlags.None) + if (environmentOptions.TestFlags != TestFlags.None && environmentOptions.BrowserPath == null) { if (environmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) { @@ -83,29 +79,23 @@ private void LaunchBrowser(string launchUrl, AbstractBrowserRefreshServer? serve return; } - var info = new ProcessStartInfo + // dotnet-watch, by default, relies on URL file association to launch browsers. On Windows and MacOS, this works fairly well + // where URLs are associated with the default browser. On Linux, this is a bit murky. + // 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. + var processSpec = new ProcessSpec() { - FileName = fileName, - Arguments = args, - UseShellExecute = true, + Executable = fileName, + Arguments = arg != null ? [arg] : [], + UseShellExecute = useShellExecute, + OnOutput = environmentOptions.TestFlags.HasFlag(TestFlags.RedirectBrowserOutput) ? processOutputReporter.ReportOutput : null, }; - try - { - using var browserProcess = Process.Start(info); - if (browserProcess is null or { HasExited: true }) - { - // dotnet-watch, by default, relies on URL file association to launch browsers. On Windows and MacOS, this works fairly well - // where URLs are associated with the default browser. On Linux, this is a bit murky. - // 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. - logger.LogInformation("Unable to launch the browser. Url '{Url}'.", launchUrl); - } - } - catch (Exception e) + using var browserProcess = ProcessRunner.TryStartProcess(processSpec, logger); + if (browserProcess is null or { HasExited: true }) { - logger.LogDebug("Failed to launch a browser: {Message}", e.Message); + logger.LogWarning("Unable to launch the browser. Url '{Url}'.", launchUrl); } } diff --git a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs index ca8f616ebcce..56bcba3427e6 100644 --- a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs +++ b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs @@ -43,6 +43,10 @@ internal static class ProjectGraphUtilities } catch (Exception e) when (e is not OperationCanceledException) { + // ProejctGraph aggregates OperationCanceledException exception, + // throw here to propagate the cancellation. + cancellationToken.ThrowIfCancellationRequested(); + logger.LogDebug("Failed to load project graph."); if (e is AggregateException { InnerExceptions: var innerExceptions }) diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs index f2fbfa7cabd3..72f787332533 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs @@ -22,6 +22,11 @@ internal enum TestFlags /// This allows tests to trigger key based events. /// ReadKeyFromStdin = 1 << 3, + + /// + /// Redirects the output of the launched browser process to watch output. + /// + RedirectBrowserOutput = 1 << 4, } internal sealed record EnvironmentOptions( diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index cedccb2351f1..f99a3fd4c1db 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -363,7 +363,7 @@ private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates2 updat _logger.Log(MessageDescriptor.RestartNeededToApplyChanges); } - var diagnosticsToDisplayInApp = new List(); + var errorsToDisplayInApp = new List(); // Display errors first, then warnings: ReportCompilationDiagnostics(DiagnosticSeverity.Error); @@ -373,7 +373,7 @@ private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates2 updat // report or clear diagnostics in the browser UI await ForEachProjectAsync( _runningProjects, - (project, cancellationToken) => project.Clients.ReportCompilationErrorsInApplicationAsync([.. diagnosticsToDisplayInApp], cancellationToken).AsTask() ?? Task.CompletedTask, + (project, cancellationToken) => project.Clients.ReportCompilationErrorsInApplicationAsync([.. errorsToDisplayInApp], cancellationToken).AsTask() ?? Task.CompletedTask, cancellationToken); void ReportCompilationDiagnostics(DiagnosticSeverity severity) @@ -437,16 +437,20 @@ void ReportRudeEdits() bool IsAutoRestartEnabled(ProjectId id) => runningProjectInfos.TryGetValue(id, out var info) && info.RestartWhenChangesHaveNoEffect; - void ReportDiagnostic(Diagnostic diagnostic, MessageDescriptor descriptor, string prefix = "") + void ReportDiagnostic(Diagnostic diagnostic, MessageDescriptor descriptor, string autoPrefix = "") { var display = CSharpDiagnosticFormatter.Instance.Format(diagnostic); - var args = new[] { prefix, display }; + var args = new[] { autoPrefix, display }; _logger.Log(descriptor, args); - if (descriptor.Severity != MessageSeverity.None) + if (autoPrefix != "") { - diagnosticsToDisplayInApp.Add(descriptor.GetMessage(args)); + errorsToDisplayInApp.Add(MessageDescriptor.RestartingApplicationToApplyChanges.GetMessage()); + } + else if (descriptor.Severity != MessageSeverity.None) + { + errorsToDisplayInApp.Add(descriptor.GetMessage(args)); } } diff --git a/src/BuiltInTools/dotnet-watch/Process/ProcessRunner.cs b/src/BuiltInTools/dotnet-watch/Process/ProcessRunner.cs index 3583dc07b8d8..efb4ed6ec6c3 100644 --- a/src/BuiltInTools/dotnet-watch/Process/ProcessRunner.cs +++ b/src/BuiltInTools/dotnet-watch/Process/ProcessRunner.cs @@ -9,10 +9,15 @@ namespace Microsoft.DotNet.Watch { internal sealed class ProcessRunner(TimeSpan processCleanupTimeout) { - private sealed class ProcessState + private sealed class ProcessState(Process process) : IDisposable { + public Process Process { get; } = process; + public int ProcessId; public bool HasExited; + + public void Dispose() + => Process.Dispose(); } // For testing purposes only, lock on access. @@ -31,67 +36,32 @@ public static IReadOnlyCollection GetRunningApplicationProcesses() /// public async Task RunAsync(ProcessSpec processSpec, ILogger logger, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken) { - var state = new ProcessState(); var stopwatch = new Stopwatch(); - - var onOutput = processSpec.OnOutput; - - using var process = CreateProcess(processSpec, onOutput, state, logger); - stopwatch.Start(); - Exception? launchException = null; - try - { - if (!process.Start()) - { - throw new InvalidOperationException("Process can't be started."); - } - - state.ProcessId = process.Id; - - if (processSpec.IsUserApplication) - { - lock (s_runningApplicationProcesses) - { - s_runningApplicationProcesses.Add(state.ProcessId); - } - } - - if (onOutput != null) - { - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - } - } - catch (Exception e) - { - launchException = e; - } - - var argsDisplay = processSpec.GetArgumentsDisplay(); - if (launchException == null) + using var state = TryStartProcessImpl(processSpec, logger); + if (state == null) { - logger.Log(MessageDescriptor.LaunchedProcess, processSpec.Executable, argsDisplay, state.ProcessId); - } - else - { - logger.Log(MessageDescriptor.FailedToLaunchProcess, processSpec.Executable, argsDisplay, launchException.Message); return int.MinValue; } - if (launchResult != null) + if (processSpec.IsUserApplication) { - launchResult.ProcessId = process.Id; + lock (s_runningApplicationProcesses) + { + s_runningApplicationProcesses.Add(state.ProcessId); + } } + launchResult?.ProcessId = state.ProcessId; + int? exitCode = null; try { try { - await process.WaitForExitAsync(processTerminationToken); + await state.Process.WaitForExitAsync(processTerminationToken); } catch (OperationCanceledException) { @@ -99,7 +69,7 @@ public async Task RunAsync(ProcessSpec processSpec, ILogger logger, Process // Either Ctrl+C was pressed or the process is being restarted. // Non-cancellable to not leave orphaned processes around blocking resources: - await TerminateProcessAsync(process, processSpec, state, logger, CancellationToken.None); + await TerminateProcessAsync(state.Process, processSpec, state, logger, CancellationToken.None); } } catch (Exception e) @@ -125,14 +95,14 @@ public async Task RunAsync(ProcessSpec processSpec, ILogger logger, Process try { - exitCode = process.ExitCode; + exitCode = state.Process.ExitCode; } catch { exitCode = null; } - logger.Log(MessageDescriptor.ProcessRunAndExited, process.Id, stopwatch.ElapsedMilliseconds, exitCode); + logger.Log(MessageDescriptor.ProcessRunAndExited, state.ProcessId, stopwatch.ElapsedMilliseconds, exitCode); if (processSpec.IsUserApplication) { @@ -159,21 +129,28 @@ public async Task RunAsync(ProcessSpec processSpec, ILogger logger, Process return exitCode ?? int.MinValue; } - private static Process CreateProcess(ProcessSpec processSpec, Action? onOutput, ProcessState state, ILogger logger) + internal static Process? TryStartProcess(ProcessSpec processSpec, ILogger logger) + => TryStartProcessImpl(processSpec, logger)?.Process; + + private static ProcessState? TryStartProcessImpl(ProcessSpec processSpec, ILogger logger) { + var onOutput = processSpec.OnOutput; + var process = new Process { EnableRaisingEvents = true, StartInfo = { FileName = processSpec.Executable, - UseShellExecute = false, + UseShellExecute = processSpec.UseShellExecute, WorkingDirectory = processSpec.WorkingDirectory, RedirectStandardOutput = onOutput != null, RedirectStandardError = onOutput != null, } }; + var state = new ProcessState(process); + if (processSpec.IsUserApplication && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { process.StartInfo.CreateNewProcessGroup = true; @@ -229,7 +206,32 @@ private static Process CreateProcess(ProcessSpec processSpec, Action }; } - return process; + var argsDisplay = processSpec.GetArgumentsDisplay(); + + try + { + if (!process.Start()) + { + throw new InvalidOperationException("Process can't be started."); + } + state.ProcessId = process.Id; + + if (onOutput != null) + { + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + + logger.Log(MessageDescriptor.LaunchedProcess, processSpec.Executable, argsDisplay, state.ProcessId); + return state; + } + catch (Exception e) + { + logger.Log(MessageDescriptor.FailedToLaunchProcess, processSpec.Executable, argsDisplay, e.Message); + + state.Dispose(); + return null; + } } private async ValueTask TerminateProcessAsync(Process process, ProcessSpec processSpec, ProcessState state, ILogger logger, CancellationToken cancellationToken) diff --git a/src/BuiltInTools/dotnet-watch/Process/ProcessSpec.cs b/src/BuiltInTools/dotnet-watch/Process/ProcessSpec.cs index b780f2770029..b3e1eaa1a6a6 100644 --- a/src/BuiltInTools/dotnet-watch/Process/ProcessSpec.cs +++ b/src/BuiltInTools/dotnet-watch/Process/ProcessSpec.cs @@ -8,12 +8,13 @@ internal sealed class ProcessSpec { public string? Executable { get; set; } public string? WorkingDirectory { get; set; } - public Dictionary EnvironmentVariables { get; } = new(); + public Dictionary EnvironmentVariables { get; } = []; public IReadOnlyList? Arguments { get; set; } public string? EscapedArguments { get; set; } public Action? OnOutput { get; set; } public ProcessExitAction? OnExit { get; set; } public CancellationToken CancelOutputCapture { get; set; } + public bool UseShellExecute { get; set; } = false; /// /// True if the process is a user application, false if it is a helper process (e.g. dotnet build). diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index b2b9bd03bbd9..f67a360a3a32 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.cs @@ -240,7 +240,7 @@ internal DotNetWatchContext CreateContext(ProcessRunner processRunner) EnvironmentOptions = environmentOptions, RootProjectOptions = rootProjectOptions, BrowserRefreshServerFactory = new BrowserRefreshServerFactory(), - BrowserLauncher = new BrowserLauncher(logger, environmentOptions), + BrowserLauncher = new BrowserLauncher(logger, processOutputReporter, environmentOptions), }; } diff --git a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs index c6f64394da88..3d60e288a977 100644 --- a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs +++ b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs @@ -209,9 +209,12 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor UpdatingDiagnostics = Create(LogEvents.UpdatingDiagnostics, Emoji.Default); public static readonly MessageDescriptor FailedToReceiveResponseFromConnectedBrowser = Create(LogEvents.FailedToReceiveResponseFromConnectedBrowser, Emoji.Default); public static readonly MessageDescriptor NoBrowserConnected = Create(LogEvents.NoBrowserConnected, Emoji.Default); + public static readonly MessageDescriptor LaunchingBrowser = Create("Launching browser: {0} {1}", Emoji.Default, MessageSeverity.Verbose); public static readonly MessageDescriptor RefreshingBrowser = Create(LogEvents.RefreshingBrowser, Emoji.Default); public static readonly MessageDescriptor ReloadingBrowser = Create(LogEvents.ReloadingBrowser, Emoji.Default); public static readonly MessageDescriptor RefreshServerRunningAt = Create(LogEvents.RefreshServerRunningAt, Emoji.Default); + public static readonly MessageDescriptor ConnectedToRefreshServer = Create(LogEvents.ConnectedToRefreshServer, Emoji.Default); + public static readonly MessageDescriptor RestartingApplicationToApplyChanges = Create("Restarting application to apply changes ...", Emoji.Default, MessageSeverity.Output); public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = Create("Ignoring change in hidden directory '{0}': {1} '{2}'", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = Create("Ignoring change in output directory: {0} '{1}'", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor IgnoringChangeInExcludedFile = Create("Ignoring change in excluded file '{0}': {1}. Path matches {2} glob '{3}' set in '{4}'.", Emoji.Watch, MessageSeverity.Verbose); diff --git a/src/Cli/dotnet/Program.cs b/src/Cli/dotnet/Program.cs index cd82a15330f4..a0dd8d0a9c34 100644 --- a/src/Cli/dotnet/Program.cs +++ b/src/Cli/dotnet/Program.cs @@ -6,6 +6,7 @@ using System.CommandLine; using System.CommandLine.Parsing; using System.Diagnostics; +using System.Runtime.InteropServices; using Microsoft.DotNet.Cli.CommandFactory; using Microsoft.DotNet.Cli.CommandFactory.CommandResolution; using Microsoft.DotNet.Cli.Commands.Run; @@ -29,6 +30,10 @@ public class Program public static ITelemetry TelemetryClient; public static int Main(string[] args) { + // Register a handler for SIGTERM to allow graceful shutdown of the application on Unix. + // See https://github.com/dotnet/docs/issues/46226. + using var termSignalRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, _ => Environment.Exit(0)); + using AutomaticEncodingRestorer _ = new(); // Setting output encoding is not available on those platforms diff --git a/test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs b/test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs index f1d2a3b77bbd..7171e0a19260 100644 --- a/test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs +++ b/test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs @@ -8,6 +8,7 @@ using System.Runtime.Versioning; using System.Threading; using System.Threading.Tasks; +using System.Runtime.InteropServices; // diff --git a/test/TestAssets/TestProjects/WatchRazorWithDeps/RazorApp/Components/Pages/Home.razor b/test/TestAssets/TestProjects/WatchRazorWithDeps/RazorApp/Components/Pages/Home.razor index bb44fc2639e7..2daa1cadc252 100644 --- a/test/TestAssets/TestProjects/WatchRazorWithDeps/RazorApp/Components/Pages/Home.razor +++ b/test/TestAssets/TestProjects/WatchRazorWithDeps/RazorApp/Components/Pages/Home.razor @@ -5,4 +5,11 @@ -Welcome to your new app. \ No newline at end of file +Welcome to your new app. + +@code{ + class C + { + /* member placeholder */ + } +} \ No newline at end of file diff --git a/test/dotnet-watch-test-browser/Program.cs b/test/dotnet-watch-test-browser/Program.cs new file mode 100644 index 000000000000..78f96cb73ccb --- /dev/null +++ b/test/dotnet-watch-test-browser/Program.cs @@ -0,0 +1,146 @@ +using System; +using System.Buffers; +using System.Linq; +using System.Net.Http; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +if (args is not [var urlArg]) +{ + Console.Error.WriteLine(); + return -1; +} + +Log($"Test browser opened at '{urlArg}'."); + +var url = new Uri(urlArg, UriKind.Absolute); + +var (webSocketUrls, publicKey) = await GetWebSocketUrlsAndPublicKey(url); + +var secret = RandomNumberGenerator.GetBytes(32); +var encryptedSecret = GetEncryptedSecret(publicKey, secret); + +using var webSocket = await OpenWebSocket(webSocketUrls, encryptedSecret); +var buffer = new byte[8 * 1024]; + +while (await TryReceiveMessageAsync(webSocket, message => Log($"Received: {Encoding.UTF8.GetString(message)}"))) +{ +} + +Log("WebSocket closed"); + +return 0; + +static async Task OpenWebSocket(string[] urls, string encryptedSecret) +{ + foreach (var url in urls) + { + try + { + var webSocket = new ClientWebSocket(); + webSocket.Options.AddSubProtocol(Uri.EscapeDataString(encryptedSecret)); + await webSocket.ConnectAsync(new Uri(url), CancellationToken.None); + return webSocket; + } + catch (Exception e) + { + Log($"Error connecting to '{url}': {e.Message}"); + } + } + + throw new InvalidOperationException("Unable to establish a connection."); +} + +static async ValueTask TryReceiveMessageAsync(WebSocket socket, Action> receiver) +{ + var writer = new ArrayBufferWriter(initialCapacity: 1024); + + while (true) + { + ValueWebSocketReceiveResult result; + var data = writer.GetMemory(); + try + { + result = await socket.ReceiveAsync(data, CancellationToken.None); + } + catch (Exception e) when (e is not OperationCanceledException) + { + Log($"Failed to receive response: {e.Message}"); + return false; + } + + if (result.MessageType == WebSocketMessageType.Close) + { + return false; + } + + writer.Advance(result.Count); + if (result.EndOfMessage) + { + break; + } + } + + receiver(writer.WrittenSpan); + return true; +} + +static async Task<(string[] url, string key)> GetWebSocketUrlsAndPublicKey(Uri baseUrl) +{ + var refreshScriptUrl = new Uri(baseUrl, "/_framework/aspnetcore-browser-refresh.js"); + + Log($"Fetching: {refreshScriptUrl}"); + + using var httpClient = new HttpClient(); + var content = await httpClient.GetStringAsync(refreshScriptUrl); + + Log($"Request for '{refreshScriptUrl}' succeeded"); + var webSocketUrl = GetWebSocketUrls(content); + var key = GetSharedSecretKey(content); + + Log($"WebSocket urls are '{string.Join(',', webSocketUrl)}'."); + Log($"Key is '{key}'."); + + return (webSocketUrl, key); +} + +static string[] GetWebSocketUrls(string refreshScript) +{ + var pattern = "const webSocketUrls = '([^']+)'"; + + var match = Regex.Match(refreshScript, pattern); + if (!match.Success) + { + throw new InvalidOperationException($"Can't find web socket URL pattern in the script: {pattern}{Environment.NewLine}{refreshScript}"); + } + + return match.Groups[1].Value.Split(","); +} + +static string GetSharedSecretKey(string refreshScript) +{ + var pattern = @"const sharedSecret = await getSecret\('([^']+)'\)"; + + var match = Regex.Match(refreshScript, pattern); + if (!match.Success) + { + throw new InvalidOperationException($"Can't find web socket shared secret pattern in the script: {pattern}{Environment.NewLine}{refreshScript}"); + } + + return match.Groups[1].Value; +} + +// Equivalent to getSecret function in WebSocketScriptInjection.js: +static string GetEncryptedSecret(string key, byte[] secret) +{ + using var rsa = RSA.Create(); + rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(key), out _); + return Convert.ToBase64String(rsa.Encrypt(secret, RSAEncryptionPadding.OaepSHA256)); +} + +static void Log(string message) + => Console.WriteLine($"🧪 {message}"); diff --git a/test/dotnet-watch-test-browser/dotnet-watch-test-browser.csproj b/test/dotnet-watch-test-browser/dotnet-watch-test-browser.csproj new file mode 100644 index 000000000000..408cb9159c99 --- /dev/null +++ b/test/dotnet-watch-test-browser/dotnet-watch-test-browser.csproj @@ -0,0 +1,14 @@ + + + Exe + $(ToolsetTargetFramework) + MicrosoftAspNetCore + Microsoft.DotNet.Watch.UnitTests + + + + + + + + diff --git a/test/dotnet-watch.Tests/Browser/BrowserLaunchTests.cs b/test/dotnet-watch.Tests/Browser/BrowserLaunchTests.cs deleted file mode 100644 index f5fbf358d819..000000000000 --- a/test/dotnet-watch.Tests/Browser/BrowserLaunchTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.DotNet.Watch.UnitTests -{ - public class BrowserLaunchTests : DotNetWatchTestBase - { - private const string AppName = "WatchBrowserLaunchApp"; - - public BrowserLaunchTests(ITestOutputHelper logger) - : base(logger) - { - } - - [Fact] - public async Task LaunchesBrowserOnStart() - { - var testAsset = TestAssets.CopyTestAsset(AppName) - .WithSource(); - - App.Start(testAsset, [], testFlags: TestFlags.MockBrowser); - - // check that all app output is printed out: - await App.WaitForOutputLineContaining("Content root path:"); - - Assert.Contains(App.Process.Output, line => line.Contains("Application started. Press Ctrl+C to shut down.")); - Assert.Contains(App.Process.Output, line => line.Contains("Hosting environment: Development")); - - // Verify we launched the browser. - Assert.Contains(App.Process.Output, line => line.Contains("dotnet watch ⌚ Launching browser: https://localhost:5001")); - } - - [Fact] - public async Task UsesBrowserSpecifiedInEnvironment() - { - var testAsset = TestAssets.CopyTestAsset(AppName) - .WithSource(); - - App.EnvironmentVariables.Add("DOTNET_WATCH_BROWSER_PATH", "mycustombrowser.bat"); - - App.Start(testAsset, [], testFlags: TestFlags.MockBrowser); - await App.WaitForOutputLineContaining(MessageDescriptor.ConfiguredToUseBrowserRefresh); - await App.WaitForOutputLineContaining(MessageDescriptor.ConfiguredToLaunchBrowser); - - // Verify we launched the browser. - await App.AssertOutputLineStartsWith("dotnet watch ⌚ Launching browser: mycustombrowser.bat https://localhost:5001"); - } - } -} diff --git a/test/dotnet-watch.Tests/Browser/BrowserTests.cs b/test/dotnet-watch.Tests/Browser/BrowserTests.cs new file mode 100644 index 000000000000..9242c328b373 --- /dev/null +++ b/test/dotnet-watch.Tests/Browser/BrowserTests.cs @@ -0,0 +1,110 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class BrowserTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger) +{ + [Fact] + public async Task LaunchesBrowserOnStart() + { + var testAsset = TestAssets.CopyTestAsset("WatchBrowserLaunchApp") + .WithSource(); + + App.Start(testAsset, [], testFlags: TestFlags.MockBrowser); + + // check that all app output is printed out: + await App.WaitForOutputLineContaining("Content root path:"); + + Assert.Contains(App.Process.Output, line => line.Contains("Application started. Press Ctrl+C to shut down.")); + Assert.Contains(App.Process.Output, line => line.Contains("Hosting environment: Development")); + + // Verify we launched the browser. + App.AssertOutputContains(MessageDescriptor.LaunchingBrowser.GetMessage("https://localhost:5001", "")); + } + + [Fact] + public async Task BrowserDiagnostics() + { + var testAsset = TestAssets.CopyTestAsset("WatchRazorWithDeps") + .WithSource(); + + App.UseTestBrowser(); + + var url = $"http://localhost:{TestOptions.GetTestPort()}"; + var tfm = ToolsetInfo.CurrentTargetFramework; + + App.Start(testAsset, ["--urls", url], relativeProjectDirectory: "RazorApp", testFlags: TestFlags.ReadKeyFromStdin); + + await App.WaitForOutputLineContaining(MessageDescriptor.ConfiguredToUseBrowserRefresh); + await App.WaitForOutputLineContaining(MessageDescriptor.ConfiguredToLaunchBrowser); + await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); + + // Verify the browser has been launched. + await App.WaitUntilOutputContains($"🧪 Test browser opened at '{url}'."); + + // Verify the browser connected to the refresh server. + await App.WaitUntilOutputContains(MessageDescriptor.ConnectedToRefreshServer, "Browser #1"); + + App.Process.ClearOutput(); + + var homePagePath = Path.Combine(testAsset.Path, "RazorApp", "Components", "Pages", "Home.razor"); + + // rude edit: + UpdateSourceFile(homePagePath, src => src.Replace("/* member placeholder */", """ + public virtual int F() => 1; + """)); + + var errorMessage = $"{homePagePath}(13,9): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application."; + var jsonErrorMessage = JsonSerializer.Serialize(errorMessage); + + await App.WaitForOutputLineContaining(errorMessage); + + await App.WaitForOutputLineContaining("Do you want to restart your app?"); + + await App.WaitUntilOutputContains($$""" + 🧪 Received: {"type":"HotReloadDiagnosticsv1","diagnostics":[{{jsonErrorMessage}}]} + """); + + // auto restart next time: + App.SendKey('a'); + + // browser page is reloaded when the app restarts: + await App.WaitForOutputLineContaining(MessageDescriptor.ReloadingBrowser, $"RazorApp ({tfm})"); + + // browser page was reloaded after the app restarted: + await App.WaitUntilOutputContains(""" + 🧪 Received: Reload + """); + + await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); + + // another rude edit: + UpdateSourceFile(homePagePath, src => src.Replace("public virtual int F() => 1;", "/* member placeholder */")); + + errorMessage = $"{homePagePath}(11,5): error ENC0033: Deleting method 'F()' requires restarting the application."; + await App.WaitForOutputLineContaining("[auto-restart] " + errorMessage); + + await App.WaitUntilOutputContains($$""" + 🧪 Received: {"type":"HotReloadDiagnosticsv1","diagnostics":["Restarting application to apply changes ..."]} + """); + + await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); + + // browser page was reloaded after the app restarted: + await App.WaitUntilOutputContains(""" + 🧪 Received: Reload + """); + + // valid edit: + UpdateSourceFile(homePagePath, src => src.Replace("/* member placeholder */", "public int F() => 1;")); + + await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadSucceeded); + + await App.WaitUntilOutputContains($$""" + 🧪 Received: {"type":"AspNetCoreHotReloadApplied"} + """); + } +} diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index a7a1bcda1923..048ecd8830bd 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -721,6 +721,8 @@ class AppUpdateHandler [PlatformSpecificFact(TestPlatforms.Windows)] public async Task GracefulTermination_Windows() { + var tfm = ToolsetInfo.CurrentTargetFramework; + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") .WithSource(); @@ -739,7 +741,7 @@ public async Task GracefulTermination_Windows() await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); - await App.WaitUntilOutputContains(new Regex(@"dotnet watch 🕵️ \[.*\] Windows Ctrl\+C handling enabled.")); + await App.WaitUntilOutputContains($"dotnet watch 🕵️ [WatchHotReloadApp ({tfm})] Windows Ctrl+C handling enabled."); await App.WaitUntilOutputContains("Started"); @@ -749,7 +751,38 @@ public async Task GracefulTermination_Windows() await App.WaitUntilOutputContains("exited with exit code 0."); } - [PlatformSpecificTheory(TestPlatforms.Windows, Skip = "https://github.com/dotnet/sdk/issues/49928")] // https://github.com/dotnet/sdk/issues/49307 + [PlatformSpecificFact(TestPlatforms.AnyUnix)] + public async Task GracefulTermination_Unix() + { + var tfm = ToolsetInfo.CurrentTargetFramework; + + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") + .WithSource(); + + var programPath = Path.Combine(testAsset.Path, "Program.cs"); + + UpdateSourceFile(programPath, src => src.Replace("// ", """ + using var termSignalRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, _ => + { + Console.WriteLine("SIGTERM detected! Performing cleanup..."); + }); + """)); + + App.Start(testAsset, [], testFlags: TestFlags.ReadKeyFromStdin); + + await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); + + await App.WaitUntilOutputContains($"dotnet watch 🕵️ [WatchHotReloadApp ({tfm})] Posix signal handlers registered."); + + await App.WaitUntilOutputContains("Started"); + + App.SendControlC(); + + await App.WaitForOutputLineContaining("SIGTERM detected! Performing cleanup..."); + await App.WaitUntilOutputContains("exited with exit code 0."); + } + + [PlatformSpecificTheory(TestPlatforms.Windows, Skip = "https://github.com/dotnet/sdk/issues/49928")] // https://github.com/dotnet/aspnetcore/issues/63759 [CombinatorialData] public async Task BlazorWasm(bool projectSpecifiesCapabilities) { @@ -777,7 +810,7 @@ public async Task BlazorWasm(bool projectSpecifiesCapabilities) App.AssertOutputContains(MessageDescriptor.ConfiguredToLaunchBrowser); // Browser is launched based on blazor-devserver output "Now listening on: ...". - await App.WaitUntilOutputContains($"dotnet watch ⌚ Launching browser: http://localhost:{port}"); + await App.WaitUntilOutputContains(MessageDescriptor.LaunchingBrowser.GetMessage($"http://localhost:{port}", "")); // Middleware should have been loaded to blazor-devserver before the browser is launched: App.AssertOutputContains("dbug: Microsoft.AspNetCore.Watch.BrowserRefresh.BlazorWasmHotReloadMiddleware[0]"); @@ -809,7 +842,7 @@ public async Task BlazorWasm(bool projectSpecifiesCapabilities) } } - [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307") + [PlatformSpecificFact(TestPlatforms.Windows)] // https://github.com/dotnet/aspnetcore/issues/63759 public async Task BlazorWasm_MSBuildWarning() { var testAsset = TestAssets @@ -831,7 +864,7 @@ public async Task BlazorWasm_MSBuildWarning() await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); } - [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307") + [PlatformSpecificFact(TestPlatforms.Windows)] // https://github.com/dotnet/aspnetcore/issues/63759 public async Task BlazorWasm_Restart() { var testAsset = TestAssets.CopyTestAsset("WatchBlazorWasm") @@ -847,14 +880,14 @@ public async Task BlazorWasm_Restart() App.AssertOutputContains(MessageDescriptor.PressCtrlRToRestart); // Browser is launched based on blazor-devserver output "Now listening on: ...". - await App.WaitUntilOutputContains($"dotnet watch ⌚ Launching browser: http://localhost:{port}"); + await App.WaitUntilOutputContains(MessageDescriptor.LaunchingBrowser.GetMessage($"http://localhost:{port}", "")); App.SendControlR(); await App.WaitUntilOutputContains(MessageDescriptor.ReloadingBrowser); } - [PlatformSpecificFact(TestPlatforms.Windows, Skip = "https://github.com/dotnet/sdk/issues/49928")] // "https://github.com/dotnet/sdk/issues/49307") + [PlatformSpecificFact(TestPlatforms.Windows, Skip = "https://github.com/dotnet/sdk/issues/49928")] // https://github.com/dotnet/aspnetcore/issues/63759 public async Task BlazorWasmHosted() { var testAsset = TestAssets.CopyTestAsset("WatchBlazorWasmHosted") @@ -878,7 +911,7 @@ public async Task BlazorWasmHosted() App.AssertOutputContains($"dotnet watch ⌚ [blazorhosted ({tfm})] Capabilities: 'Baseline AddMethodToExistingType AddStaticFieldToExistingType AddInstanceFieldToExistingType NewTypeDefinition ChangeCustomAttributes UpdateParameters GenericUpdateMethod GenericAddMethodToExistingType GenericAddFieldToExistingType AddFieldRva'"); } - [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307") + [PlatformSpecificFact(TestPlatforms.Windows)] // https://github.com/dotnet/aspnetcore/issues/63759 public async Task Razor_Component_ScopedCssAndStaticAssets() { var testAsset = TestAssets.CopyTestAsset("WatchRazorWithDeps") @@ -891,7 +924,7 @@ public async Task Razor_Component_ScopedCssAndStaticAssets() App.AssertOutputContains(MessageDescriptor.ConfiguredToUseBrowserRefresh); App.AssertOutputContains(MessageDescriptor.ConfiguredToLaunchBrowser); - App.AssertOutputContains($"dotnet watch ⌚ Launching browser: http://localhost:{port}"); + App.AssertOutputContains(MessageDescriptor.LaunchingBrowser.GetMessage($"http://localhost:{port}", "")); App.Process.ClearOutput(); var scopedCssPath = Path.Combine(testAsset.Path, "RazorClassLibrary", "Components", "Example.razor.css"); @@ -1114,7 +1147,7 @@ public static void PrintDirectoryName([CallerFilePathAttribute] string filePath await App.AssertOutputLineStartsWith("> NewSubdir", failure: _ => false); } - [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307") + [PlatformSpecificFact(TestPlatforms.Windows)] // https://github.com/dotnet/aspnetcore/issues/63759 public async Task Aspire_BuildError_ManualRestart() { var tfm = ToolsetInfo.CurrentTargetFramework; @@ -1210,7 +1243,7 @@ public async Task Aspire_BuildError_ManualRestart() App.AssertOutputContains("dotnet watch ⭐ [#1] Sending 'sessionTerminated'"); } - [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307") + [PlatformSpecificFact(TestPlatforms.Windows)] // https://github.com/dotnet/aspnetcore/issues/63759 public async Task Aspire_NoEffect_AutoRestart() { var tfm = ToolsetInfo.CurrentTargetFramework; @@ -1245,8 +1278,8 @@ public async Task Aspire_NoEffect_AutoRestart() await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadChangeHandled); App.AssertOutputContains($"dotnet watch 🕵️ [WatchAspire.Web ({tfm})] Updates applied."); - App.AssertOutputDoesNotContain("Projects rebuilt"); - App.AssertOutputDoesNotContain("Projects restarted"); + App.AssertOutputDoesNotContain(MessageDescriptor.ProjectsRebuilt); + App.AssertOutputDoesNotContain(MessageDescriptor.ProjectsRestarted); App.AssertOutputDoesNotContain("⚠"); } } diff --git a/test/dotnet-watch.Tests/TestUtilities/AssertEx.cs b/test/dotnet-watch.Tests/TestUtilities/AssertEx.cs index 6f2b2fc7e30a..efdc4720c243 100644 --- a/test/dotnet-watch.Tests/TestUtilities/AssertEx.cs +++ b/test/dotnet-watch.Tests/TestUtilities/AssertEx.cs @@ -117,11 +117,11 @@ public static void Equal(T expected, T actual, IEqualityComparer? comparer if (expected == null) { - Fail("expected was null, but actual wasn't" + Environment.NewLine + message); + Fail("pattern was null, but actual wasn't" + Environment.NewLine + message); } else if (actual == null) { - Fail("actual was null, but expected wasn't" + Environment.NewLine + message); + Fail("actual was null, but pattern wasn't" + Environment.NewLine + message); } else if (!(comparer ?? AssertEqualityComparer.Instance).Equals(expected, actual)) { @@ -235,14 +235,20 @@ public static void EqualFileList(IEnumerable expectedFiles, IEnumerable< } public static void ContainsSubstring(string expected, IEnumerable items) + => AssertSubstringPresence(expected, items, expectedPresent: true); + + public static void DoesNotContainSubstring(string expected, IEnumerable items) + => AssertSubstringPresence(expected, items, expectedPresent: false); + + private static void AssertSubstringPresence(string expected, IEnumerable items, bool expectedPresent) { - if (items.Any(item => item.Contains(expected))) + if (items.Any(item => item.Contains(expected)) == expectedPresent) { return; } var message = new StringBuilder(); - message.AppendLine($"Expected output not found:"); + message.AppendLine($"Expected output {(expectedPresent ? "not found" : "found")}:"); message.AppendLine(expected); message.AppendLine(); message.AppendLine("Actual output:"); @@ -256,15 +262,21 @@ public static void ContainsSubstring(string expected, IEnumerable items) } public static void ContainsPattern(Regex expected, IEnumerable items) + => AssertPatternPresence(expected, items, expectedPresent: true); + + public static void DoesNotContainPattern(Regex pattern, IEnumerable items) + => AssertPatternPresence(pattern, items, expectedPresent: false); + + private static void AssertPatternPresence(Regex pattern, IEnumerable items, bool expectedPresent) { - if (items.Any(item => expected.IsMatch(item))) + if (items.Any(item => pattern.IsMatch(item)) == expectedPresent) { return; } var message = new StringBuilder(); - message.AppendLine($"Expected pattern not found in the output:"); - message.AppendLine(expected.ToString()); + message.AppendLine($"Expected pattern {(expectedPresent ? "not found" : "found")} in the output:"); + message.AppendLine(pattern.ToString()); message.AppendLine(); message.AppendLine("Actual output:"); diff --git a/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs b/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs index 8888b5634398..81bfd17aae20 100644 --- a/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs +++ b/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs @@ -32,15 +32,21 @@ internal sealed class WatchableApp(DebugTestOutputLogger logger) : IDisposable public void AssertOutputContains(string message) => AssertEx.ContainsSubstring(message, Process.Output); - public void AssertOutputDoesNotContain(string message) - => Assert.DoesNotContain(Process.Output, line => line.Contains(message)); - public void AssertOutputContains(Regex pattern) => AssertEx.ContainsPattern(pattern, Process.Output); public void AssertOutputContains(MessageDescriptor descriptor, string projectDisplay = null) => AssertOutputContains(GetPattern(descriptor, projectDisplay)); + public void AssertOutputDoesNotContain(string message) + => AssertEx.DoesNotContainSubstring(message, Process.Output); + + public void AssertOutputDoesNotContain(Regex pattern) + => AssertEx.DoesNotContainPattern(pattern, Process.Output); + + public void AssertOutputDoesNotContain(MessageDescriptor descriptor, string projectDisplay = null) + => AssertOutputDoesNotContain(GetPattern(descriptor, projectDisplay)); + private static Regex GetPattern(MessageDescriptor descriptor, string projectDisplay = null) => new Regex(Regex.Replace(Regex.Escape((projectDisplay != null ? $"[{projectDisplay}] " : "") + descriptor.Format), @"\\\{[0-9]+\}", ".*")); @@ -203,5 +209,22 @@ public void SendKey(char c) Process.Process.StandardInput.Write(c); Process.Process.StandardInput.Flush(); } + + public void UseTestBrowser() + { + var path = GetTestBrowserPath(); + EnvironmentVariables.Add("DOTNET_WATCH_BROWSER_PATH", path); + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, UnixFileMode.UserExecute); + } + } + + public static string GetTestBrowserPath() + { + var exeExtension = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + return Path.Combine(Path.GetDirectoryName(typeof(WatchableApp).Assembly.Location!)!, "test-browser", "dotnet-watch-test-browser" + exeExtension); + } } } diff --git a/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs b/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs index 1efccba2eb22..d673b618826c 100644 --- a/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs +++ b/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs @@ -16,9 +16,11 @@ private static DotNetWatchContext CreateContext(bool suppressMSBuildIncrementali SuppressMSBuildIncrementalism = suppressMSBuildIncrementalism }; + var processOutputReporter = new TestProcessOutputReporter(); + return new DotNetWatchContext() { - ProcessOutputReporter = new TestProcessOutputReporter(), + ProcessOutputReporter = processOutputReporter, Logger = NullLogger.Instance, BuildLogger = NullLogger.Instance, LoggerFactory = NullLoggerFactory.Instance, @@ -26,7 +28,7 @@ private static DotNetWatchContext CreateContext(bool suppressMSBuildIncrementali Options = new(), RootProjectOptions = TestOptions.ProjectOptions, EnvironmentOptions = environmentOptions, - BrowserLauncher = new BrowserLauncher(NullLogger.Instance, environmentOptions), + BrowserLauncher = new BrowserLauncher(NullLogger.Instance, processOutputReporter, environmentOptions), BrowserRefreshServerFactory = new BrowserRefreshServerFactory() }; } diff --git a/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs b/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs index a28d91c3423e..895a9d6b779e 100644 --- a/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs +++ b/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs @@ -13,9 +13,11 @@ private static DotNetWatchContext CreateContext(string[] args = null, Environmen { environmentOptions ??= TestOptions.GetEnvironmentOptions(); + var processOutputReporter = new TestProcessOutputReporter(); + return new() { - ProcessOutputReporter = new TestProcessOutputReporter(), + ProcessOutputReporter = processOutputReporter, LoggerFactory = NullLoggerFactory.Instance, Logger = NullLogger.Instance, BuildLogger = NullLogger.Instance, @@ -23,7 +25,7 @@ private static DotNetWatchContext CreateContext(string[] args = null, Environmen Options = new(), RootProjectOptions = TestOptions.GetProjectOptions(args), EnvironmentOptions = environmentOptions, - BrowserLauncher = new BrowserLauncher(NullLogger.Instance, environmentOptions), + BrowserLauncher = new BrowserLauncher(NullLogger.Instance, processOutputReporter, environmentOptions), BrowserRefreshServerFactory = new BrowserRefreshServerFactory() }; } diff --git a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj index 6a81d97e1ed3..772d35b80517 100644 --- a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj +++ b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj @@ -16,10 +16,23 @@ --> + + + + + + <_Files>@(TestBrowserOutput->'%(RootDir)%(Directory)*.*') + + + <_FileItem Include="$(_Files)" /> + + + +