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)" />
+
+
+
+