diff --git a/src/Dotnet.Watch/.editorconfig b/src/Dotnet.Watch/.editorconfig new file mode 100644 index 000000000000..54d2f34da8e4 --- /dev/null +++ b/src/Dotnet.Watch/.editorconfig @@ -0,0 +1,26 @@ +# EditorConfig to suppress warnings/errors for Watch solution + +root = false + +[*.cs] +# CA - Code Analysis warnings +dotnet_diagnostic.CA1305.severity = none # Specify IFormatProvider +dotnet_diagnostic.CA1822.severity = none # Mark members as static +dotnet_diagnostic.CA1835.severity = none # Prefer Memory-based overloads for ReadAsync/WriteAsync +dotnet_diagnostic.CA1852.severity = none # Seal internal types +dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task +dotnet_diagnostic.CA2201.severity = none # Do not raise reserved exception types +dotnet_diagnostic.CA2008.severity = none # Do not create tasks without passing a TaskScheduler + +# CS - C# compiler warnings/errors +dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member +dotnet_diagnostic.CS1573.severity = none # Parameter 'sourceFile' has no matching param tag in the XML comment + +# IDE - IDE/Style warnings +dotnet_diagnostic.IDE0005.severity = none # Using directive is unnecessary +dotnet_diagnostic.IDE0011.severity = none # Add braces +dotnet_diagnostic.IDE0036.severity = none # Order modifiers +dotnet_diagnostic.IDE0060.severity = none # Remove unused parameter +dotnet_diagnostic.IDE0073.severity = none # File header does not match required text +dotnet_diagnostic.IDE0161.severity = none # Convert to file-scoped namespace +dotnet_diagnostic.IDE1006.severity = none # Naming rule violation diff --git a/src/Dotnet.Watch/Directory.Build.props b/src/Dotnet.Watch/Directory.Build.props new file mode 100644 index 000000000000..6fcadbd40f20 --- /dev/null +++ b/src/Dotnet.Watch/Directory.Build.props @@ -0,0 +1,6 @@ + + + + $(RepoRoot)src\Microsoft.DotNet.ProjectTools\ + + diff --git a/src/Dotnet.Watch/HotReloadClient/DefaultHotReloadClient.cs b/src/Dotnet.Watch/HotReloadClient/DefaultHotReloadClient.cs index 9ca5fb9cc271..c07dc05444fe 100644 --- a/src/Dotnet.Watch/HotReloadClient/DefaultHotReloadClient.cs +++ b/src/Dotnet.Watch/HotReloadClient/DefaultHotReloadClient.cs @@ -129,7 +129,7 @@ private async Task ListenForResponsesAsync(CancellationToken cancellationToken) { if (!cancellationToken.IsCancellationRequested) { - Logger.LogError("Failed to read response: {Message}", e.Message); + Logger.LogError("Failed to read response: {Exception}", e.ToString()); } } } diff --git a/src/Dotnet.Watch/HotReloadClient/NamedPipeClientTransport.cs b/src/Dotnet.Watch/HotReloadClient/NamedPipeClientTransport.cs index 2ab6027faf4c..0328c5aa4aca 100644 --- a/src/Dotnet.Watch/HotReloadClient/NamedPipeClientTransport.cs +++ b/src/Dotnet.Watch/HotReloadClient/NamedPipeClientTransport.cs @@ -49,7 +49,7 @@ public override void ConfigureEnvironment(IDictionary env) public override async Task WaitForConnectionAsync(CancellationToken cancellationToken) { - _logger.LogDebug("Waiting for application to connect to pipe {NamedPipeName}.", _namedPipeName); + _logger.LogDebug("Waiting for application to connect to pipe '{NamedPipeName}'.", _namedPipeName); try { diff --git a/src/Dotnet.Watch/Watch.Aspire/AspireLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/AspireLauncher.cs new file mode 100644 index 000000000000..2dee0c755caf --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/AspireLauncher.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal abstract class AspireLauncher +{ + public EnvironmentOptions EnvironmentOptions { get; } + public GlobalOptions GlobalOptions { get; } + public PhysicalConsole Console { get; } + public ConsoleReporter Reporter { get; } + public LoggerFactory LoggerFactory { get; } + public ILogger Logger { get; } + + public AspireLauncher(GlobalOptions globalOptions, EnvironmentOptions environmentOptions) + { + GlobalOptions = globalOptions; + EnvironmentOptions = environmentOptions; + Console = new PhysicalConsole(environmentOptions.TestFlags); + Reporter = new ConsoleReporter(Console, environmentOptions.LogMessagePrefix, environmentOptions.SuppressEmojis); + LoggerFactory = new LoggerFactory(Reporter, environmentOptions.CliLogLevel ?? globalOptions.LogLevel); + Logger = LoggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName); + } + + public static AspireLauncher? TryCreate(string[] args) + { + var rootCommand = new AspireRootCommand(); + + var parseResult = rootCommand.Parse(args); + if (parseResult.Errors.Count > 0) + { + foreach (var error in parseResult.Errors) + { + System.Console.Error.WriteLine(error); + } + + return null; + } + + return parseResult.CommandResult.Command switch + { + AspireServerCommandDefinition serverCommand => AspireServerLauncher.TryCreate(parseResult, serverCommand), + AspireResourceCommandDefinition resourceCommand => AspireResourceLauncher.TryCreate(parseResult, resourceCommand), + AspireHostCommandDefinition hostCommand => AspireHostLauncher.TryCreate(parseResult, hostCommand), + _ => throw new InvalidOperationException(), + }; + } + + public abstract Task LaunchAsync(CancellationToken cancellationToken); +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Commands/AspireCommandDefinition.cs b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireCommandDefinition.cs new file mode 100644 index 000000000000..0b3748dc2350 --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireCommandDefinition.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal abstract class AspireCommandDefinition : Command +{ + public readonly Option QuietOption = new("--quiet") { Arity = ArgumentArity.Zero }; + public readonly Option VerboseOption = new("--verbose") { Arity = ArgumentArity.Zero }; + + protected AspireCommandDefinition(string name, string description) + : base(name, description) + { + Options.Add(VerboseOption); + Options.Add(QuietOption); + + VerboseOption.Validators.Add(v => + { + if (v.HasOption(QuietOption) && v.HasOption(VerboseOption)) + { + v.AddError("Cannot specify both '--quiet' and '--verbose' options."); + } + }); + } + + public LogLevel GetLogLevel(ParseResult parseResult) + => parseResult.GetValue(QuietOption) ? LogLevel.Warning : parseResult.GetValue(VerboseOption) ? LogLevel.Debug : LogLevel.Information; +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Commands/AspireHostCommandDefinition.cs b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireHostCommandDefinition.cs new file mode 100644 index 000000000000..4475a7fcf2ce --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireHostCommandDefinition.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireHostCommandDefinition : AspireCommandDefinition +{ + public readonly Option SdkOption = new("--sdk") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false }; + + /// + /// Project or file. + /// + public readonly Option EntryPointOption = new("--entrypoint") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false }; + + public readonly Argument ApplicationArguments = new("arguments") { Arity = ArgumentArity.ZeroOrMore }; + public readonly Option NoLaunchProfileOption = new("--no-launch-profile") { Arity = ArgumentArity.Zero }; + public readonly Option LaunchProfileOption = new("--launch-profile", "-lp") { Arity = ArgumentArity.ExactlyOne }; + + public AspireHostCommandDefinition() + : base("host", "Starts AppHost project.") + { + Arguments.Add(ApplicationArguments); + + Options.Add(SdkOption); + Options.Add(EntryPointOption); + Options.Add(NoLaunchProfileOption); + Options.Add(LaunchProfileOption); + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Commands/AspireResourceCommandDefinition.cs b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireResourceCommandDefinition.cs new file mode 100644 index 000000000000..88ead313c6bc --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireResourceCommandDefinition.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireResourceCommandDefinition : AspireCommandDefinition +{ + public readonly Argument ApplicationArguments = new("arguments") { Arity = ArgumentArity.ZeroOrMore }; + + /// + /// Server pipe name. + /// + public readonly Option ServerOption = new("--server") + { + Arity = ArgumentArity.ExactlyOne, + Required = true, + AllowMultipleArgumentsPerToken = false + }; + + public readonly Option EntryPointOption = new("--entrypoint") + { + Arity = ArgumentArity.ExactlyOne, + Required = true, + AllowMultipleArgumentsPerToken = false + }; + + public readonly Option> EnvironmentOption = new("--environment", "-e") + { + Description = "Environment variables for the process", + CustomParser = ParseEnvironmentVariables, + AllowMultipleArgumentsPerToken = false + }; + + public readonly Option NoLaunchProfileOption = new("--no-launch-profile") { Arity = ArgumentArity.Zero }; + public readonly Option LaunchProfileOption = new("--launch-profile", "-lp") { Arity = ArgumentArity.ExactlyOne }; + + public AspireResourceCommandDefinition() + : base("resource", "Starts resource project.") + { + Arguments.Add(ApplicationArguments); + + Options.Add(ServerOption); + Options.Add(EntryPointOption); + Options.Add(EnvironmentOption); + Options.Add(NoLaunchProfileOption); + Options.Add(LaunchProfileOption); + } + + private static IReadOnlyDictionary ParseEnvironmentVariables(ArgumentResult argumentResult) + { + var result = new Dictionary(PathUtilities.OSSpecificPathComparer); + + List? invalid = null; + + foreach (var token in argumentResult.Tokens) + { + var separator = token.Value.IndexOf('='); + var (name, value) = (separator >= 0) + ? (token.Value[0..separator], token.Value[(separator + 1)..]) + : (token.Value, ""); + + name = name.Trim(); + + if (name != "") + { + result[name] = value; + } + else + { + invalid ??= []; + invalid.Add(token); + } + } + + if (invalid != null) + { + argumentResult.AddError(string.Format( + "Incorrectly formatted environment variables {0}", + string.Join(", ", invalid.Select(x => $"'{x.Value}'")))); + } + + return result; + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Commands/AspireRootCommand.cs b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireRootCommand.cs new file mode 100644 index 000000000000..f9d3b6f6b266 --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireRootCommand.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireRootCommand : RootCommand +{ + public readonly AspireServerCommandDefinition ServerCommand = new(); + public readonly AspireResourceCommandDefinition ResourceCommand = new(); + public readonly AspireHostCommandDefinition HostCommand = new(); + + public AspireRootCommand() + { + Directives.Add(new EnvironmentVariablesDirective()); + + Subcommands.Add(ServerCommand); + Subcommands.Add(ResourceCommand); + Subcommands.Add(HostCommand); + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Commands/AspireServerCommandDefinition.cs b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireServerCommandDefinition.cs new file mode 100644 index 000000000000..dfb64002f2ad --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Commands/AspireServerCommandDefinition.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireServerCommandDefinition : AspireCommandDefinition +{ + /// + /// Server pipe name. + /// + public readonly Option ServerOption = new("--server") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false }; + + public readonly Option SdkOption = new("--sdk") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false }; + + /// + /// Paths to resource projects or entry-point files. + /// + public readonly Option ResourceOption = new("--resource") { Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = true }; + + /// + /// Status pipe name for sending watch status events back to the AppHost. + /// + public readonly Option StatusPipeOption = new("--status-pipe") { Arity = ArgumentArity.ExactlyOne, AllowMultipleArgumentsPerToken = false }; + + /// + /// Control pipe name for receiving commands from the AppHost. + /// + public readonly Option ControlPipeOption = new("--control-pipe") { Arity = ArgumentArity.ExactlyOne, AllowMultipleArgumentsPerToken = false }; + + public AspireServerCommandDefinition() + : base("server", "Starts the dotnet watch server.") + { + Options.Add(ServerOption); + Options.Add(SdkOption); + Options.Add(ResourceOption); + Options.Add(StatusPipeOption); + Options.Add(ControlPipeOption); + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Commands/OptionExtensions.cs b/src/Dotnet.Watch/Watch.Aspire/Commands/OptionExtensions.cs new file mode 100644 index 000000000000..2ec5b9f2007d --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Commands/OptionExtensions.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace Microsoft.DotNet.Watch; + +internal static class OptionExtensions +{ + public static bool HasOption(this SymbolResult symbolResult, Option option) + => symbolResult.GetResult(option) is OptionResult or && !or.Implicit; +} diff --git a/src/Dotnet.Watch/Watch.Aspire/DotNetWatchLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/DotNetWatchLauncher.cs deleted file mode 100644 index dd56ed47d0c3..000000000000 --- a/src/Dotnet.Watch/Watch.Aspire/DotNetWatchLauncher.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Extensions.Logging; - -namespace Microsoft.DotNet.Watch; - -internal static class DotNetWatchLauncher -{ - public static async Task RunAsync(string workingDirectory, DotNetWatchOptions options) - { - var globalOptions = new GlobalOptions() - { - LogLevel = options.LogLevel, - NoHotReload = false, - NonInteractive = true, - }; - - var commandArguments = new List(); - if (options.NoLaunchProfile) - { - commandArguments.Add("--no-launch-profile"); - } - - commandArguments.AddRange(options.ApplicationArguments); - - var mainProjectOptions = new ProjectOptions() - { - IsMainProject = true, - Representation = options.Project, - WorkingDirectory = workingDirectory, - LaunchProfileName = options.NoLaunchProfile ? default : null, - Command = "run", - CommandArguments = [.. commandArguments], - LaunchEnvironmentVariables = [], - }; - - var muxerPath = Path.GetFullPath(Path.Combine(options.SdkDirectory, "..", "..", "dotnet" + PathUtilities.ExecutableExtension)); - - // msbuild tasks depend on host path variable: - Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, muxerPath); - - var console = new PhysicalConsole(TestFlags.None); - var reporter = new ConsoleReporter(console, suppressEmojis: false); - var environmentOptions = EnvironmentOptions.FromEnvironment(muxerPath); - var processRunner = new ProcessRunner(environmentOptions.GetProcessCleanupTimeout()); - var loggerFactory = new LoggerFactory(reporter, globalOptions.LogLevel); - var logger = loggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName); - - using var context = new DotNetWatchContext() - { - ProcessOutputReporter = reporter, - LoggerFactory = loggerFactory, - Logger = logger, - BuildLogger = loggerFactory.CreateLogger(DotNetWatchContext.BuildLogComponentName), - ProcessRunner = processRunner, - Options = globalOptions, - EnvironmentOptions = environmentOptions, - MainProjectOptions = mainProjectOptions, - RootProjects = [mainProjectOptions.Representation], - BuildArguments = [], - TargetFramework = null, - BrowserRefreshServerFactory = new BrowserRefreshServerFactory(), - BrowserLauncher = new BrowserLauncher(logger, reporter, environmentOptions), - }; - - using var shutdownHandler = new ShutdownHandler(console, logger); - - try - { - var watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: null); - await watcher.WatchAsync(shutdownHandler.CancellationToken); - } - catch (OperationCanceledException) when (shutdownHandler.CancellationToken.IsCancellationRequested) - { - // Ctrl+C forced an exit - } - catch (Exception e) - { - logger.LogError("An unexpected error occurred: {Exception}", e.ToString()); - return false; - } - - return true; - } -} diff --git a/src/Dotnet.Watch/Watch.Aspire/DotNetWatchOptions.cs b/src/Dotnet.Watch/Watch.Aspire/DotNetWatchOptions.cs deleted file mode 100644 index 4558d5ba2273..000000000000 --- a/src/Dotnet.Watch/Watch.Aspire/DotNetWatchOptions.cs +++ /dev/null @@ -1,96 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Immutable; -using System.CommandLine; -using System.CommandLine.Parsing; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.Logging; - -namespace Microsoft.DotNet.Watch; - -internal sealed class DotNetWatchOptions -{ - /// - /// The .NET SDK directory to load msbuild from (e.g. C:\Program Files\dotnet\sdk\10.0.100). - /// Also used to locate `dotnet` executable. - /// - public required string SdkDirectory { get; init; } - - public required ProjectRepresentation Project { get; init; } - public required ImmutableArray ApplicationArguments { get; init; } - public LogLevel LogLevel { get; init; } - public bool NoLaunchProfile { get; init; } - - public static bool TryParse(string[] args, [NotNullWhen(true)] out DotNetWatchOptions? options) - { - var sdkOption = new Option("--sdk") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false }; - var projectOption = new Option("--project") { Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; - var fileOption = new Option("--file") { Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; - var quietOption = new Option("--quiet") { Arity = ArgumentArity.Zero }; - var verboseOption = new Option("--verbose") { Arity = ArgumentArity.Zero }; - var noLaunchProfileOption = new Option("--no-launch-profile") { Arity = ArgumentArity.Zero }; - var applicationArguments = new Argument("arguments") { Arity = ArgumentArity.ZeroOrMore }; - - var rootCommand = new RootCommand() - { - Directives = { new EnvironmentVariablesDirective() }, - Options = - { - sdkOption, - projectOption, - fileOption, - quietOption, - verboseOption, - noLaunchProfileOption - }, - Arguments = - { - applicationArguments - } - }; - - rootCommand.Validators.Add(v => - { - if (HasOption(v, quietOption) && HasOption(v, verboseOption)) - { - v.AddError("Cannot specify both '--quiet' and '--verbose' options."); - } - - if (HasOption(v, projectOption) && HasOption(v, fileOption)) - { - v.AddError("Cannot specify both '--file' and '--project' options."); - } - else if (!HasOption(v, projectOption) && !HasOption(v, fileOption)) - { - v.AddError("Must specify either '--file' or '--project' option."); - } - }); - - var parseResult = rootCommand.Parse(args); - if (parseResult.Errors.Count > 0) - { - foreach (var error in parseResult.Errors) - { - Console.Error.WriteLine(error); - } - - options = null; - return false; - } - - options = new DotNetWatchOptions() - { - SdkDirectory = parseResult.GetRequiredValue(sdkOption), - Project = new ProjectRepresentation(projectPath: parseResult.GetValue(projectOption), entryPointFilePath: parseResult.GetValue(fileOption)), - LogLevel = parseResult.GetValue(quietOption) ? LogLevel.Warning : parseResult.GetValue(verboseOption) ? LogLevel.Debug : LogLevel.Information, - ApplicationArguments = [.. parseResult.GetValue(applicationArguments) ?? []], - NoLaunchProfile = parseResult.GetValue(noLaunchProfileOption), - }; - - return true; - } - - private static bool HasOption(SymbolResult symbolResult, Option option) - => symbolResult.GetResult(option) is OptionResult or && !or.Implicit; -} diff --git a/src/Dotnet.Watch/Watch.Aspire/Host/AspireHostLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/Host/AspireHostLauncher.cs new file mode 100644 index 000000000000..bb3c9a92f11f --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Host/AspireHostLauncher.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.CommandLine; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireHostLauncher( + GlobalOptions globalOptions, + EnvironmentOptions environmentOptions, + ProjectRepresentation entryPoint, + ImmutableArray applicationArguments, + Optional launchProfileName) + : AspireWatcherLauncher(globalOptions, environmentOptions) +{ + internal const string LogMessagePrefix = "aspire watch host"; + + public ProjectRepresentation EntryPoint => entryPoint; + public ImmutableArray ApplicationArguments => applicationArguments; + public Optional LaunchProfileName => launchProfileName; + + internal static AspireHostLauncher? TryCreate(ParseResult parseResult, AspireHostCommandDefinition command) + { + var sdkDirectory = parseResult.GetValue(command.SdkOption)!; + var entryPointPath = parseResult.GetValue(command.EntryPointOption)!; + var applicationArguments = parseResult.GetValue(command.ApplicationArguments) ?? []; + var launchProfile = parseResult.GetValue(command.LaunchProfileOption); + var noLaunchProfile = parseResult.GetValue(command.NoLaunchProfileOption); + + var globalOptions = new GlobalOptions() + { + LogLevel = command.GetLogLevel(parseResult), + NoHotReload = false, + NonInteractive = true, + }; + + return new AspireHostLauncher( + globalOptions, + EnvironmentOptions.FromEnvironment(sdkDirectory, LogMessagePrefix), + entryPoint: ProjectRepresentation.FromProjectOrEntryPointFilePath(entryPointPath), + applicationArguments: [.. applicationArguments], + launchProfileName: noLaunchProfile ? Optional.NoValue : launchProfile); + } + + internal ProjectOptions GetProjectOptions() + { + var commandArguments = new List() + { + EntryPoint.IsProjectFile ? "--project" : "--file", + EntryPoint.ProjectOrEntryPointFilePath, + }; + + if (LaunchProfileName.Value != null) + { + commandArguments.Add("--launch-profile"); + commandArguments.Add(LaunchProfileName.Value); + } + else if (!LaunchProfileName.HasValue) + { + commandArguments.Add("--no-launch-profile"); + } + + commandArguments.AddRange(ApplicationArguments); + + return new ProjectOptions() + { + IsMainProject = true, + Representation = EntryPoint, + WorkingDirectory = EnvironmentOptions.WorkingDirectory, + LaunchProfileName = LaunchProfileName, + Command = "run", + CommandArguments = [.. commandArguments], + LaunchEnvironmentVariables = [], + }; + } + + public override async Task LaunchAsync(CancellationToken cancellationToken) + { + return await LaunchWatcherAsync( + rootProjects: [EntryPoint], + LoggerFactory, + processLauncherFactory: null, + cancellationToken); + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj b/src/Dotnet.Watch/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj index fb08c258b800..3e7fef4bc7c5 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj +++ b/src/Dotnet.Watch/Watch.Aspire/Microsoft.DotNet.HotReload.Watch.Aspire.csproj @@ -22,8 +22,10 @@ + + - + diff --git a/src/Dotnet.Watch/Watch.Aspire/Program.cs b/src/Dotnet.Watch/Watch.Aspire/Program.cs index 37de5ceca579..bb758c667b15 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Program.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Program.cs @@ -1,12 +1,26 @@ -using Microsoft.Build.Locator; +using System.Diagnostics; +using Microsoft.Build.Locator; using Microsoft.DotNet.Watch; -if (!DotNetWatchOptions.TryParse(args, out var options)) +try { - return -1; -} + if (AspireLauncher.TryCreate(args) is not { } launcher) + { + return -1; + } -MSBuildLocator.RegisterMSBuildPath(options.SdkDirectory); + if (launcher.EnvironmentOptions.SdkDirectory != null) + { + MSBuildLocator.RegisterMSBuildPath(launcher.EnvironmentOptions.SdkDirectory); -var workingDirectory = Directory.GetCurrentDirectory(); -return await DotNetWatchLauncher.RunAsync(workingDirectory, options) ? 0 : 1; + // msbuild tasks depend on host path variable: + Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, launcher.EnvironmentOptions.GetMuxerPath()); + } + + return await launcher.LaunchAsync(CancellationToken.None); +} +catch (Exception e) +{ + Console.Error.WriteLine($"Unexpected exception: {e}"); + return -1; +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Resource/AspireResourceLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/Resource/AspireResourceLauncher.cs new file mode 100644 index 000000000000..c38f561f1382 --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Resource/AspireResourceLauncher.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.CommandLine; +using System.IO.Pipes; +using System.Text.Json; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireResourceLauncher( + GlobalOptions globalOptions, + EnvironmentOptions environmentOptions, + string serverPipeName, + string entryPoint, + ImmutableArray applicationArguments, + IReadOnlyDictionary environmentVariables, + Optional launchProfileName, + TimeSpan pipeConnectionTimeout) + : AspireLauncher(globalOptions, environmentOptions) +{ + internal const string LogMessagePrefix = "aspire watch resource"; + + public const byte Version = 1; + + // Output message type bytes + public const byte OutputTypeStdout = 1; + public const byte OutputTypeStderr = 2; + + public string ServerPipeName => serverPipeName; + public string EntryPoint => entryPoint; + public ImmutableArray ApplicationArguments => applicationArguments; + public IReadOnlyDictionary EnvironmentVariables => environmentVariables; + public Optional LaunchProfileName => launchProfileName; + + public static AspireResourceLauncher? TryCreate(ParseResult parseResult, AspireResourceCommandDefinition command) + { + var serverPipeName = parseResult.GetValue(command.ServerOption)!; + var entryPointPath = parseResult.GetValue(command.EntryPointOption)!; + var applicationArguments = parseResult.GetValue(command.ApplicationArguments) ?? []; + var environmentVariables = parseResult.GetValue(command.EnvironmentOption) ?? ImmutableDictionary.Empty; + var noLaunchProfile = parseResult.GetValue(command.NoLaunchProfileOption); + var launchProfile = parseResult.GetValue(command.LaunchProfileOption); + + var globalOptions = new GlobalOptions() + { + LogLevel = command.GetLogLevel(parseResult), + NoHotReload = false, + NonInteractive = true, + }; + + return new AspireResourceLauncher( + globalOptions, + // SDK directory is not needed for the resource launcher since it doesn't interact with MSBuild: + EnvironmentOptions.FromEnvironment(sdkDirectory: null, LogMessagePrefix), + serverPipeName: serverPipeName, + entryPoint: entryPointPath, + applicationArguments: [.. applicationArguments], + environmentVariables: environmentVariables, + launchProfileName: noLaunchProfile ? Optional.NoValue : launchProfile, + pipeConnectionTimeout: AspireEnvironmentVariables.PipeConnectionTimeout); + } + + /// + /// Connects to the server via named pipe, sends resource options as JSON, waits for ACK, + /// then stays alive proxying stdout/stderr from the server back to the console. + /// + public override async Task LaunchAsync(CancellationToken cancellationToken) + { + try + { + Logger.LogDebug("Connecting to {ServerPipeName}...", ServerPipeName); + + using var pipeClient = new NamedPipeClientStream( + serverName: ".", + ServerPipeName, + PipeDirection.InOut, + PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous); + + // Timeout ensures we don't hang indefinitely if the server isn't ready or the pipe name is wrong. + using var connectionCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectionCancellationSource.CancelAfter(pipeConnectionTimeout); + await pipeClient.ConnectAsync(connectionCancellationSource.Token); + + var request = new LaunchResourceRequest() + { + EntryPoint = EntryPoint, + ApplicationArguments = ApplicationArguments, + EnvironmentVariables = EnvironmentVariables, + LaunchProfileName = LaunchProfileName, + }; + + await pipeClient.WriteAsync(Version, cancellationToken); + + var json = Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes(request)); + await pipeClient.WriteAsync(json, cancellationToken); + + // Wait for ACK byte + var status = await pipeClient.ReadByteAsync(cancellationToken); + if (status == 0) + { + Logger.LogDebug("Server closed connection without sending ACK."); + return 1; + } + + Logger.LogDebug("Request sent. Waiting for output..."); + + // Stay alive and proxy output from the server + return await ProxyOutputAsync(pipeClient, cancellationToken); + } + catch (OperationCanceledException) + { + return 0; + } + catch (EndOfStreamException) + { + // Pipe disconnected - server shut down + return 0; + } + catch (Exception ex) + { + Logger.LogDebug("Failed to communicate with server: {Message}", ex.Message); + return 1; + } + } + + private async Task ProxyOutputAsync(NamedPipeClientStream pipe, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + byte typeByte; + try + { + typeByte = await pipe.ReadByteAsync(cancellationToken); + } + catch (EndOfStreamException) + { + // Pipe closed, server shut down + return 0; + } + + var content = await pipe.ReadStringAsync(cancellationToken); + + var output = typeByte switch + { + OutputTypeStdout => Console.Out, + OutputTypeStderr => Console.Error, + _ => throw new InvalidOperationException($"Unexpected output type: '{typeByte:X2}'") + }; + + output.WriteLine(content); + } + + return 0; + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Resource/LaunchResourceRequest.cs b/src/Dotnet.Watch/Watch.Aspire/Resource/LaunchResourceRequest.cs new file mode 100644 index 000000000000..209edaf42f42 --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Resource/LaunchResourceRequest.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; + +namespace Microsoft.DotNet.Watch; + +internal sealed class LaunchResourceRequest +{ + public required string EntryPoint { get; init; } + public required ImmutableArray ApplicationArguments { get; init; } + public required IReadOnlyDictionary EnvironmentVariables { get; init; } + public required Optional LaunchProfileName { get; init; } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/AspireServerLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/Server/AspireServerLauncher.cs new file mode 100644 index 000000000000..00d40cfecd85 --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Server/AspireServerLauncher.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.CommandLine; +using System.Threading.Channels; +using Microsoft.CodeAnalysis.Elfie.Diagnostics; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace Microsoft.DotNet.Watch; + +internal sealed class AspireServerLauncher( + GlobalOptions globalOptions, + EnvironmentOptions environmentOptions, + string serverPipeName, + ImmutableArray resourcePaths, + string? statusPipeName, + string? controlPipeName) + : AspireWatcherLauncher(globalOptions, environmentOptions) +{ + private const string LogMessagePrefix = "aspire watch server"; + + public string ServerPipeName => serverPipeName; + public ImmutableArray ResourcePaths => resourcePaths; + public string? StatusPipeName => statusPipeName; + public string? ControlPipeName => controlPipeName; + + public static AspireServerLauncher? TryCreate(ParseResult parseResult, AspireServerCommandDefinition command) + { + var serverPipeName = parseResult.GetValue(command.ServerOption)!; + var sdkDirectory = parseResult.GetValue(command.SdkOption)!; + var resourcePaths = parseResult.GetValue(command.ResourceOption) ?? []; + var statusPipeName = parseResult.GetValue(command.StatusPipeOption); + var controlPipeName = parseResult.GetValue(command.ControlPipeOption); + + var globalOptions = new GlobalOptions() + { + LogLevel = command.GetLogLevel(parseResult), + NoHotReload = false, + NonInteractive = true, + }; + + return new AspireServerLauncher( + globalOptions, + EnvironmentOptions.FromEnvironment(sdkDirectory, LogMessagePrefix), + serverPipeName: serverPipeName, + resourcePaths: [.. resourcePaths], + statusPipeName: statusPipeName, + controlPipeName: controlPipeName); + } + + public override async Task LaunchAsync(CancellationToken cancellationToken) + { + await using var statusWriter = StatusPipeName != null ? new WatchStatusWriter(StatusPipeName, Logger) : null; + + var processLauncherFactory = new ProcessLauncherFactory(ServerPipeName, ControlPipeName, statusWriter, launchProfile: null, cancellationToken); + + return await LaunchWatcherAsync( + rootProjects: [.. ResourcePaths.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath)], + statusWriter != null ? new StatusReportingLoggerFactory(statusWriter, LoggerFactory) : LoggerFactory, + processLauncherFactory, + cancellationToken); + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs new file mode 100644 index 000000000000..0ccc26894288 --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Elfie.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal abstract class AspireWatcherLauncher(GlobalOptions globalOptions, EnvironmentOptions environmentOptions) + : AspireLauncher(globalOptions, environmentOptions) +{ + protected async Task LaunchWatcherAsync( + ImmutableArray rootProjects, + ILoggerFactory loggerFactory, + IRuntimeProcessLauncherFactory? processLauncherFactory, + CancellationToken cancellationToken) + { + var logger = loggerFactory != LoggerFactory + ? loggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName) + : Logger; + + using var context = new DotNetWatchContext() + { + ProcessOutputReporter = Reporter, + LoggerFactory = loggerFactory, + Logger = logger, + BuildLogger = loggerFactory.CreateLogger(DotNetWatchContext.BuildLogComponentName), + ProcessRunner = new ProcessRunner(EnvironmentOptions.GetProcessCleanupTimeout()), + Options = GlobalOptions, + EnvironmentOptions = EnvironmentOptions, + MainProjectOptions = null, + BuildArguments = [], + TargetFramework = null, + RootProjects = rootProjects, + BrowserRefreshServerFactory = new BrowserRefreshServerFactory(), + BrowserLauncher = new BrowserLauncher(logger, Reporter, EnvironmentOptions), + }; + + using var shutdownHandler = new ShutdownHandler(Console, context.Logger); + using var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, shutdownHandler.CancellationToken); + + try + { + var watcher = new HotReloadDotNetWatcher(context, Console, processLauncherFactory); + await watcher.WatchAsync(cancellationSource.Token); + } + catch (OperationCanceledException) when (shutdownHandler.CancellationToken.IsCancellationRequested) + { + // Ctrl+C forced an exit + } + + return 0; + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs b/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs new file mode 100644 index 000000000000..8bb1e7548f30 --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs @@ -0,0 +1,358 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO.Pipes; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading.Channels; +using Aspire.Tools.Service; +using Microsoft.CodeAnalysis; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class ProcessLauncherFactory( + string serverPipeName, + string? controlPipeName, + WatchStatusWriter? statusWriter, + Optional launchProfile, + CancellationToken shutdownCancellationToken) : IRuntimeProcessLauncherFactory +{ + public IRuntimeProcessLauncher Create(ProjectLauncher projectLauncher) + { + // Connect to control pipe if provided + var controlReader = controlPipeName != null + ? new WatchControlReader(controlPipeName, projectLauncher.CompilationHandler, projectLauncher.Logger) + : null; + + return new Launcher(serverPipeName, controlReader, projectLauncher, statusWriter, launchProfile, shutdownCancellationToken); + } + + private sealed class Launcher : IRuntimeProcessLauncher + { + private const byte Version = 1; + + private readonly Optional _launchProfileName; + private readonly Task _listenerTask; + private readonly WatchStatusWriter? _statusWriter; + private readonly WatchControlReader? _controlReader; + + private CancellationTokenSource? _disposalCancellationSource; + private ImmutableHashSet _pendingRequestCompletions = []; + private volatile ProjectLauncher _projectLauncher; + + public Launcher( + string serverPipeName, + WatchControlReader? controlReader, + ProjectLauncher projectLauncher, + WatchStatusWriter? statusWriter, + Optional launchProfile, + CancellationToken shutdownCancellationToken) + { + _projectLauncher = projectLauncher; + _statusWriter = statusWriter; + _launchProfileName = launchProfile; + _controlReader = controlReader; + _disposalCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken); + _listenerTask = StartListeningAsync(serverPipeName, _disposalCancellationSource.Token); + } + + public bool IsDisposed + => _disposalCancellationSource == null; + + private ILogger Logger + => _projectLauncher.Logger; + + public async ValueTask DisposeAsync() + { + var disposalCancellationSource = Interlocked.Exchange(ref _disposalCancellationSource, null); + ObjectDisposedException.ThrowIf(disposalCancellationSource == null, this); + + Logger.LogDebug("Disposing process launcher."); + await disposalCancellationSource.CancelAsync(); + + if (_controlReader != null) + { + await _controlReader.DisposeAsync(); + } + + await _listenerTask; + await Task.WhenAll(_pendingRequestCompletions); + + disposalCancellationSource.Dispose(); + } + + private async Task StartListeningAsync(string pipeName, CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + NamedPipeServerStream? pipe = null; + try + { + pipe = new NamedPipeServerStream( + pipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + await pipe.WaitForConnectionAsync(cancellationToken); + + Logger.LogDebug("Connected to '{PipeName}'", pipeName); + + var version = await pipe.ReadByteAsync(cancellationToken); + if (version != Version) + { + Logger.LogDebug("Unsupported protocol version '{Version}'", version); + await pipe.WriteAsync((byte)0, cancellationToken); + continue; + } + + var json = await pipe.ReadStringAsync(cancellationToken); + + var request = JsonSerializer.Deserialize(json) ?? throw new JsonException("Unexpected null"); + + Logger.LogDebug("Request received."); + await pipe.WriteAsync((byte)1, cancellationToken); + + _ = HandleRequestAsync(request, pipe, cancellationToken); + + // Don't dispose the pipe - it's now owned by HandleRequestAsync + // which will keep it alive for output proxying + pipe = null; + } + finally + { + if (pipe != null) + { + await pipe.DisposeAsync(); + } + } + } + } + catch (OperationCanceledException) + { + // nop + } + catch (Exception e) + { + Logger.LogError("Failed to launch resource: {Exception}", e.Message); + } + } + + private async Task HandleRequestAsync(LaunchResourceRequest request, NamedPipeServerStream pipe, CancellationToken cancellationToken) + { + var completionSource = new TaskCompletionSource(); + ImmutableInterlocked.Update(ref _pendingRequestCompletions, set => set.Add(completionSource.Task)); + + // Shared box to track the latest RunningProject across restarts. + // restartOperation creates new RunningProjects — we always need the latest one. + var currentProject = new StrongBox(null); + + // Create a per-connection token that cancels when the pipe disconnects OR on shutdown. + // DCP Stop kills the resource command, which closes the pipe from the other end. + // We detect that by reading from the pipe — when it breaks, we cancel. + using var pipeDisconnectedSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var connectionToken = pipeDisconnectedSource.Token; + + try + { + var projectOptions = GetProjectOptions(request); + + await StartProjectAsync(projectOptions, pipe, currentProject, isRestart: currentProject.Value is not null, connectionToken); + Debug.Assert(currentProject.Value != null); + + var projectLogger = currentProject.Value.ClientLogger; + projectLogger.LogDebug("Waiting for resource to disconnect or relaunch."); + + await WaitForPipeDisconnectAsync(pipe, connectionToken); + + projectLogger.LogDebug("Resource pipe disconnected."); + } + catch (OperationCanceledException) + { + // Shutdown or DCP killed the resource command + } + catch (Exception e) + { + Logger.LogError("Failed to start '{Path}': {Exception}", request.EntryPoint, e.Message); + } + finally + { + // Cancel the connection token so any in-flight restartOperation / drain tasks stop. + await pipeDisconnectedSource.CancelAsync(); + + // Terminate the project process when the resource command disconnects. + // This handles DCP Stop — the resource command is killed, pipe breaks, + // and we clean up the project process the watch server launched. + if (currentProject.Value is { } project) + { + Logger.LogDebug("Pipe disconnected for '{Path}', terminating project process.", request.EntryPoint); + await project.Process.TerminateAsync(); + } + + await pipe.DisposeAsync(); + Logger.LogDebug("HandleRequest completed for '{Path}'.", request.EntryPoint); + } + + ImmutableInterlocked.Update(ref _pendingRequestCompletions, set => set.Remove(completionSource.Task)); + completionSource.SetResult(); + } + + private static async Task WaitForPipeDisconnectAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken) + { + try + { + var buffer = new byte[1]; + while (pipe.IsConnected && !cancellationToken.IsCancellationRequested) + { + var bytesRead = await pipe.ReadAsync(buffer, cancellationToken); + if (bytesRead == 0) + { + break; + } + } + } + catch (IOException) + { + // Pipe disconnected + } + } + + private async ValueTask StartProjectAsync(ProjectOptions projectOptions, NamedPipeServerStream pipe, StrongBox currentProject, bool isRestart, CancellationToken cancellationToken) + { + // Buffer output through a channel to avoid blocking the synchronous onOutput callback. + // The channel is drained asynchronously by DrainOutputChannelAsync which writes to the pipe. + var outputChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + + var outputChannelDrainTask = WriteProcessOutputToPipeAsync(); + + currentProject.Value = await _projectLauncher.TryLaunchProcessAsync( + projectOptions, + onOutput: line => outputChannel.Writer.TryWrite(line), + onExit: async (processId, exitCode) => + { + var isRestarting = currentProject.Value?.IsRestarting == true; + if (exitCode is not null and not 0 && !cancellationToken.IsCancellationRequested && !isRestarting) + { + // Emit a status event for non-zero exit codes so the dashboard shows the crash. + // Skip if cancellation is requested (DCP Stop/shutdown) or if the project + // is being deliberately restarted (rude edit restart). + _statusWriter?.WriteEvent(new WatchStatusEvent + { + Type = WatchStatusEvent.Types.ProcessExited, + Projects = [projectOptions.Representation.ProjectOrEntryPointFilePath], + ExitCode = exitCode, + }); + } + + // DON'T complete the output channel. + // dotnet-watch will auto-retry on crash and reuse the same onOutput callback, + // so new output from the retried process flows through the same channel/pipe. + // Completing the channel would starve the pipe and cause DCP to kill the + // resource command, triggering a disconnect → terminate → reconnect storm. + }, + restartOperation: async cancellationToken => + { + // Complete the old channel so the old drain task finishes before + // StartProjectAsync creates a new channel + drain on the same pipe. + outputChannel.Writer.TryComplete(); + await outputChannelDrainTask; + + await StartProjectAsync(projectOptions, pipe, currentProject, isRestart: true, cancellationToken); + }, + cancellationToken) + ?? throw new InvalidOperationException(); + + // Emit ProcessStarted so the dashboard knows the process is actually running. + _statusWriter?.WriteEvent(new WatchStatusEvent + { + Type = WatchStatusEvent.Types.ProcessStarted, + Projects = [projectOptions.Representation.ProjectOrEntryPointFilePath], + }); + + async Task WriteProcessOutputToPipeAsync() + { + try + { + await foreach (var line in outputChannel.Reader.ReadAllAsync(cancellationToken)) + { + await pipe.WriteAsync(line.IsError ? AspireResourceLauncher.OutputTypeStderr : AspireResourceLauncher.OutputTypeStdout, cancellationToken); + await pipe.WriteAsync(line.Content, cancellationToken); + } + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) + { + // Pipe disconnected or cancelled + } + } + } + + private ProjectOptions GetProjectOptions(LaunchResourceRequest request) + { + var project = ProjectRepresentation.FromProjectOrEntryPointFilePath(request.EntryPoint); + + return new() + { + IsMainProject = false, + Representation = project, + WorkingDirectory = Path.GetDirectoryName(request.EntryPoint) ?? throw new InvalidOperationException(), + Command = "run", + CommandArguments = GetRunCommandArguments(request, _launchProfileName.Value), + LaunchEnvironmentVariables = request.EnvironmentVariables?.Select(e => (e.Key, e.Value))?.ToArray() ?? [], + LaunchProfileName = request.LaunchProfileName, + }; + } + + // internal for testing + internal static IReadOnlyList GetRunCommandArguments(LaunchResourceRequest request, string? hostLaunchProfile) + { + var arguments = new List(); + + if (!request.LaunchProfileName.HasValue) + { + arguments.Add("--no-launch-profile"); + } + else if (!string.IsNullOrEmpty(request.LaunchProfileName.Value)) + { + arguments.Add("--launch-profile"); + arguments.Add(request.LaunchProfileName.Value); + } + else if (hostLaunchProfile != null) + { + arguments.Add("--launch-profile"); + arguments.Add(hostLaunchProfile); + } + + if (request.ApplicationArguments != null) + { + if (request.ApplicationArguments.Any()) + { + arguments.AddRange(request.ApplicationArguments); + } + else + { + // indicate that no arguments should be used even if launch profile specifies some: + arguments.Add("--no-launch-profile-arguments"); + } + } + + return arguments; + } + + public IEnumerable<(string name, string value)> GetEnvironmentVariables() + => []; + + public ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancellationToken) + => ValueTask.CompletedTask; + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/StatusReportingLoggerFactory.cs b/src/Dotnet.Watch/Watch.Aspire/Server/StatusReportingLoggerFactory.cs new file mode 100644 index 000000000000..e676a2ff0b7f --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Server/StatusReportingLoggerFactory.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +/// +/// Intercepts select log messages reported by watch and forwards them to to be sent to an external listener. +/// +internal sealed class StatusReportingLoggerFactory(WatchStatusWriter writer, LoggerFactory underlyingFactory) : ILoggerFactory +{ + public void Dispose() + { + } + + public ILogger CreateLogger(string categoryName) + => new Logger(writer, underlyingFactory.CreateLogger(categoryName)); + + public void AddProvider(ILoggerProvider provider) + => underlyingFactory.AddProvider(provider); + + private sealed class Logger(WatchStatusWriter writer, ILogger underlyingLogger) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull + => underlyingLogger.BeginScope(state); + + public bool IsEnabled(LogLevel logLevel) + => logLevel == LogLevel.None || underlyingLogger.IsEnabled(logLevel); + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + underlyingLogger.Log(logLevel, eventId, state, exception, formatter); + + WatchStatusEvent? status = null; + + if (eventId == MessageDescriptor.BuildStartedNotification.Id) + { + var logState = (LogState>)(object)state!; + + status = new WatchStatusEvent + { + Type = WatchStatusEvent.Types.Building, + Projects = logState.Arguments.Select(r => r.ProjectOrEntryPointFilePath), + }; + } + else if (eventId == MessageDescriptor.BuildCompletedNotification.Id) + { + var logState = (LogState<(IEnumerable projects, bool success)>)(object)state!; + + status = new WatchStatusEvent + { + Type = WatchStatusEvent.Types.BuildComplete, + Projects = logState.Arguments.projects.Select(r => r.ProjectOrEntryPointFilePath), + Success = logState.Arguments.success, + }; + } + else if (eventId == MessageDescriptor.ChangesAppliedToProjectsNotification.Id) + { + var logState = (LogState>)(object)state!; + + status = new WatchStatusEvent + { + Type = WatchStatusEvent.Types.HotReloadApplied, + Projects = logState.Arguments.Select(r => r.ProjectOrEntryPointFilePath), + }; + } + else if (eventId == MessageDescriptor.RestartingProjectsNotification.Id) + { + var logState = (LogState>)(object)state!; + + status = new WatchStatusEvent + { + Type = WatchStatusEvent.Types.Restarting, + Projects = logState.Arguments.Select(r => r.ProjectOrEntryPointFilePath) + }; + } + + if (status != null) + { + writer.WriteEvent(status); + } + } + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlCommand.cs b/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlCommand.cs new file mode 100644 index 000000000000..7b8f554c9a17 --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlCommand.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Watch; + +internal sealed class WatchControlCommand +{ + [JsonPropertyName("type")] + public required string Type { get; init; } + + /// + /// Paths of projects to restart. + /// + [JsonPropertyName("projects")] + public ImmutableArray Projects { get; init; } + + public static class Types + { + public const string Rebuild = "rebuild"; + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs b/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs new file mode 100644 index 000000000000..858ec4f8a9b5 --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO.Pipes; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class WatchControlReader : IAsyncDisposable +{ + private readonly CompilationHandler _compilationHandler; + private readonly string _pipeName; + private readonly NamedPipeClientStream _pipe; + private readonly ILogger _logger; + private readonly CancellationTokenSource _disposalCancellationSource = new(); + private readonly Task _listener; + + public WatchControlReader(string pipeName, CompilationHandler compilationHandler, ILogger logger) + { + _pipe = new NamedPipeClientStream( + serverName: ".", + pipeName, + PipeDirection.In, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + _pipeName = pipeName; + _compilationHandler = compilationHandler; + _logger = logger; + _listener = ListenAsync(_disposalCancellationSource.Token); + } + + public async ValueTask DisposeAsync() + { + _logger.LogDebug("Disposing control pipe."); + + _disposalCancellationSource.Cancel(); + await _listener; + + try + { + await _pipe.DisposeAsync(); + } + catch (IOException) + { + // Pipe may already be broken if the server disconnected + } + } + + private async Task ListenAsync(CancellationToken cancellationToken) + { + try + { + _logger.LogDebug("Connecting to control pipe '{PipeName}'.", _pipeName); + await _pipe.ConnectAsync(cancellationToken); + + using var reader = new StreamReader(_pipe); + + while (!cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(cancellationToken); + if (line is null) + { + return; + } + + var command = JsonSerializer.Deserialize(line); + if (command is null) + { + break; + } + + if (command.Type == WatchControlCommand.Types.Rebuild) + { + _logger.LogDebug("Received request to restart projects"); + await RestartProjectsAsync(command.Projects.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath), cancellationToken); + } + else + { + _logger.LogError("Unknown control command: '{Type}'", command.Type); + } + } + } + catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException or IOException) + { + // expected when disposing or if the server disconnects + } + catch (Exception e) + { + _logger.LogDebug("Control pipe listener failed: {Message}", e.Message); + } + } + + private async ValueTask RestartProjectsAsync(IEnumerable projects, CancellationToken cancellationToken) + { + var projectsToRestart = await _compilationHandler.TerminatePeripheralProcessesAsync(projects.Select(p => p.ProjectGraphPath), cancellationToken); + + foreach (var project in projects) + { + if (!projectsToRestart.Any(p => p.Options.Representation == project)) + { + _compilationHandler.Logger.LogDebug("Restart of '{Project}' requested but the project is not running.", project); + } + } + + await _compilationHandler.RestartPeripheralProjectsAsync(projectsToRestart, cancellationToken); + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusEvent.cs b/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusEvent.cs new file mode 100644 index 000000000000..5e96168e1fd9 --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusEvent.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Watch; + +internal sealed class WatchStatusEvent +{ + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("projects")] + public required IEnumerable Projects { get; init; } + + [JsonPropertyName("success")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Success { get; init; } + + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Error { get; init; } + + [JsonPropertyName("exitCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ExitCode { get; init; } + + public static class Types + { + public const string Building = "building"; + public const string BuildComplete = "build_complete"; + public const string HotReloadApplied = "hot_reload_applied"; + public const string Restarting = "restarting"; + public const string ProcessExited = "process_exited"; + public const string ProcessStarted = "process_started"; + } +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusWriter.cs b/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusWriter.cs new file mode 100644 index 000000000000..a208be7836d5 --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusWriter.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO.Pipes; +using System.Reflection.Metadata; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.CodeAnalysis.Elfie.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class WatchStatusWriter : IAsyncDisposable +{ + private readonly Channel _eventChannel = Channel.CreateUnbounded(new() + { + SingleReader = true, + SingleWriter = false + }); + + private readonly string? _pipeName; + private readonly NamedPipeClientStream _pipe; + private readonly ILogger _logger; + private readonly Task _channelReader; + private readonly CancellationTokenSource _disposalCancellationSource = new(); + + public WatchStatusWriter(string pipeName, ILogger logger) + { + _pipe = new NamedPipeClientStream( + serverName: ".", + pipeName, + PipeDirection.Out, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + _pipeName = pipeName; + _logger = logger; + _channelReader = StartChannelReaderAsync(_disposalCancellationSource.Token); + } + + public async ValueTask DisposeAsync() + { + _logger.LogDebug("Disposing status pipe."); + + _disposalCancellationSource.Cancel(); + await _channelReader; + + try + { + await _pipe.DisposeAsync(); + } + catch (IOException) + { + // Pipe may already be broken if the server disconnected + } + + _disposalCancellationSource.Dispose(); + } + + private async Task StartChannelReaderAsync(CancellationToken cancellationToken) + { + try + { + _logger.LogDebug("Connecting to status pipe '{PipeName}'...", _pipeName); + + await _pipe.ConnectAsync(cancellationToken); + + using var streamWriter = new StreamWriter(_pipe) { AutoFlush = true }; + + await foreach (var statusEvent in _eventChannel.Reader.ReadAllAsync(cancellationToken)) + { + var json = JsonSerializer.Serialize(statusEvent); + await streamWriter.WriteLineAsync(json.AsMemory(), cancellationToken); + } + } + catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException or IOException) + { + // expected when disposing or if the server disconnects + } + catch (Exception e) + { + _logger.LogError("Unexpected error reading status event: {Exception}", e); + } + } + + public void WriteEvent(WatchStatusEvent statusEvent) + => _eventChannel.Writer.TryWrite(statusEvent); +} diff --git a/src/Dotnet.Watch/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs b/src/Dotnet.Watch/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs new file mode 100644 index 000000000000..ffdd4f424a4b --- /dev/null +++ b/src/Dotnet.Watch/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.DotNet.Watch; + +internal static class AspireEnvironmentVariables +{ + public static TimeSpan PipeConnectionTimeout + => EnvironmentVariables.ReadTimeSpanSeconds("ASPIRE_WATCH_PIPE_CONNECTION_TIMEOUT_SECONDS") ?? TimeSpan.FromSeconds(30); +} diff --git a/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs b/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs index 6258919dbdd0..b4b72f3b7a64 100644 --- a/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs +++ b/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs @@ -57,7 +57,7 @@ private static string GetMiddlewareAssemblyPath() logger, context.LoggerFactory, middlewareAssemblyPath: GetMiddlewareAssemblyPath(), - dotnetPath: context.EnvironmentOptions.MuxerPath, + dotnetPath: context.EnvironmentOptions.GetMuxerPath(), webSocketConfig: context.EnvironmentOptions.BrowserWebSocketConfig, suppressTimeouts: context.EnvironmentOptions.TestFlags != TestFlags.None); } diff --git a/src/Dotnet.Watch/Watch/Aspire/AspireServiceFactory.cs b/src/Dotnet.Watch/Watch/Aspire/AspireServiceFactory.cs index 99539e56433d..10d46c189096 100644 --- a/src/Dotnet.Watch/Watch/Aspire/AspireServiceFactory.cs +++ b/src/Dotnet.Watch/Watch/Aspire/AspireServiceFactory.cs @@ -110,7 +110,7 @@ async ValueTask IAspireServerEvents.StartProjectAsync(string dcpId, Proj return sessionId; } - public async ValueTask StartProjectAsync(string dcpId, string sessionId, ProjectOptions projectOptions, bool isRestart, CancellationToken cancellationToken) + public async ValueTask StartProjectAsync(string dcpId, string sessionId, ProjectOptions projectOptions, bool isRestart, CancellationToken cancellationToken) { // Neither request from DCP nor restart should happen once the disposal has started. ObjectDisposedException.ThrowIf(_isDisposed, this); @@ -179,7 +179,6 @@ public async ValueTask StartProjectAsync(string dcpId, string se } _logger.LogDebug("[#{SessionId}] Session started", sessionId); - return runningProject; async Task StartChannelReader(CancellationToken cancellationToken) { diff --git a/src/Dotnet.Watch/Watch/Build/ProjectBuildManager.cs b/src/Dotnet.Watch/Watch/Build/ProjectBuildManager.cs index d7d8ccd9777d..25e12e6a4aa8 100644 --- a/src/Dotnet.Watch/Watch/Build/ProjectBuildManager.cs +++ b/src/Dotnet.Watch/Watch/Build/ProjectBuildManager.cs @@ -20,6 +20,12 @@ internal sealed class ProjectBuildManager(ProjectCollection collection, BuildRep public readonly ProjectCollection Collection = collection; public readonly BuildReporter BuildReporter = reporter; + /// + /// Used by tests to ensure no more than one build is running at a time, which is required by MSBuild. + /// + internal static SemaphoreSlim Test_BuildSemaphore + => s_buildSemaphore; + /// /// Executes the specified build requests. /// diff --git a/src/Dotnet.Watch/Watch/Build/ProjectRepresentation.cs b/src/Dotnet.Watch/Watch/Build/ProjectRepresentation.cs index 76526a8a0cb6..1aa8aedfb682 100644 --- a/src/Dotnet.Watch/Watch/Build/ProjectRepresentation.cs +++ b/src/Dotnet.Watch/Watch/Build/ProjectRepresentation.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.DotNet.ProjectTools; namespace Microsoft.DotNet.Watch; @@ -8,30 +9,23 @@ namespace Microsoft.DotNet.Watch; /// /// Project can be reprented by project file or by entry point file (for single-file apps). /// -internal readonly struct ProjectRepresentation(string projectGraphPath, string? projectPath, string? entryPointFilePath) +/// Path used in Project Graph (may be virtual). +/// Path to an physical (non-virtual) project, if available. +/// Path to an entry point file, if available. +internal readonly record struct ProjectRepresentation(string ProjectGraphPath, string? PhysicalPath, string? EntryPointFilePath) { - /// - /// Path used in Project Graph (may be virtual). - /// - public readonly string ProjectGraphPath = projectGraphPath; - - /// - /// Path to an physical (non-virtual) project, if available. - /// - public readonly string? PhysicalPath = projectPath; - - /// - /// Path to an entry point file, if available. - /// - public readonly string? EntryPointFilePath = entryPointFilePath; - public ProjectRepresentation(string? projectPath, string? entryPointFilePath) : this(projectPath ?? VirtualProjectBuilder.GetVirtualProjectPath(entryPointFilePath!), projectPath, entryPointFilePath) { } + [MemberNotNullWhen(true, nameof(PhysicalPath))] + [MemberNotNullWhen(false, nameof(EntryPointFilePath))] + public bool IsProjectFile + => PhysicalPath != null; + public string ProjectOrEntryPointFilePath - => PhysicalPath ?? EntryPointFilePath!; + => IsProjectFile ? PhysicalPath : EntryPointFilePath; public string GetContainingDirectory() => Path.GetDirectoryName(ProjectOrEntryPointFilePath)!; @@ -43,4 +37,10 @@ public static ProjectRepresentation FromProjectOrEntryPointFilePath(string proje public ProjectRepresentation WithProjectGraphPath(string projectGraphPath) => new(projectGraphPath, PhysicalPath, EntryPointFilePath); + + public bool Equals(ProjectRepresentation other) + => PathUtilities.OSSpecificPathComparer.Equals(ProjectGraphPath, other.ProjectGraphPath); + + public override int GetHashCode() + => PathUtilities.OSSpecificPathComparer.GetHashCode(ProjectGraphPath); } diff --git a/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs b/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs index 7a69972fac19..2953e4760c57 100644 --- a/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs +++ b/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs @@ -28,8 +28,9 @@ internal enum TestFlags internal sealed record EnvironmentOptions( string WorkingDirectory, - string MuxerPath, - TimeSpan? ProcessCleanupTimeout, + string? SdkDirectory, + string LogMessagePrefix, + TimeSpan? ProcessCleanupTimeout = null, bool IsPollingEnabled = false, bool SuppressHandlingStaticWebAssets = false, bool SuppressMSBuildIncrementalism = false, @@ -44,10 +45,11 @@ internal sealed record EnvironmentOptions( TestFlags TestFlags = TestFlags.None, string TestOutput = "") { - public static EnvironmentOptions FromEnvironment(string muxerPath) => new + public static EnvironmentOptions FromEnvironment(string? sdkDirectory, string logMessagePrefix) => new ( WorkingDirectory: Directory.GetCurrentDirectory(), - MuxerPath: ValidateMuxerPath(muxerPath), + SdkDirectory: sdkDirectory, + LogMessagePrefix: logMessagePrefix, ProcessCleanupTimeout: EnvironmentVariables.ProcessCleanupTimeout, IsPollingEnabled: EnvironmentVariables.IsPollingEnabled, SuppressHandlingStaticWebAssets: EnvironmentVariables.SuppressHandlingStaticWebAssets, @@ -68,16 +70,17 @@ public TimeSpan GetProcessCleanupTimeout() // Allow sufficient time for the process to exit gracefully and release resources (e.g., network ports). => ProcessCleanupTimeout ?? TimeSpan.FromSeconds(5); + private readonly string? _muxerPath = SdkDirectory != null + ? Path.GetFullPath(Path.Combine(SdkDirectory, "..", "..", "dotnet" + PathUtilities.ExecutableExtension)) + : null; + + public string GetMuxerPath() + => _muxerPath ?? throw new InvalidOperationException("SDK directory is required to determine muxer path."); + private int _uniqueLogId; public bool RunningAsTest { get => (TestFlags & TestFlags.RunningAsTest) != TestFlags.None; } - private static string ValidateMuxerPath(string path) - { - Debug.Assert(Path.GetFileNameWithoutExtension(path) == "dotnet"); - return path; - } - public string? GetBinLogPath(string projectPath, string operationName, GlobalOptions options) => options.BinaryLogPath != null ? $"{Path.Combine(WorkingDirectory, options.BinaryLogPath)[..^".binlog".Length]}-dotnet-watch.{operationName}.{Path.GetFileName(projectPath)}.{Interlocked.Increment(ref _uniqueLogId)}.binlog" diff --git a/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs b/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs index ac4dfa5db843..ad9d499e1bb5 100644 --- a/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs +++ b/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs @@ -42,7 +42,7 @@ public static LogLevel? CliLogLevel public static bool IsPollingEnabled => ReadBool("DOTNET_USE_POLLING_FILE_WATCHER"); public static bool SuppressEmojis => ReadBool("DOTNET_WATCH_SUPPRESS_EMOJIS"); public static bool RestartOnRudeEdit => ReadBool("DOTNET_WATCH_RESTART_ON_RUDE_EDIT"); - public static TimeSpan? ProcessCleanupTimeout => ReadTimeSpan("DOTNET_WATCH_PROCESS_CLEANUP_TIMEOUT_MS"); + public static TimeSpan? ProcessCleanupTimeout => ReadTimeSpanMilliseconds("DOTNET_WATCH_PROCESS_CLEANUP_TIMEOUT_MS"); public static string SdkRootDirectory => #if DEBUG @@ -89,9 +89,12 @@ public static LogLevel? CliLogLevel private static bool ReadBool(string variableName) => ParseBool(Environment.GetEnvironmentVariable(variableName)); - private static TimeSpan? ReadTimeSpan(string variableName) + internal static TimeSpan? ReadTimeSpanMilliseconds(string variableName) => Environment.GetEnvironmentVariable(variableName) is var value && long.TryParse(value, out var intValue) && intValue >= 0 ? TimeSpan.FromMilliseconds(intValue) : null; + internal static TimeSpan? ReadTimeSpanSeconds(string variableName) + => Environment.GetEnvironmentVariable(variableName) is var value && long.TryParse(value, out var intValue) && intValue >= 0 ? TimeSpan.FromSeconds(intValue) : null; + private static int? ReadInt(string variableName) => Environment.GetEnvironmentVariable(variableName) is var value && int.TryParse(value, out var intValue) ? intValue : null; diff --git a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs index 2f44472013f0..91d14e2bc234 100644 --- a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs +++ b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs @@ -28,8 +28,10 @@ internal sealed class CompilationHandler : IDisposable /// /// Projects that have been launched and to which we apply changes. + /// Maps to the list of running instances of that project. /// - private ImmutableDictionary> _runningProjects = ImmutableDictionary>.Empty; + private ImmutableDictionary> _runningProjects + = ImmutableDictionary>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer); /// /// All updates that were attempted. Includes updates whose application failed. @@ -43,7 +45,8 @@ internal sealed class CompilationHandler : IDisposable /// Current set of project instances indexed by . /// Updated whenever the project graph changes. /// - private ImmutableDictionary> _projectInstances = []; + private ImmutableDictionary> _projectInstances + = ImmutableDictionary>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer); public CompilationHandler(DotNetWatchContext context) { @@ -58,7 +61,7 @@ public void Dispose() Workspace?.Dispose(); } - private ILogger Logger + public ILogger Logger => _context.Logger; public async ValueTask TerminatePeripheralProcessesAndDispose(CancellationToken cancellationToken) @@ -81,9 +84,10 @@ private void DiscardPreviousUpdates(ImmutableArray projectsToBeRebuil _previousUpdates = _previousUpdates.RemoveAll(update => projectsToBeRebuilt.Contains(update.ProjectId)); } } + public async ValueTask StartSessionAsync(CancellationToken cancellationToken) { - Logger.Log(MessageDescriptor.HotReloadSessionStarting); + Logger.Log(MessageDescriptor.HotReloadSessionStartingNotification); var solution = Workspace.CurrentSolution; @@ -286,7 +290,7 @@ private async Task HandleRuntimeRudeEditAsync(RunningProject runningProject, str await runningProject.Process.TerminateAsync(); // Creates a new running project and launches it: - await runningProject.RestartOperation(cancellationToken); + await runningProject.RestartAsync(cancellationToken); } catch (Exception e) { @@ -393,10 +397,10 @@ public async ValueTask ApplyManagedCodeAndStaticAssetUpdatesAsync( CancellationToken cancellationToken) { var applyTasks = new List(); + ImmutableDictionary> projectsToUpdate = []; if (managedCodeUpdates is not []) { - ImmutableDictionary> projectsToUpdate; lock (_runningProjectsAndUpdatesGuard) { // Adding the updates makes sure that all new processes receive them before they are added to running processes. @@ -442,34 +446,38 @@ public async ValueTask ApplyManagedCodeAndStaticAssetUpdatesAsync( applyTasks.AddRange(await Task.WhenAll(staticAssetApplyTaskProducers)); // fire and forget: - _ = CompleteApplyOperationAsync(applyTasks, stopwatch, managedCodeUpdates.Count > 0, staticAssetUpdates.Count > 0); - } + _ = CompleteApplyOperationAsync(); - private async Task CompleteApplyOperationAsync(IEnumerable applyTasks, Stopwatch stopwatch, bool hasManagedCodeUpdates, bool hasStaticAssetUpdates) - { - try + async Task CompleteApplyOperationAsync() { - await Task.WhenAll(applyTasks); + try + { + await Task.WhenAll(applyTasks); - var elapsedMilliseconds = stopwatch.ElapsedMilliseconds; + var elapsedMilliseconds = stopwatch.ElapsedMilliseconds; - if (hasManagedCodeUpdates) - { - _context.Logger.Log(MessageDescriptor.ManagedCodeChangesApplied, elapsedMilliseconds); - } + if (managedCodeUpdates.Count > 0) + { + _context.Logger.Log(MessageDescriptor.ManagedCodeChangesApplied, elapsedMilliseconds); + } - if (hasStaticAssetUpdates) - { - _context.Logger.Log(MessageDescriptor.StaticAssetsChangesApplied, elapsedMilliseconds); - } - } - catch (Exception e) - { - // Handle all exceptions since this is a fire-and-forget task. + if (staticAssetUpdates.Count > 0) + { + _context.Logger.Log(MessageDescriptor.StaticAssetsChangesApplied, elapsedMilliseconds); + } - if (e is not OperationCanceledException) + _context.Logger.Log(MessageDescriptor.ChangesAppliedToProjectsNotification, + projectsToUpdate.Select(e => e.Value.First().Options.Representation).Concat( + staticAssetUpdates.Select(e => e.Key.Options.Representation))); + } + catch (Exception e) { - _context.Logger.LogError("Failed to apply managedCodeUpdates: {Exception}", e.ToString()); + // Handle all exceptions since this is a fire-and-forget task. + + if (e is not OperationCanceledException) + { + _context.Logger.LogError("Failed to apply managedCodeUpdates: {Exception}", e.ToString()); + } } } } @@ -838,6 +846,25 @@ internal async ValueTask> TerminatePeripheralProc return projectsToRestart; } + /// + /// Restarts given projects after their process have been terminated via . + /// + internal async Task RestartPeripheralProjectsAsync(IReadOnlyList projectsToRestart, CancellationToken cancellationToken) + { + if (projectsToRestart.Any(p => p.Options.IsMainProject)) + { + throw new InvalidOperationException("Main project can't be restarted."); + } + + _context.Logger.Log(MessageDescriptor.RestartingProjectsNotification, projectsToRestart.Select(p => p.Options.Representation)); + + await Task.WhenAll( + projectsToRestart.Select(async runningProject => runningProject.RestartAsync(cancellationToken))) + .WaitAsync(cancellationToken); + + _context.Logger.Log(MessageDescriptor.ProjectsRestarted, projectsToRestart.Count); + } + private bool RemoveRunningProject(RunningProject project) { var projectPath = project.ProjectNode.ProjectInstance.FullPath; @@ -894,15 +921,10 @@ private static ImmutableDictionary> Crea public async Task UpdateProjectGraphAsync(ProjectGraph projectGraph, CancellationToken cancellationToken) { - Logger.LogInformation("Loading projects ..."); - var stopwatch = Stopwatch.StartNew(); - _projectInstances = CreateProjectInstanceMap(projectGraph); var solution = await Workspace.UpdateProjectGraphAsync([.. projectGraph.EntryPointNodes.Select(n => n.ProjectInstance.FullPath)], cancellationToken); await SolutionUpdatedAsync(solution, "project update", cancellationToken); - - Logger.LogInformation("Projects loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); } public async Task UpdateFileContentAsync(IReadOnlyList changedFiles, CancellationToken cancellationToken) diff --git a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs index 05c363660763..8f82392d5872 100644 --- a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -287,11 +287,7 @@ await compilationHandler.GetManagedCodeUpdatesAsync( if (updates.ProjectsToRestart is not []) { - await Task.WhenAll( - updates.ProjectsToRestart.Select(async runningProject => runningProject.RestartOperation(shutdownCancellationToken))) - .WaitAsync(shutdownCancellationToken); - - _context.Logger.Log(MessageDescriptor.ProjectsRestarted, updates.ProjectsToRestart.Count); + await compilationHandler.RestartPeripheralProjectsAsync(updates.ProjectsToRestart, shutdownCancellationToken); } async Task> CaptureChangedFilesSnapshot(IReadOnlyList rebuiltProjects) @@ -881,15 +877,14 @@ private async ValueTask EvaluateProjectGraphAsync(bool restore { cancellationToken.ThrowIfCancellationRequested(); - _context.Logger.LogInformation("Loading projects ..."); + _context.Logger.Log(MessageDescriptor.LoadingProjects); var stopwatch = Stopwatch.StartNew(); var result = await EvaluationResult.TryCreateAsync(_designTimeBuildGraphFactory, _context.Options, _context.EnvironmentOptions, restore, cancellationToken); - var timeDisplay = stopwatch.Elapsed.TotalSeconds.ToString("0.0"); if (result != null) { - _context.Logger.LogInformation("Loaded {ProjectCount} project(s) in {Time}s.", result.ProjectGraph.Graph.ProjectNodes.Count, timeDisplay); + _context.Logger.Log(MessageDescriptor.LoadedProjects, result.ProjectGraph.Graph.ProjectNodes.Count, stopwatch.Elapsed.TotalSeconds); return result; } @@ -907,57 +902,74 @@ internal async Task BuildProjectsAsync(ImmutableArray p.PhysicalPath != null).Select(p => p.PhysicalPath!).ToArray(); + return success; - if (projectPaths is [var singleProjectPath]) + async Task BuildAsync() { - success |= await BuildFileOrProjectOrSolutionAsync(singleProjectPath, cancellationToken); - } - else if (projectPaths is not []) - { - var solutionFile = Path.Combine(Path.GetTempFileName() + ".slnx"); - var solutionElement = new XElement("Solution"); - - foreach (var projectPath in projectPaths) + if (projects is [var singleProject]) { - solutionElement.Add(new XElement("Project", new XAttribute("Path", projectPath))); + return await BuildFileOrProjectOrSolutionAsync(singleProject.ProjectOrEntryPointFilePath, cancellationToken); } - var doc = new XDocument(solutionElement); - doc.Save(solutionFile); + // TODO: workaround for https://github.com/dotnet/sdk/issues/51311 - try + var projectPaths = projects.Where(p => p.PhysicalPath != null).Select(p => p.PhysicalPath!).ToArray(); + + if (projectPaths is [var singleProjectPath]) { - success |= await BuildFileOrProjectOrSolutionAsync(solutionFile, cancellationToken); + if (!await BuildFileOrProjectOrSolutionAsync(singleProjectPath, cancellationToken)) + { + return false; + } } - finally + else if (projectPaths is not []) { + var solutionFile = Path.Combine(Path.GetTempFileName() + ".slnx"); + var solutionElement = new XElement("Solution"); + + foreach (var projectPath in projectPaths) + { + solutionElement.Add(new XElement("Project", new XAttribute("Path", projectPath))); + } + + var doc = new XDocument(solutionElement); + doc.Save(solutionFile); + try { - File.Delete(solutionFile); + if (!await BuildFileOrProjectOrSolutionAsync(solutionFile, cancellationToken)) + { + return false; + } } - catch + finally { - // ignore + try + { + File.Delete(solutionFile); + } + catch + { + // ignore + } } } - } - // To maximize parallelism of building dependencies, build file-based projects after all physical projects: - foreach (var file in projects.Where(p => p.EntryPointFilePath != null).Select(p => p.EntryPointFilePath!)) - { - success |= await BuildFileOrProjectOrSolutionAsync(file, cancellationToken); - } + // To maximize parallelism of building dependencies, build file-based projects after all physical projects: + foreach (var file in projects.Where(p => p.EntryPointFilePath != null).Select(p => p.EntryPointFilePath!)) + { + if (!await BuildFileOrProjectOrSolutionAsync(file, cancellationToken)) + { + return false; + } + } - return success; + return true; + } } private async Task BuildFileOrProjectOrSolutionAsync(string path, CancellationToken cancellationToken) @@ -965,7 +977,7 @@ private async Task BuildFileOrProjectOrSolutionAsync(string path, Cancella List? capturedOutput = _context.EnvironmentOptions.TestFlags != TestFlags.None ? [] : null; var processSpec = new ProcessSpec { - Executable = _context.EnvironmentOptions.MuxerPath, + Executable = _context.EnvironmentOptions.GetMuxerPath(), WorkingDirectory = Path.GetDirectoryName(path), IsUserApplication = false, diff --git a/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj b/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj index c43f9e6fac55..5b46ae0b06e7 100644 --- a/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj +++ b/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj @@ -31,10 +31,11 @@ + - + diff --git a/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs b/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs index f0357fd1e03e..4e31e8cced3a 100644 --- a/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs +++ b/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs @@ -27,6 +27,9 @@ public ILoggerFactory LoggerFactory public EnvironmentOptions EnvironmentOptions => context.EnvironmentOptions; + public CompilationHandler CompilationHandler + => compilationHandler; + public async ValueTask TryLaunchProcessAsync( ProjectOptions projectOptions, Action? onOutput, @@ -51,7 +54,7 @@ public EnvironmentOptions EnvironmentOptions var processSpec = new ProcessSpec { - Executable = EnvironmentOptions.MuxerPath, + Executable = EnvironmentOptions.GetMuxerPath(), IsUserApplication = true, WorkingDirectory = projectOptions.WorkingDirectory, OnOutput = onOutput, @@ -82,7 +85,7 @@ public EnvironmentOptions EnvironmentOptions if (clients.IsManagedAgentSupported && Logger.IsEnabled(LogLevel.Trace)) { environmentBuilder[EnvironmentVariables.Names.HotReloadDeltaClientLogMessages] = - (EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent).GetLogMessagePrefix() + $"[{projectDisplayName}]"; + (EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent).GetLogMessagePrefix(EnvironmentOptions.LogMessagePrefix) + $"[{projectDisplayName}]"; } clients.ConfigureLaunchEnvironment(environmentBuilder); diff --git a/src/Dotnet.Watch/Watch/Process/RunningProject.cs b/src/Dotnet.Watch/Watch/Process/RunningProject.cs index c12294aba562..ca1498aecbc5 100644 --- a/src/Dotnet.Watch/Watch/Process/RunningProject.cs +++ b/src/Dotnet.Watch/Watch/Process/RunningProject.cs @@ -8,7 +8,7 @@ namespace Microsoft.DotNet.Watch { - internal delegate ValueTask RestartOperation(CancellationToken cancellationToken); + internal delegate ValueTask RestartOperation(CancellationToken cancellationToken); internal sealed class RunningProject( ProjectGraphNode projectNode, @@ -27,7 +27,6 @@ internal sealed class RunningProject( public ILogger ClientLogger => clientLogger; public ImmutableArray ManagedCodeUpdateCapabilities => managedCodeUpdateCapabilities; public RunningProcess Process => process; - public RestartOperation RestartOperation => restartOperation; /// /// Set to true when the process termination is being requested so that it can be auto-restarted. @@ -85,5 +84,15 @@ public async Task CompleteApplyOperationAsync(Task applyTask) ClientLogger.LogError("Failed to apply updates to process {Process}: {Exception}", process.Id, e.ToString()); } } + + /// + /// Triggers restart operation. + /// + public async ValueTask RestartAsync(CancellationToken cancellationToken) + { + ClientLogger.Log(MessageDescriptor.ProjectRestarting); + await restartOperation(cancellationToken); + ClientLogger.Log(MessageDescriptor.ProjectRestarted); + } } } diff --git a/src/Dotnet.Watch/Watch/UI/ConsoleReporter.cs b/src/Dotnet.Watch/Watch/UI/ConsoleReporter.cs index 60b156142b08..baa0807958d0 100644 --- a/src/Dotnet.Watch/Watch/UI/ConsoleReporter.cs +++ b/src/Dotnet.Watch/Watch/UI/ConsoleReporter.cs @@ -9,10 +9,8 @@ namespace Microsoft.DotNet.Watch /// This API supports infrastructure and is not intended to be used /// directly from your code. This API may change or be removed in future releases. /// - internal sealed class ConsoleReporter(IConsole console, bool suppressEmojis) : IReporter, IProcessOutputReporter + internal sealed class ConsoleReporter(IConsole console, string logMessagePrefix, bool suppressEmojis) : IReporter, IProcessOutputReporter { - public bool SuppressEmojis { get; } = suppressEmojis; - private readonly Lock _writeLock = new(); bool IProcessOutputReporter.PrefixProcessOutput @@ -31,7 +29,7 @@ private void WriteLine(TextWriter writer, string message, ConsoleColor? color, E lock (_writeLock) { console.ForegroundColor = ConsoleColor.DarkGray; - writer.Write((SuppressEmojis ? Emoji.Default : emoji).GetLogMessagePrefix()); + writer.Write((suppressEmojis ? Emoji.Default : emoji).GetLogMessagePrefix(logMessagePrefix)); console.ResetColor(); if (color.HasValue) diff --git a/src/Dotnet.Watch/Watch/UI/IReporter.cs b/src/Dotnet.Watch/Watch/UI/IReporter.cs index d47cc2910cfc..4e2c7a0f9359 100644 --- a/src/Dotnet.Watch/Watch/UI/IReporter.cs +++ b/src/Dotnet.Watch/Watch/UI/IReporter.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -52,8 +53,8 @@ public static string ToDisplay(this Emoji emoji) _ => throw new InvalidOperationException() }; - public static string GetLogMessagePrefix(this Emoji emoji) - => $"dotnet watch {emoji.ToDisplay()} "; + public static string GetLogMessagePrefix(this Emoji emoji, string logMessagePrefix) + => $"{logMessagePrefix} {emoji.ToDisplay()} "; public static void Log(this ILogger logger, MessageDescriptor descriptor) => Log(logger, descriptor, default); @@ -96,7 +97,7 @@ public bool IsEnabled(LogLevel logLevel) public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { - if (!IsEnabled(logLevel)) + if (logLevel == LogLevel.None || !IsEnabled(logLevel)) { return; } @@ -133,12 +134,12 @@ public void AddProvider(ILoggerProvider provider) => throw new NotImplementedException(); } - internal abstract class MessageDescriptor(string format, Emoji emoji, LogLevel level, EventId id) + internal abstract class MessageDescriptor(string? format, Emoji emoji, LogLevel level, EventId id) { private static int s_id; private static ImmutableDictionary s_descriptors = []; - public string Format { get; } = format; + public string? Format { get; } = format; public Emoji Emoji { get; } = emoji; public LogLevel Level { get; } = level; public EventId Id { get; } = id; @@ -153,7 +154,14 @@ private static MessageDescriptor Create(string format, Emoji emoji private static MessageDescriptor Create(LogEvent logEvent, Emoji emoji) => Create(logEvent.Id, logEvent.Message, emoji, logEvent.Level); - private static MessageDescriptor Create(EventId id, string format, Emoji emoji, LogLevel level) + /// + /// Creates a descriptor that's only used for notifications not displayed to the user. + /// These can be used for testing or for custom loggers (e.g. Aspire status reporting). + /// + private static MessageDescriptor CreateNotification() + => Create(new EventId(++s_id), format: null, Emoji.Default, LogLevel.None); + + private static MessageDescriptor Create(EventId id, string? format, Emoji emoji, LogLevel level) { var descriptor = new MessageDescriptor(format, emoji, level, id); s_descriptors = s_descriptors.Add(id, descriptor); @@ -176,16 +184,20 @@ public static MessageDescriptor GetDescriptor(EventId id) // predefined messages used for testing: public static readonly MessageDescriptor CommandDoesNotSupportHotReload = Create("Command '{0}' does not support Hot Reload.", Emoji.HotReload, LogLevel.Debug); public static readonly MessageDescriptor HotReloadDisabledByCommandLineSwitch = Create("Hot Reload disabled by command line switch.", Emoji.HotReload, LogLevel.Debug); - public static readonly MessageDescriptor HotReloadSessionStarting = Create("Hot reload session starting.", Emoji.HotReload, LogLevel.None); + public static readonly MessageDescriptor HotReloadSessionStartingNotification = CreateNotification(); public static readonly MessageDescriptor HotReloadSessionStarted = Create("Hot reload session started.", Emoji.HotReload, LogLevel.Debug); public static readonly MessageDescriptor ProjectsRebuilt = Create("Projects rebuilt ({0})", Emoji.HotReload, LogLevel.Debug); public static readonly MessageDescriptor ProjectsRestarted = Create("Projects restarted ({0})", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor> RestartingProjectsNotification = CreateNotification>(); + public static readonly MessageDescriptor ProjectRestarting = Create("Restarting ...", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor ProjectRestarted = Create("Restarted", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor ProjectDependenciesDeployed = Create("Project dependencies deployed ({0})", Emoji.HotReload, LogLevel.Debug); public static readonly MessageDescriptor FixBuildError = Create("Fix the error to continue or press Ctrl+C to exit.", Emoji.Watch, LogLevel.Warning); public static readonly MessageDescriptor WaitingForChanges = Create("Waiting for changes", Emoji.Watch, LogLevel.Information); public static readonly MessageDescriptor<(string, string, int)> LaunchedProcess = Create<(string, string, int)>("Launched '{0}' with arguments '{1}': process id {2}", Emoji.Launch, LogLevel.Debug); public static readonly MessageDescriptor ManagedCodeChangesApplied = Create("C# and Razor changes applied in {0}ms.", Emoji.HotReload, LogLevel.Information); public static readonly MessageDescriptor StaticAssetsChangesApplied = Create("Static asset changes applied in {0}ms.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor> ChangesAppliedToProjectsNotification = CreateNotification>(); public static readonly MessageDescriptor SendingUpdateBatch = Create(LogEvents.SendingUpdateBatch, Emoji.HotReload); public static readonly MessageDescriptor UpdateBatchCompleted = Create(LogEvents.UpdateBatchCompleted, Emoji.HotReload); public static readonly MessageDescriptor UpdateBatchFailed = Create(LogEvents.UpdateBatchFailed, Emoji.HotReload); @@ -255,29 +267,40 @@ public static MessageDescriptor GetDescriptor(EventId id) public static readonly MessageDescriptor ApplicationKind_WebSockets = Create("Application kind: WebSockets.", Emoji.Default, LogLevel.Debug); public static readonly MessageDescriptor WatchingFilesForChanges = Create("Watching {0} file(s) for changes", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor WatchingFilesForChanges_FilePath = Create("> {0}", Emoji.Watch, LogLevel.Trace); - public static readonly MessageDescriptor Building = Create("Building {0} ...", Emoji.Default, LogLevel.Information); - public static readonly MessageDescriptor BuildSucceeded = Create("Build succeeded: {0}", Emoji.Default, LogLevel.Information); - public static readonly MessageDescriptor BuildFailed = Create("Build failed: {0}", Emoji.Default, LogLevel.Information); + public static readonly MessageDescriptor LoadingProjects = Create("Loading projects ...", Emoji.Watch, LogLevel.Information); + public static readonly MessageDescriptor<(int, double)> LoadedProjects = Create<(int, double)>("Loaded {0} project(s) in {1:0.0}s.", Emoji.Watch, LogLevel.Information); + public static readonly MessageDescriptor Building = Create("Building {0} ...", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor BuildFailed = Create("Build failed: {0}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor BuildSucceeded = Create("Build succeeded: {0}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor> BuildStartedNotification = CreateNotification>(); + public static readonly MessageDescriptor<(IEnumerable projects, bool success)> BuildCompletedNotification = CreateNotification<(IEnumerable projects, bool success)>(); } - internal sealed class MessageDescriptor(string format, Emoji emoji, LogLevel level, EventId id) - : MessageDescriptor(VerifyFormat(format), emoji, level, id) + internal sealed class MessageDescriptor(string? format, Emoji emoji, LogLevel level, EventId id) + : MessageDescriptor(VerifyFormat(format, level), emoji, level, id) { - private static string VerifyFormat(string format) + private static string? VerifyFormat(string? format, LogLevel level) { + Debug.Assert(format is null == level is LogLevel.None); #if DEBUG - var actualArity = format.Count(c => c == '{'); - var expectedArity = typeof(TArgs) == typeof(None) ? 0 - : typeof(TArgs).IsAssignableTo(typeof(ITuple)) ? typeof(TArgs).GenericTypeArguments.Length - : 1; + if (format != null) + { + var actualArity = format.Count(c => c == '{'); + var expectedArity = typeof(TArgs) == typeof(None) ? 0 + : typeof(TArgs).IsAssignableTo(typeof(ITuple)) ? typeof(TArgs).GenericTypeArguments.Length + : 1; - Debug.Assert(actualArity == expectedArity, $"Arguments of format string '{format}' do not match the specified type: {typeof(TArgs)} (actual arity: {actualArity}, expected arity: {expectedArity})"); + Debug.Assert(actualArity == expectedArity, $"Arguments of format string '{format}' do not match the specified type: {typeof(TArgs)} (actual arity: {actualArity}, expected arity: {expectedArity})"); + } #endif return format; } public string GetMessage(TArgs args) - => Id.Id == 0 ? Format : string.Format(Format, LogEvents.GetArgumentValues(args)); + { + Debug.Assert(Format != null); + return Id.Id == 0 ? Format : string.Format(Format, LogEvents.GetArgumentValues(args)); + } } internal interface IProcessOutputReporter diff --git a/src/Dotnet.Watch/dotnet-watch/Program.cs b/src/Dotnet.Watch/dotnet-watch/Program.cs index 32eed20213ff..0e6678dca900 100644 --- a/src/Dotnet.Watch/dotnet-watch/Program.cs +++ b/src/Dotnet.Watch/dotnet-watch/Program.cs @@ -21,6 +21,7 @@ internal sealed class Program( EnvironmentOptions environmentOptions) { public const string LogComponentName = nameof(Program); + private const string LogMessagePrefix = "dotnet watch"; public static async Task Main(string[] args) { @@ -39,16 +40,12 @@ public static async Task Main(string[] args) MSBuildLocator.RegisterMSBuildPath(sdkRootDirectory); + var environmentOptions = EnvironmentOptions.FromEnvironment(sdkRootDirectory, LogMessagePrefix); + // Register listeners that load Roslyn-related assemblies from the `Roslyn/bincore` directory. RegisterAssemblyResolutionEvents(sdkRootDirectory); - - var processPath = Environment.ProcessPath; - Debug.Assert(processPath != null); - - var environmentOptions = EnvironmentOptions.FromEnvironment(processPath); - // msbuild tasks depend on host path variable: - Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, environmentOptions.MuxerPath); + Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, environmentOptions.GetMuxerPath()); var program = TryCreate( args, @@ -73,7 +70,8 @@ public static async Task Main(string[] args) private static Program? TryCreate(IReadOnlyList args, IConsole console, EnvironmentOptions environmentOptions, out int errorCode) { - var parsingLoggerFactory = new LoggerFactory(new ConsoleReporter(console, environmentOptions.SuppressEmojis), environmentOptions.CliLogLevel ?? LogLevel.Information); + var reporter = new ConsoleReporter(console, environmentOptions.LogMessagePrefix, environmentOptions.SuppressEmojis); + var parsingLoggerFactory = new LoggerFactory(reporter, environmentOptions.CliLogLevel ?? LogLevel.Information); var options = CommandLineOptions.Parse(args, parsingLoggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName), console.Out, out errorCode); if (options == null) { @@ -81,14 +79,12 @@ public static async Task Main(string[] args) return null; } - var logLevel = environmentOptions.CliLogLevel ?? options.GlobalOptions.LogLevel; - var reporter = new ConsoleReporter(console, environmentOptions.SuppressEmojis); - var loggerFactory = new LoggerFactory(reporter, logLevel); + var loggerFactory = new LoggerFactory(reporter, environmentOptions.CliLogLevel ?? options.GlobalOptions.LogLevel); return TryCreate(options, console, environmentOptions, loggerFactory, reporter, out errorCode); } // internal for testing - internal static Program? TryCreate(CommandLineOptions options, IConsole console, EnvironmentOptions environmentOptions, LoggerFactory loggerFactory, IProcessOutputReporter processOutputReporter, out int errorCode) + internal static Program? TryCreate(CommandLineOptions options, IConsole console, EnvironmentOptions environmentOptions, ILoggerFactory loggerFactory, IProcessOutputReporter processOutputReporter, out int errorCode) { var logger = loggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName); diff --git a/src/Dotnet.Watch/dotnet-watch/Watch/DotNetWatcher.cs b/src/Dotnet.Watch/dotnet-watch/Watch/DotNetWatcher.cs index 62c6f6e894fe..650aa1b16ff3 100644 --- a/src/Dotnet.Watch/dotnet-watch/Watch/DotNetWatcher.cs +++ b/src/Dotnet.Watch/dotnet-watch/Watch/DotNetWatcher.cs @@ -50,7 +50,7 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke var processSpec = new ProcessSpec { - Executable = context.EnvironmentOptions.MuxerPath, + Executable = context.EnvironmentOptions.GetMuxerPath(), WorkingDirectory = context.EnvironmentOptions.WorkingDirectory, IsUserApplication = true, Arguments = buildEvaluator.GetProcessArguments(iteration), diff --git a/src/Dotnet.Watch/dotnet-watch/Watch/MsBuildFileSetFactory.cs b/src/Dotnet.Watch/dotnet-watch/Watch/MsBuildFileSetFactory.cs index 5a023d529d73..7217ffcf5366 100644 --- a/src/Dotnet.Watch/dotnet-watch/Watch/MsBuildFileSetFactory.cs +++ b/src/Dotnet.Watch/dotnet-watch/Watch/MsBuildFileSetFactory.cs @@ -55,7 +55,7 @@ internal sealed class EvaluationResult(IReadOnlyDictionary fil var processSpec = new ProcessSpec { - Executable = environmentOptions.MuxerPath, + Executable = environmentOptions.GetMuxerPath(), WorkingDirectory = projectDir, IsUserApplication = false, Arguments = arguments, diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif index 07aaa4edfbba..498b1e3a5e93 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif @@ -7092,4 +7092,4 @@ } } ] -} +} \ No newline at end of file diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs index 4ecca0d7bef7..9f38f3af4545 100644 --- a/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs @@ -1,38 +1,39 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.Diagnostics; -using System.Threading.Tasks.Dataflow; +using System.Threading.Channels; using Microsoft.NET.TestFramework.Utilities; -using Xunit.Abstractions; +using Xunit; namespace Microsoft.DotNet.Watch.UnitTests { - internal class AwaitableProcess(ITestOutputHelper logger) : IDisposable + internal sealed class AwaitableProcess : IAsyncDisposable { // cancel just before we hit timeout used on CI (XUnitWorkItemTimeout value in sdk\test\UnitTests.proj) private static readonly TimeSpan s_timeout = Environment.GetEnvironmentVariable("HELIX_WORK_ITEM_TIMEOUT") is { } value - ? TimeSpan.Parse(value).Subtract(TimeSpan.FromSeconds(10)) : TimeSpan.FromMinutes(1); - - private readonly object _testOutputLock = new(); + ? TimeSpan.Parse(value).Subtract(TimeSpan.FromSeconds(10)) : TimeSpan.FromMinutes(10); private readonly List _lines = []; - private readonly BufferBlock _source = new(); - private Process _process; - private bool _disposed; - public IEnumerable Output => _lines; - public int Id => _process.Id; - public Process Process => _process; + private CancellationTokenSource? _disposalCompletionSource = new(); + private readonly CancellationTokenSource _outputCompletionSource = new(); - public void Start(ProcessStartInfo processStartInfo) + private readonly Channel _outputChannel = Channel.CreateUnbounded(new() { - if (_process != null) - { - throw new InvalidOperationException("Already started"); - } + SingleReader = true, + SingleWriter = false + }); + + public int Id { get; } + public Process Process { get; } + public DebugTestOutputLogger Logger { get; } + + private readonly Task _processExitAwaiter; + + public AwaitableProcess(ProcessStartInfo processStartInfo, DebugTestOutputLogger logger) + { + Logger = logger; if (!processStartInfo.UseShellExecute && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -49,141 +50,169 @@ public void Start(ProcessStartInfo processStartInfo) processStartInfo.StandardOutputEncoding = Encoding.UTF8; processStartInfo.StandardErrorEncoding = Encoding.UTF8; - _process = new Process + Process = new Process { EnableRaisingEvents = true, StartInfo = processStartInfo, }; - _process.OutputDataReceived += OnData; - _process.ErrorDataReceived += OnData; - _process.Exited += OnExit; + Process.OutputDataReceived += OnData; + Process.ErrorDataReceived += OnData; + + Process.Start(); - WriteTestOutput($"{DateTime.Now}: starting process: '{_process.StartInfo.FileName} {_process.StartInfo.Arguments}'"); - _process.Start(); - _process.BeginErrorReadLine(); - _process.BeginOutputReadLine(); - WriteTestOutput($"{DateTime.Now}: process started: '{_process.StartInfo.FileName} {_process.StartInfo.Arguments}'"); + Process.BeginErrorReadLine(); + Process.BeginOutputReadLine(); + + try + { + Id = Process.Id; + } + catch + { + } + + _processExitAwaiter = WaitForProcessExitAsync(); } + public IEnumerable Output + => _lines; + public void ClearOutput() => _lines.Clear(); - public async Task GetOutputLineAsync(Predicate success, Predicate failure) + public async Task WaitForProcessExitAsync() { - using var cancellationOnFailure = new CancellationTokenSource(); - - if (!Debugger.IsAttached) - { - cancellationOnFailure.CancelAfter(s_timeout); - } - - var failedLineCount = 0; - while (!_source.Completion.IsCompleted && failedLineCount == 0) + while (true) { try { - while (await _source.OutputAvailableAsync(cancellationOnFailure.Token)) + if (Process.HasExited) { - var line = await _source.ReceiveAsync(cancellationOnFailure.Token); - _lines.Add(line); - if (success(line)) - { - return line; - } - - if (failure(line)) - { - if (failedLineCount == 0) - { - // Limit the time to collect remaining output after a failure to avoid hangs: - cancellationOnFailure.CancelAfter(TimeSpan.FromSeconds(1)); - } - - if (failedLineCount > 100) - { - break; - } - - failedLineCount++; - } + break; } } - catch (OperationCanceledException) when (failedLineCount > 0) + catch { break; } - } - return null; - } + using var iterationCancellationSource = new CancellationTokenSource(); + iterationCancellationSource.CancelAfter(TimeSpan.FromSeconds(1)); - public async Task> GetAllOutputLinesAsync(CancellationToken cancellationToken) - { - var lines = new List(); - while (!_source.Completion.IsCompleted) - { - while (await _source.OutputAvailableAsync(cancellationToken)) + try + { + await Process.WaitForExitAsync(iterationCancellationSource.Token); + break; + } + catch { - lines.Add(await _source.ReceiveAsync(cancellationToken)); } } - return lines; + + Logger.Log($"Process {Id} exited"); + _outputCompletionSource.Cancel(); } - private void OnData(object sender, DataReceivedEventArgs args) + public async Task GetRequiredOutputLineAsync(Predicate selector) { - var line = args.Data ?? string.Empty; - if (line.StartsWith("\x1b]")) - { - // strip terminal logger progress indicators from line - line = line.StripTerminalLoggerProgressIndicators(); - } + var line = await GetOutputLineAsync(selector); - WriteTestOutput(line); - _source.Post(line); + // process terminated without producing required output + Assert.NotNull(line); + + return line; } - private void WriteTestOutput(string text) + public async Task GetOutputLineAsync(Predicate selector) { - lock (_testOutputLock) + var disposalCompletionSource = _disposalCompletionSource; + ObjectDisposedException.ThrowIf(disposalCompletionSource == null, this); + + using var timeoutCancellation = new CancellationTokenSource(); + if (!Debugger.IsAttached) + { + timeoutCancellation.CancelAfter(s_timeout); + } + + using var outputReadCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _outputCompletionSource.Token, + disposalCompletionSource.Token, + timeoutCancellation.Token); + + if (!Debugger.IsAttached) { - if (!_disposed) + outputReadCancellation.CancelAfter(s_timeout); + } + + try + { + while (!outputReadCancellation.IsCancellationRequested) { - logger.WriteLine(text); + var line = await _outputChannel.Reader.ReadAsync(outputReadCancellation.Token); + _lines.Add(line); + if (selector(line)) + { + return line; + } } } - } + catch (OperationCanceledException) + { + if (timeoutCancellation.Token.IsCancellationRequested) + { + Assert.Fail($"Output not found within {s_timeout}"); + } - private void OnExit(object sender, EventArgs args) - { - // Wait to ensure the process has exited and all output consumed - _process.WaitForExit(); + if (disposalCompletionSource.Token.IsCancellationRequested) + { + Assert.Fail($"Test disposed while waiting for output"); + } + } - // Signal test methods waiting on all process output to be completed: - _source.Complete(); + // Read the remaining output that may have been written to + // the channel but not read yet when the process exited. + while (_outputChannel.Reader.TryRead(out var line)) + { + _lines.Add(line); + if (selector(line)) + { + return line; + } + } + + return null; } - public void Dispose() - { - _source.Complete(); + public Task WaitUntilOutputCompleted() + => GetOutputLineAsync(_ => false); - lock (_testOutputLock) + private void OnData(object sender, DataReceivedEventArgs args) + { + var line = args.Data ?? string.Empty; + if (line.StartsWith("\x1b]")) { - _disposed = true; + // strip terminal logger progress indicators from line + line = line.StripTerminalLoggerProgressIndicators(); } - if (_process == null) - { - return; - } + Logger.WriteLine(line); + + Assert.True(_outputChannel.Writer.TryWrite(line)); + } + + public async ValueTask DisposeAsync() + { + var disposalCompletionSource = Interlocked.Exchange(ref _disposalCompletionSource, null); + ObjectDisposedException.ThrowIf(disposalCompletionSource == null, this); + disposalCompletionSource.Cancel(); - _process.ErrorDataReceived -= OnData; - _process.OutputDataReceived -= OnData; + Process.ErrorDataReceived -= OnData; + Process.OutputDataReceived -= OnData; try { - _process.CancelErrorRead(); + Process.CancelErrorRead(); } catch { @@ -191,7 +220,7 @@ public void Dispose() try { - _process.CancelOutputRead(); + Process.CancelOutputRead(); } catch { @@ -199,14 +228,18 @@ public void Dispose() try { - _process.Kill(entireProcessTree: false); + Process.Kill(entireProcessTree: true); } catch { } - _process.Dispose(); - _process = null; + // ensure process has exited + await _processExitAwaiter; + + Process.Dispose(); + + _outputCompletionSource.Dispose(); } } } diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/DebugTestOutputLogger.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/DebugTestOutputLogger.cs index a6f51843881f..411cbb360967 100644 --- a/test/Microsoft.DotNet.HotReload.Test.Utilities/DebugTestOutputLogger.cs +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/DebugTestOutputLogger.cs @@ -17,10 +17,19 @@ public void Log(string message, [CallerFilePath] string? testPath = null, [Calle public void WriteLine(string message) { Debug.WriteLine(message); - logger.WriteLine(message); + + try + { + logger.WriteLine(message); + } + catch (InvalidOperationException) + { + // test output might have been disposed + } + OnMessage?.Invoke(message); } public void WriteLine(string format, params object[] args) - => WriteLine(string.Format(format, args)); + => WriteLine(string.Format(format, args)); } diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs index e388919a4672..be7b5f747c80 100644 --- a/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs @@ -19,6 +19,11 @@ internal class TestLogger(ITestOutputHelper? output = null) : ILogger public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { + if (logLevel == LogLevel.None) + { + return; + } + var message = $"[{logLevel}] {formatter(state, exception)}"; lock (Guard) diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchSdkTest.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchSdkTest.cs new file mode 100644 index 000000000000..9ec72d8412cb --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchSdkTest.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; +using Microsoft.NET.TestFramework; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public abstract class WatchSdkTest(ITestOutputHelper logger) + : SdkTest(new DebugTestOutputLogger(logger)) +{ + public TestAssetsManager TestAssets + => _testAssetsManager; + + public DebugTestOutputLogger Logger + => (DebugTestOutputLogger)base.Log; + + public new void Log(string message, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0) + => Logger.Log(message, testPath, testLine); + + public void UpdateSourceFile(string path, string text, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0) + { + var existed = File.Exists(path); + WriteAllText(path, text); + Log($"File '{path}' " + (existed ? "updated" : "added"), testPath, testLine); + } + + public void UpdateSourceFile(string path, Func contentTransform, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0) + => UpdateSourceFile(path, contentTransform(File.ReadAllText(path, Encoding.UTF8)), testPath, testLine); + + /// + /// Replacement for , which fails to write to hidden file + /// + public static void WriteAllText(string path, string text) + { + using var stream = File.Open(path, FileMode.OpenOrCreate); + + using (var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + writer.Write(text); + } + + // truncate the rest of the file content: + stream.SetLength(stream.Position); + } + + public void UpdateSourceFile(string path) + => UpdateSourceFile(path, content => content); +} diff --git a/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchableApp.cs similarity index 50% rename from test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs rename to test/Microsoft.DotNet.HotReload.Test.Utilities/WatchableApp.cs index 5aca2df2b4c7..f858d5026a27 100644 --- a/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/WatchableApp.cs @@ -1,38 +1,45 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text.RegularExpressions; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.NET.TestFramework; +using Xunit; +using Xunit.Abstractions; namespace Microsoft.DotNet.Watch.UnitTests { - internal sealed class WatchableApp(DebugTestOutputLogger logger) : IDisposable + internal sealed class WatchableApp( + ITestOutputHelper logger, + string executablePath, + string commandName, + IEnumerable commandArguments) + : IAsyncDisposable { - // Test apps should output this message as soon as they start running: - private const string StartedMessage = "Started"; - - // Test apps should output this message as soon as they exit: - private const string ExitingMessage = "Exiting"; + public static WatchableApp CreateDotnetWatchApp(ITestOutputHelper logger) + => new(logger, TestContext.Current.ToolsetUnderTest.DotNetHostPath, "watch", ["-bl"]); - private const string WatchErrorOutputEmoji = "❌"; - private const string WatchFileChanged = "dotnet watch ⌚ File changed:"; + public DebugTestOutputLogger Logger { get; } = new DebugTestOutputLogger(logger); public TestFlags TestFlags { get; private set; } - public DebugTestOutputLogger Logger => logger; + private AwaitableProcess? _process; - public AwaitableProcess Process { get; private set; } + public AwaitableProcess Process + { + get => _process ?? throw new InvalidOperationException("Process has not been started yet."); + } - public List DotnetWatchArgs { get; } = ["--verbose", "-bl"]; + public List WatchArgs { get; } = [.. commandArguments]; public Dictionary EnvironmentVariables { get; } = []; public void SuppressVerboseLogging() { - // remove default --verbose and -bl args - DotnetWatchArgs.Clear(); + // remove default -bl args + WatchArgs.Clear(); // override the default used for testing ("trace"): EnvironmentVariables.Add("DOTNET_CLI_CONTEXT_VERBOSE", ""); @@ -44,7 +51,7 @@ public void AssertOutputContains(string message) public void AssertOutputContains(Regex pattern) => AssertEx.ContainsPattern(pattern, Process.Output); - public void AssertOutputContains(MessageDescriptor descriptor, string projectDisplay = null) + public void AssertOutputContains(MessageDescriptor descriptor, string? projectDisplay = null) => AssertOutputContains(GetPattern(descriptor, projectDisplay, out _)); public void AssertOutputDoesNotContain(string message) @@ -53,10 +60,10 @@ public void AssertOutputDoesNotContain(string message) public void AssertOutputDoesNotContain(Regex pattern) => AssertEx.DoesNotContainPattern(pattern, Process.Output); - public void AssertOutputDoesNotContain(MessageDescriptor descriptor, string projectDisplay = null) + public void AssertOutputDoesNotContain(MessageDescriptor descriptor, string? projectDisplay = null) => AssertOutputDoesNotContain(GetPattern(descriptor, projectDisplay, out _)); - private static Regex GetPattern(MessageDescriptor descriptor, string projectDisplay, out string patternDisplay) + private static Regex GetPattern(MessageDescriptor descriptor, string? projectDisplay, out string patternDisplay) { var prefix = projectDisplay != null ? $"[{projectDisplay}] " : ""; var pattern = new Regex(Regex.Replace(Regex.Escape(prefix + descriptor.Format), @"\\\{[0-9]+\}", ".*")); @@ -64,100 +71,99 @@ private static Regex GetPattern(MessageDescriptor descriptor, string projectDisp return pattern; } - private void LogFoundOutput(string pattern, string testPath, int testLine) + private void LogFoundOutput(string pattern, string? testPath, int testLine) => Logger.Log($"Found output matching: '{pattern}'", testPath, testLine); - private void LogWaitingForOutput(string pattern, string testPath, int testLine) + private void LogWaitingForOutput(string pattern, string? testPath, int testLine) => Logger.Log($"Waiting for output matching: '{pattern}'", testPath, testLine); - public async ValueTask WaitUntilOutputContains(string text, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0) + public async ValueTask WaitUntilOutputContains(string text, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0) { - if (!Process.Output.Any(line => line.Contains(text))) + var matchingLine = Process.Output.FirstOrDefault(line => line.Contains(text)); + if (matchingLine == null) { LogWaitingForOutput(text, testPath, testLine); - _ = await WaitForOutputLineMatching(line => line.Contains(text)); + matchingLine = await Process.GetRequiredOutputLineAsync(line => line.Contains(text)); } LogFoundOutput(text, testPath, testLine); + return matchingLine; } - public async ValueTask WaitUntilOutputContains(Regex pattern, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0) + public async ValueTask WaitUntilOutputContains(Regex pattern, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0) { var patternDisplay = pattern.ToString(); - if (!Process.Output.Any(line => pattern.IsMatch(line))) + var matchingLine = Process.Output.FirstOrDefault(pattern.IsMatch); + if (matchingLine == null) { LogWaitingForOutput(patternDisplay, testPath, testLine); - _ = await WaitForOutputLineMatching(line => pattern.IsMatch(line)); + matchingLine = await Process.GetRequiredOutputLineAsync(pattern.IsMatch); } LogFoundOutput(patternDisplay, testPath, testLine); + return matchingLine; } - public async ValueTask WaitUntilOutputContains(MessageDescriptor descriptor, string projectDisplay = null, [CallerLineNumber] int testLine = 0, [CallerFilePath] string testPath = null) + public async ValueTask WaitUntilOutputContains(MessageDescriptor descriptor, string? projectDisplay = null, [CallerLineNumber] int testLine = 0, [CallerFilePath] string? testPath = null) { var pattern = GetPattern(descriptor, projectDisplay, out var patternDisplay); + var matchingLine = Process.Output.FirstOrDefault(pattern.IsMatch); - if (!Process.Output.Any(line => pattern.IsMatch(line))) + if (matchingLine == null) { LogWaitingForOutput(patternDisplay, testPath, testLine); - _ = await WaitForOutputLineMatching(line => pattern.IsMatch(line)); + matchingLine = await Process.GetRequiredOutputLineAsync(line => pattern.IsMatch(line)); } LogFoundOutput(patternDisplay, testPath, testLine); + return matchingLine; } - public Task WaitForOutputLineContaining(string text, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0) + public Task WaitForOutputLineContaining(string text, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0) { LogWaitingForOutput(text, testPath, testLine); - var line = Process.GetOutputLineAsync(success: line => line.Contains(text), failure: _ => false); + var line = Process.GetRequiredOutputLineAsync(line => line.Contains(text)); LogFoundOutput(text, testPath, testLine); return line; } - public Task WaitForOutputLineContaining(MessageDescriptor descriptor, string projectDisplay = null, [CallerLineNumber] int testLine = 0, [CallerFilePath] string testPath = null) + public Task WaitForOutputLineContaining(MessageDescriptor descriptor, string? projectDisplay = null, [CallerLineNumber] int testLine = 0, [CallerFilePath] string? testPath = null) { var pattern = GetPattern(descriptor, projectDisplay, out var patternDisplay); LogWaitingForOutput(patternDisplay, testPath, testLine); - var line = Process.GetOutputLineAsync(success: line => pattern.IsMatch(line), failure: _ => false); + var line = Process.GetRequiredOutputLineAsync(line => pattern.IsMatch(line)); LogFoundOutput(patternDisplay, testPath, testLine); return line; } - public Task WaitForOutputLineContaining(Regex pattern, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0) + public Task WaitForOutputLineContaining(Regex pattern, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0) { var patternDisplay = pattern.ToString(); LogWaitingForOutput(patternDisplay, testPath, testLine); - var line = Process.GetOutputLineAsync(success: line => pattern.IsMatch(line), failure: _ => false); + var line = Process.GetRequiredOutputLineAsync(line => pattern.IsMatch(line)); LogFoundOutput(patternDisplay, testPath, testLine); return line; } - private Task WaitForOutputLineMatching(Predicate predicate) - => Process.GetOutputLineAsync(success: predicate, failure: _ => false); - /// /// Asserts that the watched process outputs a line starting with and returns the remainder of that line. /// - public async Task AssertOutputLineStartsWith(string expectedPrefix, Predicate failure = null, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0) + public async Task AssertOutputLineStartsWith(string expectedPrefix, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0) { var display = $"^{expectedPrefix}.*"; LogWaitingForOutput(display, testPath, testLine); - var line = await Process.GetOutputLineAsync( - success: line => line.StartsWith(expectedPrefix, StringComparison.Ordinal), - failure: failure ?? new Predicate(line => line.Contains(WatchErrorOutputEmoji, StringComparison.Ordinal))); + var line = await Process.GetOutputLineAsync(line => line.StartsWith(expectedPrefix, StringComparison.Ordinal)); if (line == null) { - Assert.Fail(failure != null - ? "Encountered failure condition" - : $"Failed to find expected prefix: '{expectedPrefix}'"); + Assert.Fail($"Failed to find expected prefix: '{expectedPrefix}'"); } else { @@ -172,16 +178,58 @@ public async Task AssertOutputLineStartsWith(string expectedPrefix, Pred public async Task AssertOutputLineEquals(string expectedLine) => Assert.Equal("", await AssertOutputLineStartsWith(expectedLine)); - public Task AssertStarted() - => AssertOutputLineEquals(StartedMessage); + public ProcessStartInfo GetProcessStartInfo(string workingDirectory, string testOutputPath, IEnumerable arguments, TestFlags testFlags) + { + var args = new List() + { + commandName + }; + + args.AddRange(WatchArgs); + args.AddRange(arguments); + + var info = new ProcessStartInfo + { + FileName = executablePath, + WorkingDirectory = workingDirectory, + Arguments = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(args), + UseShellExecute = false, + RedirectStandardInput = false, + }; + + // FileSystemWatcher is unreliable. Use polling for testing to avoid flakiness. + info.Environment.Add("DOTNET_USE_POLLING_FILE_WATCHER", "true"); + info.Environment.Add("__DOTNET_WATCH_TEST_FLAGS", testFlags.ToString()); + info.Environment.Add("__DOTNET_WATCH_TEST_OUTPUT_DIR", testOutputPath); + info.Environment.Add("Microsoft_CodeAnalysis_EditAndContinue_LogDir", testOutputPath); + info.Environment.Add("DOTNET_CLI_CONTEXT_VERBOSE", "trace"); + + // suppress all timeouts: + info.Environment.Add("DCP_IDE_REQUEST_TIMEOUT_SECONDS", "100000"); + info.Environment.Add("DCP_IDE_NOTIFICATION_TIMEOUT_SECONDS", "100000"); + info.Environment.Add("DCP_IDE_NOTIFICATION_KEEPALIVE_SECONDS", "100000"); + info.Environment.Add("ASPIRE_ALLOW_UNSECURED_TRANSPORT", "1"); + info.Environment.Add("ASPIRE_WATCH_PIPE_CONNECTION_TIMEOUT_SECONDS", "100000"); + + // override defaults: + foreach (var (name, value) in EnvironmentVariables) + { + info.Environment[name] = value; + } - public Task AssertFileChanged() - => AssertOutputLineStartsWith(WatchFileChanged); + TestContext.Current.AddTestEnvironmentVariables(info.Environment); - public Task AssertExiting() - => AssertOutputLineStartsWith(ExitingMessage); + return info; + } - public void Start(TestAsset asset, IEnumerable arguments, string relativeProjectDirectory = null, string workingDirectory = null, TestFlags testFlags = TestFlags.RunningAsTest) + public void Start( + TestAsset asset, + IEnumerable arguments, + string? relativeProjectDirectory = null, + string? workingDirectory = null, + TestFlags testFlags = TestFlags.RunningAsTest, + [CallerFilePath] string? testPath = null, + [CallerLineNumber] int testLine = 0) { if (testFlags != TestFlags.None) { @@ -190,43 +238,25 @@ public void Start(TestAsset asset, IEnumerable arguments, string relativ var projectDirectory = (relativeProjectDirectory != null) ? Path.Combine(asset.Path, relativeProjectDirectory) : asset.Path; - var commandSpec = new DotnetCommand(Logger, ["watch", .. DotnetWatchArgs, .. arguments]) - { - WorkingDirectory = workingDirectory ?? projectDirectory, - }; - var testOutputPath = asset.GetWatchTestOutputPath(); Directory.CreateDirectory(testOutputPath); - // FileSystemWatcher is unreliable. Use polling for testing to avoid flakiness. - commandSpec.WithEnvironmentVariable("DOTNET_USE_POLLING_FILE_WATCHER", "true"); + var processStartInfo = GetProcessStartInfo(workingDirectory ?? projectDirectory, testOutputPath, arguments, testFlags); - commandSpec.WithEnvironmentVariable("__DOTNET_WATCH_TEST_FLAGS", testFlags.ToString()); - commandSpec.WithEnvironmentVariable("__DOTNET_WATCH_TEST_OUTPUT_DIR", testOutputPath); - commandSpec.WithEnvironmentVariable("Microsoft_CodeAnalysis_EditAndContinue_LogDir", testOutputPath); - commandSpec.WithEnvironmentVariable("DOTNET_CLI_CONTEXT_VERBOSE", "trace"); + _process = new AwaitableProcess(processStartInfo, Logger); - // suppress all timeouts: - commandSpec.WithEnvironmentVariable("DCP_IDE_REQUEST_TIMEOUT_SECONDS", "100000"); - commandSpec.WithEnvironmentVariable("DCP_IDE_NOTIFICATION_TIMEOUT_SECONDS", "100000"); - commandSpec.WithEnvironmentVariable("DCP_IDE_NOTIFICATION_KEEPALIVE_SECONDS", "100000"); - commandSpec.WithEnvironmentVariable("ASPIRE_ALLOW_UNSECURED_TRANSPORT", "1"); - - foreach (var env in EnvironmentVariables) - { - commandSpec.WithEnvironmentVariable(env.Key, env.Value); - } - - var processStartInfo = commandSpec.GetProcessStartInfo(); - Process = new AwaitableProcess(Logger); - Process.Start(processStartInfo); + Logger.Log($"Process {Process.Id} started: '{processStartInfo.FileName} {processStartInfo.Arguments}'", testPath, testLine); TestFlags = testFlags; } - public void Dispose() + public async ValueTask DisposeAsync() { - Process?.Dispose(); + var process = _process; + if (process != null) + { + await process.DisposeAsync(); + } } public void SendControlC() diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs new file mode 100644 index 000000000000..b7d9c7f69059 --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs @@ -0,0 +1,145 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class AspireHostLauncherCliTests +{ + [Fact] + public void RequiredSdkOption() + { + // --sdk option is missing + var args = new[] { "host", "--entrypoint", "proj", "a", "b" }; + var launcher = AspireLauncher.TryCreate(args); + Assert.Null(launcher); + } + + [Fact] + public void RequiredEntryPointOption() + { + // --entrypoint option is missing + var args = new[] { "host", "--sdk", "sdk", "--verbose" }; + var launcher = AspireLauncher.TryCreate(args); + Assert.Null(launcher); + } + + [Fact] + public void ProjectAndSdkPaths() + { + var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "myproject.csproj" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.Equal("sdk", launcher.EnvironmentOptions.SdkDirectory); + Assert.True(launcher.EntryPoint.IsProjectFile); + Assert.Equal("myproject.csproj", launcher.EntryPoint.PhysicalPath); + Assert.Empty(launcher.ApplicationArguments); + Assert.Equal(LogLevel.Information, launcher.GlobalOptions.LogLevel); + } + + [Fact] + public void FilePath() + { + var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "file.cs" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.Equal("sdk", launcher.EnvironmentOptions.SdkDirectory); + Assert.False(launcher.EntryPoint.IsProjectFile); + Assert.Equal("file.cs", launcher.EntryPoint.EntryPointFilePath); + Assert.Empty(launcher.ApplicationArguments); + Assert.Equal(LogLevel.Information, launcher.GlobalOptions.LogLevel); + } + + [Fact] + public void ApplicationArguments() + { + var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--verbose", "a", "b" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + AssertEx.SequenceEqual(["a", "b"], launcher.ApplicationArguments); + Assert.Equal(LogLevel.Debug, launcher.GlobalOptions.LogLevel); + } + + [Fact] + public void VerboseOption() + { + // With verbose flag + var argsVerbose = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--verbose" }; + var launcherVerbose = Assert.IsType(AspireLauncher.TryCreate(argsVerbose)); + Assert.Equal(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel); + + // Without verbose flag + var argsNotVerbose = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj" }; + var launcherNotVerbose = Assert.IsType(AspireLauncher.TryCreate(argsNotVerbose)); + Assert.Equal(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel); + } + + [Fact] + public void QuietOption() + { + // With quiet flag + var argsQuiet = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--quiet" }; + var launcherQuiet = Assert.IsType(AspireLauncher.TryCreate(argsQuiet)); + Assert.Equal(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel); + + // Without quiet flag + var argsNotQuiet = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj" }; + var launcherNotQuiet = Assert.IsType(AspireLauncher.TryCreate(argsNotQuiet)); + Assert.Equal(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel); + } + + [Fact] + public void NoLaunchProfileOption() + { + // With no-launch-profile flag + var argsNoProfile = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--no-launch-profile" }; + var launcherNoProfile = Assert.IsType(AspireLauncher.TryCreate(argsNoProfile)); + Assert.False(launcherNoProfile.LaunchProfileName.HasValue); + + // Without no-launch-profile flag + var argsDefault = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj" }; + var launcherDefault = Assert.IsType(AspireLauncher.TryCreate(argsDefault)); + Assert.True(launcherDefault.LaunchProfileName.HasValue); + Assert.Null(launcherDefault.LaunchProfileName.Value); + } + + [Fact] + public void LaunchProfileOption() + { + var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--launch-profile", "MyProfile" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.True(launcher.LaunchProfileName.HasValue); + Assert.Equal("MyProfile", launcher.LaunchProfileName.Value); + } + + [Fact] + public void ConflictingOptions() + { + // Cannot specify both --quiet and --verbose + var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--quiet", "--verbose" }; + var launcher = AspireLauncher.TryCreate(args); + Assert.Null(launcher); + } + + [Fact] + public void EntryPoint_MultipleValues() + { + // EntryPoint option should only accept one value; extra values become application arguments + var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj1", "proj2" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.Equal("proj1", launcher.EntryPoint.ProjectOrEntryPointFilePath); + AssertEx.SequenceEqual(["proj2"], launcher.ApplicationArguments); + } + + [Fact] + public void AllOptionsSet() + { + var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "myapp.csproj", "--verbose", "--no-launch-profile", "arg1", "arg2", "arg3" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + + Assert.True(launcher.EntryPoint.IsProjectFile); + Assert.Equal("myapp.csproj", launcher.EntryPoint.PhysicalPath); + Assert.Equal("sdk", launcher.EnvironmentOptions.SdkDirectory); + Assert.Equal(LogLevel.Debug, launcher.GlobalOptions.LogLevel); + Assert.False(launcher.LaunchProfileName.HasValue); + AssertEx.SequenceEqual(["arg1", "arg2", "arg3"], launcher.ApplicationArguments); + } +} diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs new file mode 100644 index 000000000000..52ac55d6cbdf --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.CommandLine; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class AspireHostLauncherTests +{ + private static AspireHostLauncher CreateLauncher( + string entryPointPath, + Optional launchProfileName = default, + ImmutableArray applicationArguments = default, + string? workingDirectory = null, + string? sdkDirectory = null) + { + return new AspireHostLauncher( + globalOptions: new GlobalOptions() + { + LogLevel = LogLevel.Information, + NoHotReload = false, + NonInteractive = true, + }, + environmentOptions: new EnvironmentOptions( + WorkingDirectory: workingDirectory ?? "/work", + SdkDirectory: sdkDirectory ?? "/sdk", + LogMessagePrefix: AspireHostLauncher.LogMessagePrefix), + entryPoint: ProjectRepresentation.FromProjectOrEntryPointFilePath(entryPointPath), + applicationArguments: applicationArguments.IsDefault ? [] : applicationArguments, + launchProfileName: launchProfileName); + } + + private static void AssertCommonProperties(ProjectOptions options, AspireHostLauncher launcher) + { + Assert.True(options.IsMainProject); + Assert.Equal("run", options.Command); + Assert.Equal(launcher.EntryPoint, options.Representation); + Assert.Empty(options.LaunchEnvironmentVariables); + } + + [Fact] + public void GetProjectOptions_ProjectFile_UsesProjectFlag() + { + var launcher = CreateLauncher("myapp.csproj"); + + var options = launcher.GetProjectOptions(); + + AssertCommonProperties(options, launcher); + Assert.False(options.LaunchProfileName.HasValue); + AssertEx.SequenceEqual(["--project", "myapp.csproj", "--no-launch-profile"], options.CommandArguments); + } + + [Fact] + public void GetProjectOptions_EntryPointFile_UsesFileFlag() + { + var launcher = CreateLauncher("Program.cs"); + + var options = launcher.GetProjectOptions(); + + AssertCommonProperties(options, launcher); + Assert.False(options.LaunchProfileName.HasValue); + AssertEx.SequenceEqual(["--file", "Program.cs", "--no-launch-profile"], options.CommandArguments); + } + + [Fact] + public void GetProjectOptions_WithLaunchProfile_AddsLaunchProfileArguments() + { + var launcher = CreateLauncher("myapp.csproj", launchProfileName: "MyProfile"); + + var options = launcher.GetProjectOptions(); + + AssertCommonProperties(options, launcher); + Assert.True(options.LaunchProfileName.HasValue); + Assert.Equal("MyProfile", options.LaunchProfileName.Value); + AssertEx.SequenceEqual(["--project", "myapp.csproj", "--launch-profile", "MyProfile"], options.CommandArguments); + } + + [Fact] + public void GetProjectOptions_NoLaunchProfile_AddsNoLaunchProfileFlag() + { + var launcher = CreateLauncher("myapp.csproj", launchProfileName: Optional.NoValue); + + var options = launcher.GetProjectOptions(); + + AssertCommonProperties(options, launcher); + Assert.False(options.LaunchProfileName.HasValue); + AssertEx.SequenceEqual(["--project", "myapp.csproj", "--no-launch-profile"], options.CommandArguments); + } + + [Fact] + public void GetProjectOptions_NullLaunchProfile_UsesDefault() + { + // null value (HasValue=true) means use default launch profile - no --launch-profile or --no-launch-profile flag + var launcher = CreateLauncher("myapp.csproj", launchProfileName: (string?)null); + + var options = launcher.GetProjectOptions(); + + AssertCommonProperties(options, launcher); + Assert.True(options.LaunchProfileName.HasValue); + Assert.Null(options.LaunchProfileName.Value); + AssertEx.SequenceEqual(["--project", "myapp.csproj"], options.CommandArguments); + } + + [Fact] + public void GetProjectOptions_WithApplicationArguments_AppendsArguments() + { + var launcher = CreateLauncher("myapp.csproj", launchProfileName: "Profile", applicationArguments: ["arg1", "arg2"]); + + var options = launcher.GetProjectOptions(); + + AssertCommonProperties(options, launcher); + Assert.True(options.LaunchProfileName.HasValue); + Assert.Equal("Profile", options.LaunchProfileName.Value); + AssertEx.SequenceEqual(["--project", "myapp.csproj", "--launch-profile", "Profile", "arg1", "arg2"], options.CommandArguments); + } + + [Fact] + public void GetProjectOptions_SetsCustomWorkingDirectory() + { + var launcher = CreateLauncher("myapp.csproj", workingDirectory: "/custom/path"); + + var options = launcher.GetProjectOptions(); + + AssertCommonProperties(options, launcher); + Assert.Equal("/custom/path", options.WorkingDirectory); + } + + [Fact] + public void GetProjectOptions_EntryPointFile_WithLaunchProfileAndArguments() + { + var launcher = CreateLauncher("Program.cs", launchProfileName: "Dev", applicationArguments: ["--port", "8080"]); + + var options = launcher.GetProjectOptions(); + + AssertCommonProperties(options, launcher); + Assert.True(options.LaunchProfileName.HasValue); + Assert.Equal("Dev", options.LaunchProfileName.Value); + AssertEx.SequenceEqual(["--file", "Program.cs", "--launch-profile", "Dev", "--port", "8080"], options.CommandArguments); + } + + [Fact] + public void GetProjectOptions_NoLaunchProfile_WithApplicationArguments() + { + var launcher = CreateLauncher("myapp.csproj", launchProfileName: Optional.NoValue, applicationArguments: ["--urls", "http://localhost:5000"]); + + var options = launcher.GetProjectOptions(); + + AssertCommonProperties(options, launcher); + Assert.False(options.LaunchProfileName.HasValue); + AssertEx.SequenceEqual(["--project", "myapp.csproj", "--no-launch-profile", "--urls", "http://localhost:5000"], options.CommandArguments); + } +} diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs new file mode 100644 index 000000000000..6a02ec6ab4c8 --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs @@ -0,0 +1,145 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO.Pipes; +using System.Reflection.Metadata; +using System.Text.Json; +using Elfie.Serialization; +using Xunit.Runners; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class AspireLauncherTests(ITestOutputHelper logger) : WatchSdkTest(logger) +{ + private WatchableApp CreateHostApp() + => new( + Logger, + executablePath: Path.ChangeExtension(typeof(AspireLauncher).Assembly.Location, PathUtilities.ExecutableExtension).TrimEnd('.'), + commandName: "host", + commandArguments: ["--sdk", TestContext.Current.ToolsetUnderTest.SdkFolderUnderTest]); + + private WatchableApp CreateServerApp(string serverPipe) + => new( + Logger, + executablePath: Path.ChangeExtension(typeof(AspireLauncher).Assembly.Location, PathUtilities.ExecutableExtension).TrimEnd('.'), + commandName: "server", + commandArguments: ["--sdk", TestContext.Current.ToolsetUnderTest.SdkFolderUnderTest, "--server", serverPipe]); + + private WatchableApp CreateResourceApp(string serverPipe) + => new( + Logger, + executablePath: Path.ChangeExtension(typeof(AspireLauncher).Assembly.Location, PathUtilities.ExecutableExtension).TrimEnd('.'), + commandName: "resource", + commandArguments: ["--server", serverPipe]); + + [PlatformSpecificFact(TestPlatforms.Windows | TestPlatforms.Linux)] // https://github.com/dotnet/sdk/issues/53061 + public async Task Host() + { + var testAsset = _testAssetsManager.CopyTestAsset("WatchAppWithProjectDeps") + .WithSource(); + + var projectDir = Path.Combine(testAsset.Path, "AppWithDeps"); + var projectPath = Path.Combine(projectDir, "App.WithDeps.csproj"); + + await using var host = CreateHostApp(); + host.Start(testAsset, ["--entrypoint", projectPath]); + + await host.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + } + + [PlatformSpecificFact(TestPlatforms.Windows | TestPlatforms.Linux)] // https://github.com/dotnet/sdk/issues/53061 + public async Task ServerAndResources() + { + var testAsset = _testAssetsManager.CopyTestAsset("WatchAppMultiProc") + .WithSource(); + + var tfm = ToolsetInfo.CurrentTargetFramework; + var serviceDirA = Path.Combine(testAsset.Path, "ServiceA"); + var serviceProjectA = Path.Combine(serviceDirA, "A.csproj"); + var serviceDirB = Path.Combine(testAsset.Path, "ServiceB"); + var serviceProjectB = Path.Combine(serviceDirB, "B.csproj"); + var libDir = Path.Combine(testAsset.Path, "Lib"); + var libSource = Path.Combine(libDir, "Lib.cs"); + + var pipeId = Guid.NewGuid(); + var serverPipe = $"SERVER_{pipeId:N}"; + var statusPipeName = $"STATUS_{pipeId:N}"; + var controlPipeName = $"CONTROL_{pipeId:N}"; + + await using var server = CreateServerApp(serverPipe); + await using var serviceA = CreateResourceApp(serverPipe); + await using var serviceB = CreateResourceApp(serverPipe); + + // resource can be started before the server, they will wait for the server to start: + serviceA.Start(testAsset, ["--entrypoint", serviceProjectA]); + serviceB.Start(testAsset, ["--entrypoint", serviceProjectB]); + + using var statusCancellationSource = new CancellationTokenSource(); + var statusReaderTask = PipeUtilities.ReadStatusEventsAsync(statusPipeName, statusCancellationSource.Token); + + server.Start(testAsset, + [ + "--status-pipe", statusPipeName, + "--control-pipe", controlPipeName, + "--resource", serviceProjectA, + "--resource", serviceProjectB, + ]); + + await server.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + + // valid code change: + UpdateSourceFile(libSource, """ + using System; + + public class Lib + { + public static void Common() + { + Console.WriteLine(""); + } + } + """); + + await server.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied); + await serviceA.WaitUntilOutputContains(""); + await serviceB.WaitUntilOutputContains(""); + + server.Process.ClearOutput(); + serviceA.Process.ClearOutput(); + serviceB.Process.ClearOutput(); + + using var controlPipe = new NamedPipeServerStream(controlPipeName, PipeDirection.Out, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await controlPipe.WaitForConnectionAsync(); + using var controlPipeWriter = new StreamWriter(controlPipe) { AutoFlush = true }; + + // restart resource process: + await controlPipeWriter.WriteLineAsync(JsonSerializer.Serialize(new WatchControlCommand() + { + Type = WatchControlCommand.Types.Rebuild, + Projects = [serviceProjectA], + })); + + await server.WaitUntilOutputContains("Received request to restart projects"); + await server.WaitUntilOutputContains(MessageDescriptor.ProjectRestarting, $"A ({tfm})"); + await server.WaitUntilOutputContains(MessageDescriptor.ProjectRestarted, $"A ({tfm})"); + + // initial updates are applied when the process restarts: + await server.WaitUntilOutputContains(MessageDescriptor.SendingUpdateBatch.GetMessage(0), $"A ({tfm})"); + + statusCancellationSource.Cancel(); + var statusEvents = await statusReaderTask; + + // validate that we received the expected status events from the server, ignoring the order: + AssertEx.SequenceEqual( + [ + $"type=build_complete, projects=[{serviceProjectA};{serviceProjectB}]", + $"type=building, projects=[{serviceProjectA};{serviceProjectB}]", + $"type=hot_reload_applied, projects=[{serviceProjectA};{serviceProjectB}]", + $"type=process_started, projects=[{serviceProjectA}]", + $"type=process_started, projects=[{serviceProjectA}]", + $"type=process_started, projects=[{serviceProjectB}]", + $"type=restarting, projects=[{serviceProjectA}]", + ], statusEvents.Select(e => $"type={e.Type}, projects=[{string.Join(";", e.Projects.Order())}]").Order()); + } +} diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs new file mode 100644 index 000000000000..d0151c2102fb --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs @@ -0,0 +1,274 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class AspireResourceLauncherCliTests +{ + [Fact] + public void RequiredServerOption() + { + // --server option is missing + var args = new[] { "resource", "--entrypoint", "proj" }; + var launcher = AspireLauncher.TryCreate(args); + Assert.Null(launcher); + } + + [Fact] + public void RequiredEntryPointOption() + { + // --entrypoint option is missing + var args = new[] { "resource", "--server", "pipe1" }; + var launcher = AspireLauncher.TryCreate(args); + Assert.Null(launcher); + } + + [Fact] + public void MinimalRequiredOptions() + { + var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj.csproj" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.Equal("pipe1", launcher.ServerPipeName); + Assert.Equal("proj.csproj", launcher.EntryPoint); + Assert.Empty(launcher.ApplicationArguments); + Assert.Empty(launcher.EnvironmentVariables); + Assert.True(launcher.LaunchProfileName.HasValue); + Assert.Null(launcher.LaunchProfileName.Value); + Assert.Equal(LogLevel.Information, launcher.GlobalOptions.LogLevel); + } + + [Fact] + public void ApplicationArguments() + { + var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "a", "b" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + AssertEx.SequenceEqual(["a", "b"], launcher.ApplicationArguments); + } + + [Fact] + public void EnvironmentOption_SingleVariable() + { + var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY=value" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.Single(launcher.EnvironmentVariables); + Assert.Equal("value", launcher.EnvironmentVariables["KEY"]); + } + + [Fact] + public void EnvironmentOption_MultipleVariables() + { + var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY1=val1", "-e", "KEY2=val2" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.Equal(2, launcher.EnvironmentVariables.Count); + Assert.Equal("val1", launcher.EnvironmentVariables["KEY1"]); + Assert.Equal("val2", launcher.EnvironmentVariables["KEY2"]); + } + + [Fact] + public void EnvironmentOption_ValueWithEquals() + { + var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "CONN=Server=localhost;Port=5432" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.Equal("Server=localhost;Port=5432", launcher.EnvironmentVariables["CONN"]); + } + + [Fact] + public void EnvironmentOption_EmptyValue() + { + var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY=" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.Equal("", launcher.EnvironmentVariables["KEY"]); + } + + [Fact] + public void EnvironmentOption_NoEquals() + { + var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.Equal("", launcher.EnvironmentVariables["KEY"]); + } + + [Fact] + public void NoLaunchProfileOption() + { + // With no-launch-profile flag + var argsNoProfile = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--no-launch-profile" }; + var launcherNoProfile = Assert.IsType(AspireLauncher.TryCreate(argsNoProfile)); + Assert.False(launcherNoProfile.LaunchProfileName.HasValue); + + // Without no-launch-profile flag + var argsDefault = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj" }; + var launcherDefault = Assert.IsType(AspireLauncher.TryCreate(argsDefault)); + Assert.True(launcherDefault.LaunchProfileName.HasValue); + Assert.Null(launcherDefault.LaunchProfileName.Value); + } + + [Fact] + public void LaunchProfileOption() + { + var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--launch-profile", "MyProfile" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.True(launcher.LaunchProfileName.HasValue); + Assert.Equal("MyProfile", launcher.LaunchProfileName.Value); + } + + [Fact] + public void LaunchProfileOption_ShortForm() + { + var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-lp", "MyProfile" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.True(launcher.LaunchProfileName.HasValue); + Assert.Equal("MyProfile", launcher.LaunchProfileName.Value); + } + + [Fact] + public void VerboseOption() + { + var argsVerbose = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--verbose" }; + var launcherVerbose = Assert.IsType(AspireLauncher.TryCreate(argsVerbose)); + Assert.Equal(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel); + + var argsNotVerbose = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj" }; + var launcherNotVerbose = Assert.IsType(AspireLauncher.TryCreate(argsNotVerbose)); + Assert.Equal(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel); + } + + [Fact] + public void QuietOption() + { + var argsQuiet = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--quiet" }; + var launcherQuiet = Assert.IsType(AspireLauncher.TryCreate(argsQuiet)); + Assert.Equal(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel); + + var argsNotQuiet = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj" }; + var launcherNotQuiet = Assert.IsType(AspireLauncher.TryCreate(argsNotQuiet)); + Assert.Equal(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel); + } + + [Fact] + public void ConflictingOptions() + { + // Cannot specify both --quiet and --verbose + var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--quiet", "--verbose" }; + var launcher = AspireLauncher.TryCreate(args); + Assert.Null(launcher); + } + + [Fact] + public void AllOptionsSet() + { + var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "myapp.csproj", "-e", "K1=V1", "-e", "K2=V2", "--launch-profile", "Dev", "--verbose", "arg1", "arg2" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + + Assert.Equal("pipe1", launcher.ServerPipeName); + Assert.Equal("myapp.csproj", launcher.EntryPoint); + Assert.Equal(LogLevel.Debug, launcher.GlobalOptions.LogLevel); + Assert.True(launcher.LaunchProfileName.HasValue); + Assert.Equal("Dev", launcher.LaunchProfileName.Value); + AssertEx.SequenceEqual(["arg1", "arg2"], launcher.ApplicationArguments); + Assert.Equal(2, launcher.EnvironmentVariables.Count); + Assert.Equal("V1", launcher.EnvironmentVariables["K1"]); + Assert.Equal("V2", launcher.EnvironmentVariables["K2"]); + } + + [Fact] + public void EnvironmentOption_Duplicates() + { + var command = new AspireResourceCommandDefinition(); + var result = command.Parse(["--server", "S", "--entrypoint", "E", "-e", "A=1", "-e", "A=2"]); + + result.GetValue(command.EnvironmentOption) + .Should() + .BeEquivalentTo(new Dictionary { ["A"] = "2" }); + + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void EnvironmentOption_Duplicates_CasingDifference() + { + var command = new AspireResourceCommandDefinition(); + var result = command.Parse(["--server", "S", "--entrypoint", "E", "-e", "A=1", "-e", "a=2"]); + + var expected = new Dictionary(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + expected.Add("A", "2"); + } + else + { + expected.Add("A", "1"); + expected.Add("a", "2"); + } + + result.GetValue(command.EnvironmentOption) + .Should() + .BeEquivalentTo(expected); + + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void EnvironmentOption_MultiplePerToken() + { + var command = new AspireResourceCommandDefinition(); + var result = command.Parse(["--server", "S", "--entrypoint", "E", "-e", "A=1;B=2,C=3 D=4", "-e", "B==Y=", "-e", "C;=;"]); + + result.GetValue(command.EnvironmentOption) + .Should() + .BeEquivalentTo(new Dictionary + { + ["A"] = "1;B=2,C=3 D=4", + ["B"] = "=Y=", + ["C;"] = ";" + }); + + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void EnvironmentOption_NoValue() + { + var command = new AspireResourceCommandDefinition(); + var result = command.Parse(["--server", "S", "--entrypoint", "E", "-e", "A"]); + + result.GetValue(command.EnvironmentOption) + .Should() + .BeEquivalentTo(new Dictionary { ["A"] = "" }); + + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void EnvironmentOption_WhitespaceTrimming() + { + var command = new AspireResourceCommandDefinition(); + var result = command.Parse(["--server", "S", "--entrypoint", "E", "-e", " A \t\n\r\u2002 = X Y \t\n\r\u2002"]); + + result.GetValue(command.EnvironmentOption) + .Should() + .BeEquivalentTo(new Dictionary { ["A"] = " X Y \t\n\r\u2002" }); + + result.Errors.Should().BeEmpty(); + } + + [Theory] + [InlineData("")] + [InlineData("=")] + [InlineData("= X")] + [InlineData(" \u2002 = X")] + public void EnvironmentOption_Errors(string token) + { + var command = new AspireResourceCommandDefinition(); + var result = command.Parse(["--server", "S", "--entrypoint", "E", "-e", token]); + + AssertEx.SequenceEqual( + [ + $"Incorrectly formatted environment variables '{token}'" + ], result.Errors.Select(e => e.Message)); + } +} diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs new file mode 100644 index 000000000000..39ba1fdff22b --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class AspireServerLauncherCliTests +{ + [Fact] + public void RequiredServerOption() + { + // --server option is missing + var args = new[] { "server", "--sdk", "sdk" }; + var launcher = AspireLauncher.TryCreate(args); + Assert.Null(launcher); + } + + [Fact] + public void RequiredSdkOption() + { + // --sdk option is missing + var args = new[] { "server", "--server", "pipe1" }; + var launcher = AspireLauncher.TryCreate(args); + Assert.Null(launcher); + } + + [Fact] + public void MinimalRequiredOptions() + { + var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.Equal("pipe1", launcher.ServerPipeName); + Assert.Equal(LogLevel.Information, launcher.GlobalOptions.LogLevel); + Assert.Empty(launcher.ResourcePaths); + Assert.Null(launcher.StatusPipeName); + Assert.Null(launcher.ControlPipeName); + } + + [Fact] + public void ResourceOption_SingleValue() + { + var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + AssertEx.SequenceEqual(["proj1.csproj"], launcher.ResourcePaths); + } + + [Fact] + public void ResourceOption_MultipleValues() + { + var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj", "proj2.csproj", "file.cs" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + AssertEx.SequenceEqual(["proj1.csproj", "proj2.csproj", "file.cs"], launcher.ResourcePaths); + } + + [Fact] + public void ResourceOption_MultipleFlags() + { + var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj", "--resource", "proj2.csproj" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + AssertEx.SequenceEqual(["proj1.csproj", "proj2.csproj"], launcher.ResourcePaths); + } + + [Fact] + public void StatusPipeOption() + { + var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--status-pipe", "status1" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.Equal("status1", launcher.StatusPipeName); + } + + [Fact] + public void ControlPipeOption() + { + var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--control-pipe", "control1" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + Assert.Equal("control1", launcher.ControlPipeName); + } + + [Fact] + public void VerboseOption() + { + // With verbose flag + var argsVerbose = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--verbose" }; + var launcherVerbose = Assert.IsType(AspireLauncher.TryCreate(argsVerbose)); + Assert.Equal(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel); + + // Without verbose flag + var argsNotVerbose = new[] { "server", "--server", "pipe1", "--sdk", "sdk" }; + var launcherNotVerbose = Assert.IsType(AspireLauncher.TryCreate(argsNotVerbose)); + Assert.Equal(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel); + } + + [Fact] + public void QuietOption() + { + // With quiet flag + var argsQuiet = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--quiet" }; + var launcherQuiet = Assert.IsType(AspireLauncher.TryCreate(argsQuiet)); + Assert.Equal(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel); + + // Without quiet flag + var argsNotQuiet = new[] { "server", "--server", "pipe1", "--sdk", "sdk" }; + var launcherNotQuiet = Assert.IsType(AspireLauncher.TryCreate(argsNotQuiet)); + Assert.Equal(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel); + } + + [Fact] + public void ConflictingOptions() + { + // Cannot specify both --quiet and --verbose + var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--quiet", "--verbose" }; + var launcher = AspireLauncher.TryCreate(args); + Assert.Null(launcher); + } + + [Fact] + public void AllOptionsSet() + { + var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj", "proj2.csproj", "--status-pipe", "status1", "--control-pipe", "control1", "--verbose" }; + var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + + Assert.Equal("pipe1", launcher.ServerPipeName); + Assert.Equal(LogLevel.Debug, launcher.GlobalOptions.LogLevel); + AssertEx.SequenceEqual(["proj1.csproj", "proj2.csproj"], launcher.ResourcePaths); + Assert.Equal("status1", launcher.StatusPipeName); + Assert.Equal("control1", launcher.ControlPipeName); + } +} diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/DotNetWatchLauncherTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/DotNetWatchLauncherTests.cs deleted file mode 100644 index 26e009bb1bae..000000000000 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/DotNetWatchLauncherTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; - -namespace Microsoft.DotNet.Watch.UnitTests; - -public class DotNetWatchLauncherTests(ITestOutputHelper logger) -{ - private TestAssetsManager TestAssets { get; } = new(logger); - - [Fact] - public async Task AspireWatchLaunch() - { - var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps") - .WithSource(); - - var path = Path.ChangeExtension(typeof(DotNetWatchLauncher).Assembly.Location, PathUtilities.ExecutableExtension).TrimEnd('.'); - var sdkRootDirectory = TestContext.Current.ToolsetUnderTest.SdkFolderUnderTest; - var projectDir = Path.Combine(testAsset.Path, "AppWithDeps"); - var projectPath = Path.Combine(projectDir, "App.WithDeps.csproj"); - - var startInfo = new ProcessStartInfo - { - FileName = path, - Arguments = $@"--sdk ""{sdkRootDirectory}"" --project ""{projectPath}"" --verbose", - UseShellExecute = false, - RedirectStandardInput = true, - WorkingDirectory = projectDir, - }; - - using var process = new AwaitableProcess(logger); - process.Start(startInfo); - - await process.GetOutputLineAsync(success: line => line.Contains("dotnet watch ⌚ Waiting for changes"), failure: _ => false); - } -} diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/DotNetWatchOptionsTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/DotNetWatchOptionsTests.cs deleted file mode 100644 index 0c6775ea30ef..000000000000 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/DotNetWatchOptionsTests.cs +++ /dev/null @@ -1,146 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Extensions.Logging; - -namespace Microsoft.DotNet.Watch.UnitTests; - -public class DotNetWatchOptionsTests -{ - [Fact] - public void TryParse_RequiredSdkOption() - { - // --sdk option is missing - var args = new[] { "--project", "proj", "a", "b" }; - Assert.False(DotNetWatchOptions.TryParse(args, out var options)); - Assert.Null(options); - } - - [Fact] - public void TryParse_RequiredProjectOrFileOption() - { - // --project and --file options are missing - var args = new[] { "--verbose", "a", "b" }; - Assert.False(DotNetWatchOptions.TryParse(args, out var options)); - Assert.Null(options); - } - - [Fact] - public void TryParse_ProjectAndSdkPaths() - { - var args = new[] { "--sdk", "sdk", "--project", "myproject.csproj" }; - Assert.True(DotNetWatchOptions.TryParse(args, out var options)); - Assert.Equal("sdk", options.SdkDirectory); - Assert.Equal("myproject.csproj", options.Project.PhysicalPath); - Assert.Empty(options.ApplicationArguments); - } - - [Fact] - public void TryParse_FilePath() - { - var args = new[] { "--sdk", "sdk", "--file", "file.cs" }; - Assert.True(DotNetWatchOptions.TryParse(args, out var options)); - Assert.Equal("sdk", options.SdkDirectory); - Assert.Equal("file.cs", options.Project.EntryPointFilePath); - Assert.Empty(options.ApplicationArguments); - } - - [Fact] - public void TryParse_ProjectAndFilePaths() - { - var args = new[] { "--sdk", "sdk", "--project", "myproject.csproj", "--file", "file.cs" }; - Assert.False(DotNetWatchOptions.TryParse(args, out var options)); - Assert.Null(options); - } - - [Fact] - public void TryParse_ApplicationArguments() - { - var args = new[] { "--sdk", "sdk", "--project", "proj", "--verbose", "a", "b" }; - Assert.True(DotNetWatchOptions.TryParse(args, out var options)); - AssertEx.SequenceEqual(["a", "b"], options.ApplicationArguments); - } - - [Fact] - public void TryParse_VerboseOption() - { - // With verbose flag - var argsVerbose = new[] { "--sdk", "sdk", "--project", "proj", "--verbose" }; - Assert.True(DotNetWatchOptions.TryParse(argsVerbose, out var optionsVerbose)); - Assert.Equal(LogLevel.Debug, optionsVerbose.LogLevel); - - // Without verbose flag - var argsNotVerbose = new[] { "--sdk", "sdk", "--project", "proj" }; - Assert.True(DotNetWatchOptions.TryParse(argsNotVerbose, out var optionsNotVerbose)); - Assert.Equal(LogLevel.Information, optionsNotVerbose.LogLevel); - } - - [Fact] - public void TryParse_QuietOption() - { - // With quiet flag - var argsQuiet = new[] { "--sdk", "sdk", "--project", "proj", "--quiet" }; - Assert.True(DotNetWatchOptions.TryParse(argsQuiet, out var optionsQuiet)); - Assert.Equal(LogLevel.Warning, optionsQuiet.LogLevel); - - // Without quiet flag - var argsNotQuiet = new[] { "--sdk", "sdk", "--project", "proj" }; - Assert.True(DotNetWatchOptions.TryParse(argsNotQuiet, out var optionsNotQuiet)); - Assert.Equal(LogLevel.Information, optionsNotQuiet.LogLevel); - } - - [Fact] - public void TryParse_NoLaunchProfileOption() - { - // With no-launch-profile flag - var argsNoProfile = new[] { "--sdk", "sdk", "--project", "proj", "--no-launch-profile" }; - Assert.True(DotNetWatchOptions.TryParse(argsNoProfile, out var optionsNoProfile)); - Assert.True(optionsNoProfile.NoLaunchProfile); - - // Without no-launch-profile flag - var argsWithProfile = new[] { "--sdk", "sdk", "--project", "proj" }; - Assert.True(DotNetWatchOptions.TryParse(argsWithProfile, out var optionsWithProfile)); - Assert.False(optionsWithProfile.NoLaunchProfile); - } - - [Fact] - public void TryParse_ConflictingOptions() - { - // Cannot specify both --quiet and --verbose - var args = new[] { "--sdk", "sdk", "--project", "proj", "--quiet", "--verbose" }; - Assert.False(DotNetWatchOptions.TryParse(args, out var options)); - Assert.Null(options); - } - - [Fact] - public void TryParse_Project_MultipleValues() - { - // Project option should only accept one value - var args = new[] { "--sdk", "sdk", "--project", "proj1", "proj2" }; - Assert.True(DotNetWatchOptions.TryParse(args, out var options)); - Assert.Equal("proj1", options.Project.PhysicalPath); - AssertEx.SequenceEqual(["proj2"], options.ApplicationArguments); - } - - [Fact] - public void TryParse_File_MultipleValues() - { - // Project option should only accept one value - var args = new[] { "--sdk", "sdk", "--file", "file1.cs", "file2.cs" }; - Assert.True(DotNetWatchOptions.TryParse(args, out var options)); - Assert.Equal("file1.cs", options.Project.EntryPointFilePath); - AssertEx.SequenceEqual(["file2.cs"], options.ApplicationArguments); - } - - [Fact] - public void TryParse_AllOptionsSet() - { - var args = new[] { "--sdk", "sdk", "--project", "myapp.csproj", "--verbose", "--no-launch-profile", "arg1", "arg2", "arg3" }; - Assert.True(DotNetWatchOptions.TryParse(args, out var options)); - - Assert.Equal("myapp.csproj", options.Project.PhysicalPath); - Assert.Equal(LogLevel.Debug, options.LogLevel); - Assert.True(options.NoLaunchProfile); - AssertEx.SequenceEqual(["arg1", "arg2", "arg3"], options.ApplicationArguments); - } -} diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj index 728c13a54f9b..e65ce1a88a56 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj @@ -6,6 +6,10 @@ Microsoft.DotNet.Watch.Aspire.UnitTests MicrosoftAspNetCore + + + + diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Utilities/PipeUtilities.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Utilities/PipeUtilities.cs new file mode 100644 index 000000000000..3bc100bc1255 --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Utilities/PipeUtilities.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel.DataAnnotations; +using System.IO.Pipes; +using System.Text.Json; + +namespace Microsoft.DotNet.Watch.UnitTests; + +internal static class PipeUtilities +{ + public static async Task> ReadStatusEventsAsync(string pipeName, CancellationToken cancellationToken) + { + var lines = new List(); + + using var pipe = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + await pipe.WaitForConnectionAsync(cancellationToken); + + using var reader = new StreamReader(pipe, Encoding.UTF8); + + try + { + while (!cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(cancellationToken); + if (line == null) + { + break; + } + + var value = JsonSerializer.Deserialize(line); + Assert.NotNull(value); + + lines.Add(value); + } + } + catch (Exception e) when (e is IOException or OperationCanceledException) + { + } + + return lines; + } +} diff --git a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs index 863fd738d0e8..4b13f122d4ac 100644 --- a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs +++ b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs @@ -11,7 +11,7 @@ public class SdkCommandSpec public string? FileName { get; set; } public List Arguments { get; set; } = new List(); - public Dictionary Environment { get; set; } = new Dictionary(); + public Dictionary Environment { get; set; } = new(); public List EnvironmentToRemove { get; } = new List(); diff --git a/test/Microsoft.NET.TestFramework/TestContext.cs b/test/Microsoft.NET.TestFramework/TestContext.cs index f75e170b195a..136808001333 100644 --- a/test/Microsoft.NET.TestFramework/TestContext.cs +++ b/test/Microsoft.NET.TestFramework/TestContext.cs @@ -110,7 +110,7 @@ public static string GetRuntimeGraphFilePath() return lastWrittenSdk.GetFiles("RuntimeIdentifierGraph.json").Single().FullName; } - public void AddTestEnvironmentVariables(IDictionary environment) + public void AddTestEnvironmentVariables(IDictionary environment) { environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; diff --git a/test/Microsoft.NET.TestFramework/ToolsetInfo.cs b/test/Microsoft.NET.TestFramework/ToolsetInfo.cs index d68ebe03e1ee..3ff247a086e0 100644 --- a/test/Microsoft.NET.TestFramework/ToolsetInfo.cs +++ b/test/Microsoft.NET.TestFramework/ToolsetInfo.cs @@ -154,7 +154,7 @@ private void InitMSBuildVersion() } } - public void AddTestEnvironmentVariables(IDictionary environment) + public void AddTestEnvironmentVariables(IDictionary environment) { if (ShouldUseFullFrameworkMSBuild) { diff --git a/test/dotnet-new.IntegrationTests/CommonTemplatesTests.cs b/test/dotnet-new.IntegrationTests/CommonTemplatesTests.cs index af33ea66d105..3bb70de4aacf 100644 --- a/test/dotnet-new.IntegrationTests/CommonTemplatesTests.cs +++ b/test/dotnet-new.IntegrationTests/CommonTemplatesTests.cs @@ -59,7 +59,7 @@ public CommonTemplatesTests(SharedHomeDirectory fixture, ITestOutputHelper log) [InlineData("MSBuild Directory.Build.targets file", "buildtargets", new[] { "--inherit" })] public async Task AllCommonItemsCreate(string expectedTemplateName, string templateShortName, string[]? args) { - Dictionary environmentUnderTest = new() { ["DOTNET_NOLOGO"] = false.ToString() }; + Dictionary environmentUnderTest = new() { ["DOTNET_NOLOGO"] = false.ToString() }; TestContext.Current.AddTestEnvironmentVariables(environmentUnderTest); string itemName = expectedTemplateName.Replace(' ', '-').Replace('.', '-'); @@ -76,7 +76,7 @@ public async Task AllCommonItemsCreate(string expectedTemplateName, string templ DoNotPrependTemplateNameToScenarioName = true, UniqueFor = expectedTemplateName.Equals("NuGet Config") ? UniqueForOption.OsPlatform : null, } - .WithCustomEnvironment(environmentUnderTest) + .WithCustomEnvironment(environmentUnderTest!) .WithCustomScrubbers( ScrubbersDefinition.Empty .AddScrubber(sb => sb.UnixifyNewlines(), "out") @@ -203,7 +203,7 @@ public async Task AotVariants(string name, string language) string projectDir = Path.Combine(workingDir, outputDir); string finalProjectName = Path.Combine(projectDir, $"{projName}.{extension}"); - Dictionary environmentUnderTest = new() { ["DOTNET_NOLOGO"] = false.ToString() }; + Dictionary environmentUnderTest = new() { ["DOTNET_NOLOGO"] = false.ToString() }; TestContext.Current.AddTestEnvironmentVariables(environmentUnderTest); TemplateVerifierOptions options = new TemplateVerifierOptions(templateName: name) @@ -219,7 +219,7 @@ public async Task AotVariants(string name, string language) VerificationExcludePatterns = new[] { "*/stderr.txt", "*\\stderr.txt" }, DotnetExecutablePath = TestContext.Current.ToolsetUnderTest?.DotNetHostPath, } - .WithCustomEnvironment(environmentUnderTest) + .WithCustomEnvironment(environmentUnderTest!) .WithCustomScrubbers( ScrubbersDefinition.Empty .AddScrubber(sb => sb.Replace($"{currentDefaultFramework}", "%FRAMEWORK%")) @@ -397,7 +397,7 @@ public async Task FeaturesSupport( string projectDir = Path.Combine(workingDir, outputDir); string finalProjectName = Path.Combine(projectDir, $"{projName}.{extension}"); - Dictionary environmentUnderTest = new() { ["DOTNET_NOLOGO"] = false.ToString() }; + Dictionary environmentUnderTest = new() { ["DOTNET_NOLOGO"] = false.ToString() }; environmentUnderTest["CheckEolTargetFramework"] = false.ToString(); TestContext.Current.AddTestEnvironmentVariables(environmentUnderTest); @@ -418,7 +418,7 @@ public async Task FeaturesSupport( VerificationExcludePatterns = new[] { "*/stderr.txt", "*\\stderr.txt" }, DotnetExecutablePath = TestContext.Current.ToolsetUnderTest?.DotNetHostPath, } - .WithCustomEnvironment(environmentUnderTest) + .WithCustomEnvironment(environmentUnderTest!) .WithCustomScrubbers( ScrubbersDefinition.Empty .AddScrubber(sb => sb.Replace($"{langVersion}", "%LANG%")) diff --git a/test/dotnet-new.IntegrationTests/DotnetClassTemplateTests.cs b/test/dotnet-new.IntegrationTests/DotnetClassTemplateTests.cs index 9b2d505d0eb9..b2df521ebd2f 100644 --- a/test/dotnet-new.IntegrationTests/DotnetClassTemplateTests.cs +++ b/test/dotnet-new.IntegrationTests/DotnetClassTemplateTests.cs @@ -46,7 +46,7 @@ public async Task DotnetCSharpClassTemplatesTest( string targetFramework = "") { // prevents logging a welcome message from sdk installation - Dictionary environmentUnderTest = new() { ["DOTNET_NOLOGO"] = false.ToString() }; + Dictionary environmentUnderTest = new() { ["DOTNET_NOLOGO"] = false.ToString() }; TestContext.Current.AddTestEnvironmentVariables(environmentUnderTest); string folderName = GetFolderName(templateShortName, langVersion, targetFramework); @@ -74,7 +74,7 @@ public async Task DotnetCSharpClassTemplatesTest( OutputDirectory = workingDir, EnsureEmptyOutputDirectory = false } - .WithCustomEnvironment(environmentUnderTest) + .WithCustomEnvironment(environmentUnderTest!) .WithCustomScrubbers( ScrubbersDefinition.Empty .AddScrubber((path, content) => @@ -126,7 +126,7 @@ public async Task DotnetVisualBasicClassTemplatesTest( string fileName = "") { // prevents logging a welcome message from sdk installation - Dictionary environmentUnderTest = new() { ["DOTNET_NOLOGO"] = false.ToString() }; + Dictionary environmentUnderTest = new() { ["DOTNET_NOLOGO"] = false.ToString() }; TestContext.Current.AddTestEnvironmentVariables(environmentUnderTest); string folderName = GetFolderName(templateShortName, langVersion, targetFramework); @@ -154,7 +154,7 @@ public async Task DotnetVisualBasicClassTemplatesTest( OutputDirectory = workingDir, EnsureEmptyOutputDirectory = false } - .WithCustomEnvironment(environmentUnderTest) + .WithCustomEnvironment(environmentUnderTest!) .WithCustomScrubbers( ScrubbersDefinition.Empty .AddScrubber((path, content) => diff --git a/test/dotnet-new.IntegrationTests/TemplateEngineSamplesTest.cs b/test/dotnet-new.IntegrationTests/TemplateEngineSamplesTest.cs index 2783789100df..525a3e7ff848 100644 --- a/test/dotnet-new.IntegrationTests/TemplateEngineSamplesTest.cs +++ b/test/dotnet-new.IntegrationTests/TemplateEngineSamplesTest.cs @@ -45,7 +45,7 @@ public async Task TemplateEngineSamplesProjectTest( string caseDescription) { _log.LogInformation($"Template with {caseDescription}"); - Dictionary environmentUnderTest = new() { ["DOTNET_NOLOGO"] = false.ToString() }; + Dictionary environmentUnderTest = new() { ["DOTNET_NOLOGO"] = false.ToString() }; TestContext.Current.AddTestEnvironmentVariables(environmentUnderTest); FileExtensions.AddTextExtension(".cshtml"); @@ -60,7 +60,7 @@ public async Task TemplateEngineSamplesProjectTest( DoNotPrependCallerMethodNameToScenarioName = true, ScenarioName = $"{folderName.Substring(folderName.IndexOf("-") + 1)}{GetScenarioName(arguments)}" } - .WithCustomEnvironment(environmentUnderTest) + .WithCustomEnvironment(environmentUnderTest!) .WithCustomScrubbers( ScrubbersDefinition.Empty .AddScrubber(sb => sb.Replace(DateTime.Now.ToString("MM/dd/yyyy"), "**/**/****"))); diff --git a/test/dotnet-watch.Tests/Build/EvaluationTests.cs b/test/dotnet-watch.Tests/Build/EvaluationTests.cs index 50ce37dca37e..f3c3b8e77787 100644 --- a/test/dotnet-watch.Tests/Build/EvaluationTests.cs +++ b/test/dotnet-watch.Tests/Build/EvaluationTests.cs @@ -10,9 +10,6 @@ public class EvaluationTests(ITestOutputHelper output) private readonly TestLogger _logger = new(output); private readonly TestAssetsManager _testAssets = new(output); - private static string MuxerPath - => TestContext.Current.ToolsetUnderTest.DotNetHostPath; - private static string InspectPath(string path, string rootDir) => path.Substring(rootDir.Length + 1).Replace("\\", "/"); @@ -141,7 +138,7 @@ public async Task StaticAssets(bool isWeb, [CombinatorialValues(true, false, nul }, }; - var testAsset = _testAssets.CreateTestProject(project, identifier: enableStaticWebAssets.ToString()); + var testAsset = _testAssets.CreateTestProject(project, identifier: $"{isWeb}_{enableStaticWebAssets}"); await VerifyEvaluation(testAsset, isWeb && enableStaticWebAssets != false ? @@ -442,7 +439,7 @@ public async Task ProjectReferences_Graph() .Path; var projectA = Path.Combine(testDirectory, "A", "A.csproj"); - var options = TestOptions.GetEnvironmentOptions(workingDirectory: testDirectory, muxerPath: MuxerPath); + var options = TestOptions.GetEnvironmentOptions(workingDirectory: testDirectory); var processRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero); var filesetFactory = new MSBuildFileSetFactory(projectA, targetFramework: null, buildArguments: ["/p:_DotNetWatchTraceOutput=true"], processRunner, _logger, options); @@ -506,7 +503,7 @@ public async Task MsbuildOutput() var testAsset = _testAssets.CreateTestProject(project1); var project1Path = GetTestProjectPath(testAsset); - var options = TestOptions.GetEnvironmentOptions(workingDirectory: Path.GetDirectoryName(project1Path)!, muxerPath: MuxerPath); + var options = TestOptions.GetEnvironmentOptions(workingDirectory: Path.GetDirectoryName(project1Path)!); var processRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero); var factory = new MSBuildFileSetFactory(project1Path, targetFramework: null, buildArguments: [], processRunner, _logger, options); @@ -543,7 +540,7 @@ private async Task VerifyEvaluation(TestAsset testAsset, string? targetFramework async Task VerifyTargetsEvaluation() { - var options = TestOptions.GetEnvironmentOptions(workingDirectory: testDir, muxerPath: MuxerPath) with { TestOutput = testDir }; + var options = TestOptions.GetEnvironmentOptions(workingDirectory: testDir) with { TestOutput = testDir }; var processRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero); var buildArguments = targetFramework != null ? new[] { "/p:TargetFramework=" + targetFramework } : []; var factory = new MSBuildFileSetFactory(rootProjectPath, targetFramework: null, buildArguments, processRunner, _logger, options); @@ -559,7 +556,7 @@ async Task VerifyProjectGraphEvaluation() { // Needs to be executed in dotnet-watch process in order for msbuild to load from the correct location. - using var watchableApp = new WatchableApp(new DebugTestOutputLogger(output)); + await using var watchableApp = WatchableApp.CreateDotnetWatchApp(output); var arguments = targetFramework != null ? new[] { "-f", targetFramework } : []; if (suppressStaticWebAssets) diff --git a/test/dotnet-watch.Tests/CommandLine/BinaryLoggerTests.cs b/test/dotnet-watch.Tests/CommandLine/BinaryLoggerTests.cs index 7e4ac0be86c9..8d799b136e77 100644 --- a/test/dotnet-watch.Tests/CommandLine/BinaryLoggerTests.cs +++ b/test/dotnet-watch.Tests/CommandLine/BinaryLoggerTests.cs @@ -7,7 +7,6 @@ namespace Microsoft.DotNet.Watch.UnitTests; -[Collection(nameof(InProcBuildTestCollection))] public class BinaryLoggerTests { [Theory] @@ -27,29 +26,37 @@ public class BinaryLoggerTests [InlineData("\"\"\"path{}\"", "path{}.binlog", false)] // wildcard {} not supported public void ParseBinaryLogFilePath(string? value, string? expected, bool matchesMSBuildImpl = true) { - Assert.Equal(expected, CommandLineOptions.ParseBinaryLogFilePath(value)); - - if (!matchesMSBuildImpl) + ProjectBuildManager.Test_BuildSemaphore.Wait(); + try { - return; - } + Assert.Equal(expected, CommandLineOptions.ParseBinaryLogFilePath(value)); + + if (!matchesMSBuildImpl) + { + return; + } - Assert.NotNull(value); - Assert.NotNull(expected); + Assert.NotNull(value); + Assert.NotNull(expected); - var dir = TestContext.Current.TestExecutionDirectory; - Directory.SetCurrentDirectory(dir); + var dir = TestContext.Current.TestExecutionDirectory; + Directory.SetCurrentDirectory(dir); - var bl = new BinaryLogger() { Parameters = value }; - bl.Initialize(new EventSource()); + var bl = new BinaryLogger() { Parameters = value }; + bl.Initialize(new EventSource()); - var actualPath = bl.GetType().GetProperty("FilePath", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)?.GetValue(bl); - if (actualPath != null) + var actualPath = bl.GetType().GetProperty("FilePath", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)?.GetValue(bl); + if (actualPath != null) + { + Assert.Equal(Path.Combine(dir, expected), actualPath); + } + + bl.Shutdown(); + } + finally { - Assert.Equal(Path.Combine(dir, expected), actualPath); + ProjectBuildManager.Test_BuildSemaphore.Release(); } - - bl.Shutdown(); } private class EventSource : IEventSource diff --git a/test/dotnet-watch.Tests/CommandLine/LaunchSettingsTests.cs b/test/dotnet-watch.Tests/CommandLine/LaunchSettingsTests.cs index 6fabe683fdf2..7fc6f9f504a7 100644 --- a/test/dotnet-watch.Tests/CommandLine/LaunchSettingsTests.cs +++ b/test/dotnet-watch.Tests/CommandLine/LaunchSettingsTests.cs @@ -39,7 +39,7 @@ public async Task RunsWithDotnetLaunchProfileEnvVariableWhenNotExplicitlySpecifi if (!hotReload) { - App.DotnetWatchArgs.Add("--no-hot-reload"); + App.WatchArgs.Add("--no-hot-reload"); } App.Start(testAsset, []); @@ -57,11 +57,11 @@ public async Task RunsWithDotnetLaunchProfileEnvVariableWhenExplicitlySpecified( if (!hotReload) { - App.DotnetWatchArgs.Add("--no-hot-reload"); + App.WatchArgs.Add("--no-hot-reload"); } - App.DotnetWatchArgs.Add("--launch-profile"); - App.DotnetWatchArgs.Add("Second"); + App.WatchArgs.Add("--launch-profile"); + App.WatchArgs.Add("Second"); App.Start(testAsset, []); Assert.Equal("<<>>", await App.AssertOutputLineStartsWith("DOTNET_LAUNCH_PROFILE = ")); } @@ -77,7 +77,7 @@ public async Task RunsWithDotnetLaunchProfileEnvVariableWhenExplicitlySpecifiedB if (!hotReload) { - App.DotnetWatchArgs.Add("--no-hot-reload"); + App.WatchArgs.Add("--no-hot-reload"); } App.Start(testAsset, ["--", "--launch-profile", "Third"]); @@ -160,7 +160,7 @@ public async Task Run_WithHotReloadEnabled_DoesNotReadConsoleIn_InNonInteractive App.EnvironmentVariables.Add("READ_INPUT", "true"); App.Start(testAsset, ["--non-interactive"]); - await App.AssertStarted(); + await App.WaitForOutputLineContaining("Started"); var standardInput = App.Process.Process.StandardInput; var inputString = "This is a test input"; diff --git a/test/dotnet-watch.Tests/CommandLine/ProgramTests.Arguments.cs b/test/dotnet-watch.Tests/CommandLine/ProgramTests.Arguments.cs new file mode 100644 index 000000000000..1b8d00976fb4 --- /dev/null +++ b/test/dotnet-watch.Tests/CommandLine/ProgramTests.Arguments.cs @@ -0,0 +1,142 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.DotNet.Watch.UnitTests +{ + public class ProgramTests_Arguments(ITestOutputHelper output) : DotNetWatchTestBase(output) + { + [Theory] + [InlineData(new[] { "--no-hot-reload", "run" }, "")] + [InlineData(new[] { "--no-hot-reload", "run", "args" }, "args")] + [InlineData(new[] { "--no-hot-reload", "--", "run", "args" }, "run,args")] + [InlineData(new[] { "--no-hot-reload" }, "")] + [InlineData(new string[] { }, "")] + [InlineData(new[] { "run" }, "")] + [InlineData(new[] { "run", "args" }, "args")] + [InlineData(new[] { "--", "run", "args" }, "run,args")] + [InlineData(new[] { "--", "test", "args" }, "test,args")] + [InlineData(new[] { "--", "build", "args" }, "build,args")] + [InlineData(new[] { "abc" }, "abc")] + public async Task Arguments(string[] arguments, string expectedApplicationArgs) + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp", identifier: string.Join(",", arguments)) + .WithSource(); + + App.SuppressVerboseLogging(); + App.Start(testAsset, arguments); + + Assert.Equal(expectedApplicationArgs, await App.AssertOutputLineStartsWith("Arguments = ")); + } + + // https://github.com/dotnet/sdk/issues/49665 + [PlatformSpecificFact(TestPlatforms.Any & ~TestPlatforms.OSX)] + public async Task RunArguments_NoHotReload() + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadAppMultiTfm") + .WithSource(); + + App.SuppressVerboseLogging(); + App.Start(testAsset, arguments: + [ + "--no-hot-reload", + "run", + "-f", + "net6.0", + "--property:AssemblyVersion=1.2.3.4", + "--property", + "AssemblyTitle= | A=B'\tC | ", + "-v", + "minimal", + "--", // the following args are application args + "-v", + ]); + + Assert.Equal("-v", await App.AssertOutputLineStartsWith("Arguments = ")); + Assert.Equal("WatchHotReloadAppMultiTfm, Version=1.2.3.4, Culture=neutral, PublicKeyToken=null", await App.AssertOutputLineStartsWith("AssemblyName = ")); + Assert.Equal("' | A=B'\tC | '", await App.AssertOutputLineStartsWith("AssemblyTitle = ")); + Assert.Equal(".NETCoreApp,Version=v6.0", await App.AssertOutputLineStartsWith("TFM = ")); + + // expected output from build (-v minimal): + Assert.Contains(App.Process.Output, l => l.Contains("Determining projects to restore...")); + + // not expected to find verbose output of dotnet watch + Assert.DoesNotContain(App.Process.Output, l => l.Contains("Working directory:")); + } + + // https://github.com/dotnet/sdk/issues/49665 + [PlatformSpecificFact(TestPlatforms.Any & ~TestPlatforms.OSX)] + public async Task RunArguments_HotReload() + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadAppMultiTfm") + .WithSource(); + + App.SuppressVerboseLogging(); + App.Start(testAsset, arguments: + [ + "run", + "-f", // dotnet watch does not recognize this arg -> dotnet run arg + "net6.0", + "--property", + "AssemblyVersion=1.2.3.4", + "--property", + "AssemblyTitle= | A=B'\tC | ", + "--", // the following args are not dotnet run args + "-v", // dotnet build argument + "minimal" + ]); + + Assert.Equal("WatchHotReloadAppMultiTfm, Version=1.2.3.4, Culture=neutral, PublicKeyToken=null", await App.AssertOutputLineStartsWith("AssemblyName = ")); + Assert.Equal("' | A=B'\tC | '", await App.AssertOutputLineStartsWith("AssemblyTitle = ")); + Assert.Equal(".NETCoreApp,Version=v6.0", await App.AssertOutputLineStartsWith("TFM = ")); + + // not expected to find verbose output of dotnet watch + Assert.DoesNotContain(App.Process.Output, l => l.Contains("Working directory:")); + + Assert.Contains(App.Process.Output, l => l.Contains("Hot reload enabled.")); + } + + [Theory] + [InlineData("P1", "argP1")] + [InlineData("P and Q and \"R\"", "argPQR")] + public async Task ArgumentsFromLaunchSettings_Watch(string profileName, string expectedArgs) + { + var testAsset = TestAssets.CopyTestAsset("WatchAppWithLaunchSettings", identifier: profileName) + .WithSource(); + + App.Start(testAsset, arguments: new[] + { + "--verbose", + "--no-hot-reload", + "-lp", + profileName + }); + + Assert.Equal(expectedArgs, await App.AssertOutputLineStartsWith("Arguments: ")); + + Assert.Contains(App.Process.Output, l => l.Contains($"Found named launch profile '{profileName}'.")); + Assert.Contains(App.Process.Output, l => l.Contains("Hot Reload disabled by command line switch.")); + } + + [Theory] + [InlineData("P1", "argP1")] + [InlineData("P and Q and \"R\"", "argPQR")] + public async Task ArgumentsFromLaunchSettings_HotReload(string profileName, string expectedArgs) + { + var testAsset = TestAssets.CopyTestAsset("WatchAppWithLaunchSettings", identifier: profileName) + .WithSource(); + + App.Start(testAsset, arguments: new[] + { + "--verbose", + "-lp", + profileName + }); + + Assert.Equal(expectedArgs, await App.AssertOutputLineStartsWith("Arguments: ")); + + Assert.Contains(App.Process.Output, l => l.Contains($"Found named launch profile '{profileName}'.")); + } + } +} diff --git a/test/dotnet-watch.Tests/CommandLine/ProgramTests.HostArguments.cs b/test/dotnet-watch.Tests/CommandLine/ProgramTests.HostArguments.cs new file mode 100644 index 000000000000..20888c0d0449 --- /dev/null +++ b/test/dotnet-watch.Tests/CommandLine/ProgramTests.HostArguments.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.DotNet.Watch.UnitTests +{ + public class ProgramTests_HostArguments(ITestOutputHelper output) : DotNetWatchTestBase(output) + { + [Theory] + [InlineData(new[] { "--no-hot-reload", "--", "run", "args" }, "Argument Specified in Props,run,args")] + [InlineData(new[] { "--", "run", "args" }, "Argument Specified in Props,run,args")] + // if arguments specified on command line the ones from launch profile are ignored + [InlineData(new[] { "-lp", "P1", "--", "run", "args" },"Argument Specified in Props,run,args")] + // arguments specified in build file override arguments in launch profile + [InlineData(new[] { "-lp", "P1" }, "Argument Specified in Props")] + public async Task Arguments_HostArguments(string[] arguments, string expectedApplicationArgs) + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadAppCustomHost", identifier: string.Join(",", arguments)) + .WithSource(); + + App.Start(testAsset, arguments); + + AssertEx.Equal(expectedApplicationArgs, await App.AssertOutputLineStartsWith("Arguments = ")); + } + } +} diff --git a/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs b/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs index c3c8c2b3ffc8..208853ba40f5 100644 --- a/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs +++ b/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs @@ -17,16 +17,18 @@ public async Task ConsoleCancelKey() var console = new TestConsole(Logger); var reporter = new TestReporter(Logger); + var eventObserver = new TestEventObserver(); var loggerFactory = new LoggerFactory(reporter, LogLevel.Debug); + var observableLoggerFactory = new TestObservableLoggerFactory(eventObserver, loggerFactory); - var watching = reporter.RegisterSemaphore(MessageDescriptor.WatchingWithHotReload); - var shutdownRequested = reporter.RegisterSemaphore(MessageDescriptor.ShutdownRequested); + var watching = eventObserver.RegisterSemaphore(MessageDescriptor.WatchingWithHotReload); + var shutdownRequested = eventObserver.RegisterSemaphore(MessageDescriptor.ShutdownRequested); var program = Program.TryCreate( TestOptions.GetCommandLineOptions(["--verbose"]), console, - TestOptions.GetEnvironmentOptions(workingDirectory: testAsset.Path, TestContext.Current.ToolsetUnderTest.DotNetHostPath, testAsset), - loggerFactory, + TestOptions.GetEnvironmentOptions(workingDirectory: testAsset.Path, testAsset), + observableLoggerFactory, reporter, out var errorCode); @@ -45,278 +47,6 @@ public async Task ConsoleCancelKey() await shutdownRequested.WaitAsync(); } - [Theory] - [InlineData(new[] { "--no-hot-reload", "run" }, "")] - [InlineData(new[] { "--no-hot-reload", "run", "args" }, "args")] - [InlineData(new[] { "--no-hot-reload", "--", "run", "args" }, "run,args")] - [InlineData(new[] { "--no-hot-reload" }, "")] - [InlineData(new string[] { }, "")] - [InlineData(new[] { "run" }, "")] - [InlineData(new[] { "run", "args" }, "args")] - [InlineData(new[] { "--", "run", "args" }, "run,args")] - [InlineData(new[] { "--", "test", "args" }, "test,args")] - [InlineData(new[] { "--", "build", "args" }, "build,args")] - [InlineData(new[] { "abc" }, "abc")] - public async Task Arguments(string[] arguments, string expectedApplicationArgs) - { - var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp", identifier: string.Join(",", arguments)) - .WithSource(); - - App.SuppressVerboseLogging(); - App.Start(testAsset, arguments); - - Assert.Equal(expectedApplicationArgs, await App.AssertOutputLineStartsWith("Arguments = ")); - } - - [Theory] - [InlineData(new[] { "--no-hot-reload", "--", "run", "args" }, "Argument Specified in Props,run,args")] - [InlineData(new[] { "--", "run", "args" }, "Argument Specified in Props,run,args")] - // if arguments specified on command line the ones from launch profile are ignored - [InlineData(new[] { "-lp", "P1", "--", "run", "args" },"Argument Specified in Props,run,args")] - // arguments specified in build file override arguments in launch profile - [InlineData(new[] { "-lp", "P1" }, "Argument Specified in Props")] - public async Task Arguments_HostArguments(string[] arguments, string expectedApplicationArgs) - { - var testAsset = TestAssets.CopyTestAsset("WatchHotReloadAppCustomHost", identifier: string.Join(",", arguments)) - .WithSource(); - - App.Start(testAsset, arguments); - - AssertEx.Equal(expectedApplicationArgs, await App.AssertOutputLineStartsWith("Arguments = ")); - } - - // https://github.com/dotnet/sdk/issues/49665 - [PlatformSpecificFact(TestPlatforms.Any & ~TestPlatforms.OSX)] - public async Task RunArguments_NoHotReload() - { - var testAsset = TestAssets.CopyTestAsset("WatchHotReloadAppMultiTfm") - .WithSource(); - - App.SuppressVerboseLogging(); - App.Start(testAsset, arguments: - [ - "--no-hot-reload", - "run", - "-f", - "net6.0", - "--property:AssemblyVersion=1.2.3.4", - "--property", - "AssemblyTitle= | A=B'\tC | ", - "-v", - "minimal", - "--", // the following args are application args - "-v", - ]); - - Assert.Equal("-v", await App.AssertOutputLineStartsWith("Arguments = ")); - Assert.Equal("WatchHotReloadAppMultiTfm, Version=1.2.3.4, Culture=neutral, PublicKeyToken=null", await App.AssertOutputLineStartsWith("AssemblyName = ")); - Assert.Equal("' | A=B'\tC | '", await App.AssertOutputLineStartsWith("AssemblyTitle = ")); - Assert.Equal(".NETCoreApp,Version=v6.0", await App.AssertOutputLineStartsWith("TFM = ")); - - // expected output from build (-v minimal): - Assert.Contains(App.Process.Output, l => l.Contains("Determining projects to restore...")); - - // not expected to find verbose output of dotnet watch - Assert.DoesNotContain(App.Process.Output, l => l.Contains("Working directory:")); - } - - // https://github.com/dotnet/sdk/issues/49665 - [PlatformSpecificFact(TestPlatforms.Any & ~TestPlatforms.OSX)] - public async Task RunArguments_HotReload() - { - var testAsset = TestAssets.CopyTestAsset("WatchHotReloadAppMultiTfm") - .WithSource(); - - App.SuppressVerboseLogging(); - App.Start(testAsset, arguments: - [ - "run", - "-f", // dotnet watch does not recognize this arg -> dotnet run arg - "net6.0", - "--property", - "AssemblyVersion=1.2.3.4", - "--property", - "AssemblyTitle= | A=B'\tC | ", - "--", // the following args are not dotnet run args - "-v", // dotnet build argument - "minimal" - ]); - - Assert.Equal("WatchHotReloadAppMultiTfm, Version=1.2.3.4, Culture=neutral, PublicKeyToken=null", await App.AssertOutputLineStartsWith("AssemblyName = ")); - Assert.Equal("' | A=B'\tC | '", await App.AssertOutputLineStartsWith("AssemblyTitle = ")); - Assert.Equal(".NETCoreApp,Version=v6.0", await App.AssertOutputLineStartsWith("TFM = ")); - - // not expected to find verbose output of dotnet watch - Assert.DoesNotContain(App.Process.Output, l => l.Contains("Working directory:")); - - Assert.Contains(App.Process.Output, l => l.Contains("Hot reload enabled.")); - } - - [Theory] - [InlineData("P1", "argP1")] - [InlineData("P and Q and \"R\"", "argPQR")] - public async Task ArgumentsFromLaunchSettings_Watch(string profileName, string expectedArgs) - { - var testAsset = TestAssets.CopyTestAsset("WatchAppWithLaunchSettings", identifier: profileName) - .WithSource(); - - App.Start(testAsset, arguments: new[] - { - "--verbose", - "--no-hot-reload", - "-lp", - profileName - }); - - Assert.Equal(expectedArgs, await App.AssertOutputLineStartsWith("Arguments: ")); - - Assert.Contains(App.Process.Output, l => l.Contains($"Found named launch profile '{profileName}'.")); - Assert.Contains(App.Process.Output, l => l.Contains("Hot Reload disabled by command line switch.")); - } - - [Theory] - [InlineData("P1", "argP1")] - [InlineData("P and Q and \"R\"", "argPQR")] - public async Task ArgumentsFromLaunchSettings_HotReload(string profileName, string expectedArgs) - { - var testAsset = TestAssets.CopyTestAsset("WatchAppWithLaunchSettings", identifier: profileName) - .WithSource(); - - App.Start(testAsset, arguments: new[] - { - "--verbose", - "-lp", - profileName - }); - - Assert.Equal(expectedArgs, await App.AssertOutputLineStartsWith("Arguments: ")); - - Assert.Contains(App.Process.Output, l => l.Contains($"Found named launch profile '{profileName}'.")); - } - - [Fact] - public async Task TestCommand() - { - var testAsset = TestAssets.CopyTestAsset("XunitCore") - .WithSource(); - - App.Start(testAsset, ["--verbose", "test", "--list-tests", "/p:VSTestUseMSBuildOutput=false"]); - - await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); - - await App.WaitUntilOutputContains("The following Tests are available:"); - await App.WaitUntilOutputContains(" TestNamespace.VSTestXunitTests.VSTestXunitPassTest"); - App.Process.ClearOutput(); - - // update file: - var testFile = Path.Combine(testAsset.Path, "UnitTest1.cs"); - var content = File.ReadAllText(testFile, Encoding.UTF8); - File.WriteAllText(testFile, content.Replace("VSTestXunitPassTest", "VSTestXunitPassTest2"), Encoding.UTF8); - - await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); - - await App.WaitUntilOutputContains("The following Tests are available:"); - await App.WaitUntilOutputContains(" TestNamespace.VSTestXunitTests.VSTestXunitPassTest2"); - } - - [Fact] - public async Task TestCommand_MultiTargeting() - { - var testAsset = TestAssets.CopyTestAsset("XunitMulti") - .WithSource(); - - App.Start(testAsset, ["--verbose", "test", "--framework", ToolsetInfo.CurrentTargetFramework, "--list-tests", "/p:VSTestUseMSBuildOutput=false"]); - - await App.AssertOutputLineEquals("The following Tests are available:"); - await App.AssertOutputLineEquals(" TestNamespace.VSTestXunitTests.VSTestXunitFailTestNetCoreApp"); - } - - [Fact] - public async Task BuildCommand() - { - var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp") - .WithSource(); - - App.Start(testAsset, ["--verbose", "--property", "TestProperty=123", "build", "/t:TestTarget"]); - - await App.WaitUntilOutputContains(MessageDescriptor.CommandDoesNotSupportHotReload.GetMessage("build")); - await App.WaitUntilOutputContains("warning : The value of property is '123'"); - - await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); - - // evaluation affected by -c option: - Assert.Contains("TestProperty", App.Process.Output.Single(line => line.Contains("/t:GenerateWatchList"))); - } - - [Fact] - public async Task MSBuildCommand() - { - var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp") - .WithSource(); - - App.Start(testAsset, ["--verbose", "/p:TestProperty=123", "msbuild", "/t:TestTarget"]); - - await App.WaitUntilOutputContains(MessageDescriptor.CommandDoesNotSupportHotReload.GetMessage("msbuild")); - await App.WaitUntilOutputContains("warning : The value of property is '123'"); - - await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); - - // TestProperty is not passed to evaluation since msbuild command doesn't include it in forward options: - Assert.DoesNotContain("TestProperty", App.Process.Output.Single(line => line.Contains("/t:GenerateWatchList"))); - } - - [Fact] - public async Task PackCommand() - { - var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp") - .WithSource(); - - App.Start(testAsset, ["--verbose", "pack", "-c", "Release"]); - - var packagePath = Path.Combine(testAsset.Path, "bin", "Release", "WatchNoDepsApp.1.0.0.nupkg"); - - await App.WaitUntilOutputContains(MessageDescriptor.CommandDoesNotSupportHotReload.GetMessage("pack")); - await App.WaitUntilOutputContains($"Successfully created package '{packagePath}'"); - - await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); - - // evaluation affected by -c option: - Assert.Contains("-property:Configuration=Release", App.Process.Output.Single(line => line.Contains("/t:GenerateWatchList"))); - } - - [Fact] - public async Task PublishCommand() - { - var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp") - .WithSource(); - - App.Start(testAsset, ["--verbose", "publish", "-c", "Release"]); - - await App.WaitUntilOutputContains(MessageDescriptor.CommandDoesNotSupportHotReload.GetMessage("publish")); - await App.WaitUntilOutputContains(Path.Combine("Release", ToolsetInfo.CurrentTargetFramework, "publish")); - - await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); - - // evaluation affected by -c option: - Assert.Contains("-property:Configuration=Release", App.Process.Output.Single(line => line.Contains("/t:GenerateWatchList"))); - } - - [Fact] - public async Task FormatCommand() - { - var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp") - .WithSource(); - - App.SuppressVerboseLogging(); - App.Start(testAsset, ["--verbose", "format", "--verbosity", "detailed"]); - - await App.WaitUntilOutputContains(MessageDescriptor.CommandDoesNotSupportHotReload.GetMessage("format")); - await App.WaitUntilOutputContains("format --verbosity detailed"); - await App.WaitUntilOutputContains("Format complete in"); - - await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); - } - [Fact] public async Task ProjectGraphLoadFailure() { @@ -342,7 +72,7 @@ public async Task ProjectGraphLoadFailure() await App.WaitUntilOutputContains("dotnet watch ⌚ Fix the error to continue or press Ctrl+C to exit."); } - [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307") + [Fact] public async Task ListsFiles() { var testAsset = TestAssets.CopyTestAsset("WatchGlobbingApp") @@ -350,8 +80,9 @@ public async Task ListsFiles() App.SuppressVerboseLogging(); App.Start(testAsset, ["--list"]); - var lines = await App.Process.GetAllOutputLinesAsync(CancellationToken.None); - var files = lines.Where(l => !l.StartsWith("dotnet watch ⌚") && l.Trim() != ""); + await App.Process.WaitUntilOutputCompleted(); + + var files = App.Process.Output.Where(l => !l.StartsWith("dotnet watch ⌚") && l.Trim() != ""); AssertEx.EqualFileList( testAsset.Path, diff --git a/test/dotnet-watch.Tests/CommandLine/SubcommandTests.cs b/test/dotnet-watch.Tests/CommandLine/SubcommandTests.cs new file mode 100644 index 000000000000..2b4dfdcdff8f --- /dev/null +++ b/test/dotnet-watch.Tests/CommandLine/SubcommandTests.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.DotNet.Watch.UnitTests +{ + public class SubcommandTests(ITestOutputHelper output) : DotNetWatchTestBase(output) + { + [Fact] + public async Task TestCommand() + { + var testAsset = TestAssets.CopyTestAsset("XunitCore") + .WithSource(); + + App.Start(testAsset, ["--verbose", "test", "--list-tests", "/p:VSTestUseMSBuildOutput=false"]); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); + + await App.WaitUntilOutputContains("The following Tests are available:"); + await App.WaitUntilOutputContains(" TestNamespace.VSTestXunitTests.VSTestXunitPassTest"); + App.Process.ClearOutput(); + + // update file: + var testFile = Path.Combine(testAsset.Path, "UnitTest1.cs"); + var content = File.ReadAllText(testFile, Encoding.UTF8); + File.WriteAllText(testFile, content.Replace("VSTestXunitPassTest", "VSTestXunitPassTest2"), Encoding.UTF8); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); + + await App.WaitUntilOutputContains("The following Tests are available:"); + await App.WaitUntilOutputContains(" TestNamespace.VSTestXunitTests.VSTestXunitPassTest2"); + } + + [Fact] + public async Task TestCommand_MultiTargeting() + { + var testAsset = TestAssets.CopyTestAsset("XunitMulti") + .WithSource(); + + App.Start(testAsset, ["--verbose", "test", "--framework", ToolsetInfo.CurrentTargetFramework, "--list-tests", "/p:VSTestUseMSBuildOutput=false"]); + + await App.AssertOutputLineEquals("The following Tests are available:"); + await App.AssertOutputLineEquals(" TestNamespace.VSTestXunitTests.VSTestXunitFailTestNetCoreApp"); + } + + [Fact] + public async Task BuildCommand() + { + var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp") + .WithSource(); + + App.Start(testAsset, ["--verbose", "--property", "TestProperty=123", "build", "/t:TestTarget"]); + + await App.WaitUntilOutputContains(MessageDescriptor.CommandDoesNotSupportHotReload.GetMessage("build")); + await App.WaitUntilOutputContains("warning : The value of property is '123'"); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); + + // evaluation affected by -c option: + Assert.Contains("TestProperty", App.Process.Output.Single(line => line.Contains("/t:GenerateWatchList"))); + } + + [Fact] + public async Task MSBuildCommand() + { + var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp") + .WithSource(); + + App.Start(testAsset, ["--verbose", "/p:TestProperty=123", "msbuild", "/t:TestTarget"]); + + await App.WaitUntilOutputContains(MessageDescriptor.CommandDoesNotSupportHotReload.GetMessage("msbuild")); + await App.WaitUntilOutputContains("warning : The value of property is '123'"); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); + + // TestProperty is not passed to evaluation since msbuild command doesn't include it in forward options: + Assert.DoesNotContain("TestProperty", App.Process.Output.Single(line => line.Contains("/t:GenerateWatchList"))); + } + + [Fact] + public async Task PackCommand() + { + var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp") + .WithSource(); + + App.Start(testAsset, ["--verbose", "pack", "-c", "Release"]); + + var packagePath = Path.Combine(testAsset.Path, "bin", "Release", "WatchNoDepsApp.1.0.0.nupkg"); + + await App.WaitUntilOutputContains(MessageDescriptor.CommandDoesNotSupportHotReload.GetMessage("pack")); + await App.WaitUntilOutputContains($"Successfully created package '{packagePath}'"); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); + + // evaluation affected by -c option: + Assert.Contains("-property:Configuration=Release", App.Process.Output.Single(line => line.Contains("/t:GenerateWatchList"))); + } + + [Fact] + public async Task PublishCommand() + { + var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp") + .WithSource(); + + App.Start(testAsset, ["--verbose", "publish", "-c", "Release"]); + + await App.WaitUntilOutputContains(MessageDescriptor.CommandDoesNotSupportHotReload.GetMessage("publish")); + await App.WaitUntilOutputContains(Path.Combine("Release", ToolsetInfo.CurrentTargetFramework, "publish")); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); + + // evaluation affected by -c option: + Assert.Contains("-property:Configuration=Release", App.Process.Output.Single(line => line.Contains("/t:GenerateWatchList"))); + } + + [Fact] + public async Task FormatCommand() + { + var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp") + .WithSource(); + + App.SuppressVerboseLogging(); + App.Start(testAsset, ["--verbose", "format", "--verbosity", "detailed"]); + + await App.WaitUntilOutputContains(MessageDescriptor.CommandDoesNotSupportHotReload.GetMessage("format")); + await App.WaitUntilOutputContains("format --verbosity detailed"); + await App.WaitUntilOutputContains("Format complete in"); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); + } + } +} diff --git a/test/dotnet-watch.Tests/ConsoleReporterTests.cs b/test/dotnet-watch.Tests/ConsoleReporterTests.cs index 748badd9f4d2..21bda6d44d4e 100644 --- a/test/dotnet-watch.Tests/ConsoleReporterTests.cs +++ b/test/dotnet-watch.Tests/ConsoleReporterTests.cs @@ -17,30 +17,30 @@ public class ConsoleReporterTests public void WritesToStandardStreams(bool suppressEmojis) { var testConsole = new TestConsole(); - var reporter = new ConsoleReporter(testConsole, suppressEmojis: suppressEmojis); + var reporter = new ConsoleReporter(testConsole, "test prefix", suppressEmojis: suppressEmojis); reporter.Report(id: default, Emoji.Watch, LogLevel.Trace, "trace {0}"); - Assert.Equal($"dotnet watch {(suppressEmojis ? ":" : "⌚")} trace {{0}}" + EOL, testConsole.GetError()); + Assert.Equal($"test prefix {(suppressEmojis ? ":" : "⌚")} trace {{0}}" + EOL, testConsole.GetError()); testConsole.Clear(); reporter.Report(id: default, Emoji.Watch, LogLevel.Debug, "verbose"); - Assert.Equal($"dotnet watch {(suppressEmojis ? ":" : "⌚")} verbose" + EOL, testConsole.GetError()); + Assert.Equal($"test prefix {(suppressEmojis ? ":" : "⌚")} verbose" + EOL, testConsole.GetError()); testConsole.Clear(); reporter.Report(id: default, Emoji.Watch, LogLevel.Information, "out"); - Assert.Equal($"dotnet watch {(suppressEmojis ? ":" : "⌚")} out" + EOL, testConsole.GetError()); + Assert.Equal($"test prefix {(suppressEmojis ? ":" : "⌚")} out" + EOL, testConsole.GetError()); testConsole.Clear(); reporter.Report(id: default, Emoji.Warning, LogLevel.Warning, "warn"); - Assert.Equal($"dotnet watch {(suppressEmojis ? ":" : "⚠")} warn" + EOL, testConsole.GetError()); + Assert.Equal($"test prefix {(suppressEmojis ? ":" : "⚠")} warn" + EOL, testConsole.GetError()); testConsole.Clear(); reporter.Report(id: default, Emoji.Error, LogLevel.Error, "error"); - Assert.Equal($"dotnet watch {(suppressEmojis ? ":" : "❌")} error" + EOL, testConsole.GetError()); + Assert.Equal($"test prefix {(suppressEmojis ? ":" : "❌")} error" + EOL, testConsole.GetError()); testConsole.Clear(); reporter.Report(id: default, Emoji.Error, LogLevel.Critical, "critical"); - Assert.Equal($"dotnet watch {(suppressEmojis ? ":" : "❌")} critical" + EOL, testConsole.GetError()); + Assert.Equal($"test prefix {(suppressEmojis ? ":" : "❌")} critical" + EOL, testConsole.GetError()); testConsole.Clear(); } diff --git a/test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs b/test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs index fa3de559d3e8..715c93552330 100644 --- a/test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs +++ b/test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs @@ -76,7 +76,7 @@ public async Task Aspire_BuildError_ManualRestart() await App.WaitUntilOutputContains($"[WatchAspire.ApiService ({tfm})] Exited"); - await App.WaitUntilOutputContains(MessageDescriptor.Building.GetMessage(serviceProjectPath)); + await App.WaitUntilOutputContains(MessageDescriptor.Building); await App.WaitUntilOutputContains("error CS0246: The type or namespace name 'WeatherForecast' could not be found"); App.Process.ClearOutput(); @@ -87,7 +87,7 @@ public async Task Aspire_BuildError_ManualRestart() await App.WaitUntilOutputContains(MessageDescriptor.ProjectsRestarted.GetMessage(1)); - await App.WaitUntilOutputContains(MessageDescriptor.BuildSucceeded.GetMessage(serviceProjectPath)); + await App.WaitUntilOutputContains(MessageDescriptor.BuildSucceeded); await App.WaitUntilOutputContains(MessageDescriptor.ProjectsRebuilt); await App.WaitUntilOutputContains($"Starting: '{serviceProjectPath}'"); @@ -112,8 +112,9 @@ public async Task Aspire_BuildError_ManualRestart() await App.WaitUntilOutputContains("dotnet watch ⭐ [#1] Stop session"); await App.WaitUntilOutputContains("dotnet watch ⭐ [#2] Stop session"); await App.WaitUntilOutputContains("dotnet watch ⭐ [#3] Stop session"); - await App.WaitUntilOutputContains("dotnet watch ⭐ [#2] Sending 'sessionTerminated'"); - await App.WaitUntilOutputContains("dotnet watch ⭐ [#3] Sending 'sessionTerminated'"); + + // Note: do not check that 'sessionTerminated' notification is received. + // It might get cancelled and not delivered on shutdown. } [PlatformSpecificFact(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/53058, https://github.com/dotnet/sdk/issues/53061, https://github.com/dotnet/sdk/issues/53114 diff --git a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs index 42a531303ceb..59788507d466 100644 --- a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs +++ b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs @@ -20,7 +20,7 @@ public async Task ReferenceOutputAssembly_False() var cmdOptions = TestOptions.GetCommandLineOptions(["--project", hostProject]); var projectOptions = TestOptions.GetProjectOptions(cmdOptions); - var environmentOptions = TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory, "dotnet"); + var environmentOptions = TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory); var factory = new ProjectGraphFactory([hostProjectRepr], targetFramework: null, buildProperties: [], NullLogger.Instance); var projectGraph = factory.TryLoadProjectGraph(projectGraphRequired: false, CancellationToken.None); diff --git a/test/dotnet-watch.Tests/HotReload/CtrlRTests.cs b/test/dotnet-watch.Tests/HotReload/CtrlRTests.cs new file mode 100644 index 000000000000..f859369c9e5b --- /dev/null +++ b/test/dotnet-watch.Tests/HotReload/CtrlRTests.cs @@ -0,0 +1,89 @@ +// 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 CtrlRTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger) +{ + [Fact] + public async Task RestartsBuild() + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") + .WithSource(); + + await using var w = CreateInProcWatcher(testAsset, []); + + var buildCounter = 0; + + w.Observer.RegisterAction(MessageDescriptor.Building, () => + { + if (Interlocked.Increment(ref buildCounter) == 1) + { + w.Console.PressKey(new ConsoleKeyInfo('R', ConsoleKey.R, shift: false, alt: false, control: true)); + } + }); + + var restarting = w.Observer.RegisterSemaphore(MessageDescriptor.Restarting); + + // Iteration #1 build should be canceled, iteration #2 should build and launch the app. + var hasExpectedOutput = w.CreateCompletionSource(); + w.Reporter.OnProcessOutput += line => + { + Assert.DoesNotContain("DOTNET_WATCH_ITERATION = 1", line.Content); + + if (line.Content.Contains("DOTNET_WATCH_ITERATION = 2")) + { + hasExpectedOutput.TrySetResult(); + } + }; + + w.Start(); + + // 🔄 Restarting + await restarting.WaitAsync(w.ShutdownSource.Token); + + // DOTNET_WATCH_ITERATION = 2 + await hasExpectedOutput.Task; + + Assert.Equal(2, buildCounter); + } + + [Fact] + public async Task CancelsWaitForFileChange() + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") + .WithSource(); + + var programFilePath = Path.Combine(testAsset.Path, "Program.cs"); + + File.WriteAllText(programFilePath, """ + System.Console.WriteLine(""); + """); + + await using var w = CreateInProcWatcher(testAsset, []); + + w.Observer.RegisterAction(MessageDescriptor.WaitingForFileChangeBeforeRestarting, () => + { + w.Console.PressKey(new ConsoleKeyInfo('R', ConsoleKey.R, shift: false, alt: false, control: true)); + }); + + var buildCounter = 0; + w.Observer.RegisterAction(MessageDescriptor.Building, () => Interlocked.Increment(ref buildCounter)); + + var counter = 0; + var hasExpectedOutput = w.CreateCompletionSource(); + w.Reporter.OnProcessOutput += line => + { + if (line.Content.Contains("") && Interlocked.Increment(ref counter) == 2) + { + hasExpectedOutput.TrySetResult(); + } + }; + + w.Start(); + + await hasExpectedOutput.Task; + + Assert.Equal(2, buildCounter); + } +} diff --git a/test/dotnet-watch.Tests/HotReload/FileExclusionTests.cs b/test/dotnet-watch.Tests/HotReload/FileExclusionTests.cs new file mode 100644 index 000000000000..a5b9af94b711 --- /dev/null +++ b/test/dotnet-watch.Tests/HotReload/FileExclusionTests.cs @@ -0,0 +1,115 @@ +// 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 FileExclusionTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger) +{ + public enum DirectoryKind + { + Ordinary, + Hidden, + Bin, + Obj, + } + + [Theory] + [CombinatorialData] + public async Task IgnoredChange(bool isExisting, bool isIncluded, DirectoryKind directoryKind) + { + var testAsset = CopyTestAsset("WatchNoDepsApp", [isExisting, isIncluded, directoryKind]); + + var workingDirectory = testAsset.Path; + string dir; + + switch (directoryKind) + { + case DirectoryKind.Bin: + dir = Path.Combine(workingDirectory, "bin", "Debug", ToolsetInfo.CurrentTargetFramework); + break; + + case DirectoryKind.Obj: + dir = Path.Combine(workingDirectory, "obj", "Debug", ToolsetInfo.CurrentTargetFramework); + break; + + case DirectoryKind.Hidden: + dir = Path.Combine(workingDirectory, ".dir"); + break; + + default: + dir = workingDirectory; + break; + } + + var extension = isIncluded ? ".cs" : ".txt"; + + Directory.CreateDirectory(dir); + + var path = Path.Combine(dir, "File" + extension); + + if (isExisting) + { + File.WriteAllText(path, "class C { int F() => 1; }"); + + if (isIncluded && directoryKind is DirectoryKind.Bin or DirectoryKind.Obj or DirectoryKind.Hidden) + { + var project = Path.Combine(workingDirectory, "WatchNoDepsApp.csproj"); + File.WriteAllText(project, File.ReadAllText(project).Replace( + "", + $""" + + """)); + } + } + + await using var w = CreateInProcWatcher(testAsset, ["--no-exit"], workingDirectory); + + var waitingForChanges = w.Observer.RegisterSemaphore(MessageDescriptor.WaitingForChanges); + var changeHandled = w.Observer.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); + var ignoringChangeInHiddenDirectory = w.Observer.RegisterSemaphore(MessageDescriptor.IgnoringChangeInHiddenDirectory); + var ignoringChangeInExcludedFile = w.Observer.RegisterSemaphore(MessageDescriptor.IgnoringChangeInExcludedFile); + var fileAdditionTriggeredReEvaluation = w.Observer.RegisterSemaphore(MessageDescriptor.FileAdditionTriggeredReEvaluation); + var reEvaluationCompleted = w.Observer.RegisterSemaphore(MessageDescriptor.ReEvaluationCompleted); + var noHotReloadChangesToApply = w.Observer.RegisterSemaphore(MessageDescriptor.NoManagedCodeChangesToApply); + + w.Start(); + + Log("Waiting for changes..."); + await waitingForChanges.WaitAsync(w.ShutdownSource.Token); + + UpdateSourceFile(path, "class C { int F() => 2; }"); + + switch ((isExisting, isIncluded, directoryKind)) + { + case (isExisting: true, isIncluded: true, directoryKind: _): + Log("Waiting for changed handled ..."); + await changeHandled.WaitAsync(w.ShutdownSource.Token); + break; + + case (isExisting: true, isIncluded: false, directoryKind: DirectoryKind.Ordinary): + Log("Waiting for no hot reload changes to apply ..."); + await noHotReloadChangesToApply.WaitAsync(w.ShutdownSource.Token); + break; + + case (isExisting: false, isIncluded: _, directoryKind: DirectoryKind.Ordinary): + Log("Waiting for file addition re-evalutation ..."); + await fileAdditionTriggeredReEvaluation.WaitAsync(w.ShutdownSource.Token); + Log("Waiting for re-evalutation to complete ..."); + await reEvaluationCompleted.WaitAsync(w.ShutdownSource.Token); + break; + + case (isExisting: _, isIncluded: _, directoryKind: DirectoryKind.Hidden): + Log("Waiting for ignored change in hidden dir ..."); + await ignoringChangeInHiddenDirectory.WaitAsync(w.ShutdownSource.Token); + break; + + case (isExisting: _, isIncluded: _, directoryKind: DirectoryKind.Bin or DirectoryKind.Obj): + Log("Waiting for ignored change in output dir ..."); + await ignoringChangeInExcludedFile.WaitAsync(w.ShutdownSource.Token); + break; + + default: + throw new InvalidOperationException(); + } + } +} diff --git a/test/dotnet-watch.Tests/HotReload/ProjectUpdateInProcTests.cs b/test/dotnet-watch.Tests/HotReload/ProjectUpdateInProcTests.cs new file mode 100644 index 000000000000..f39310095b2b --- /dev/null +++ b/test/dotnet-watch.Tests/HotReload/ProjectUpdateInProcTests.cs @@ -0,0 +1,182 @@ +// 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 ProjectUpdateInProcTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger) +{ + [Fact] + public async Task ProjectAndSourceFileChange() + { + var testAsset = CopyTestAsset("WatchHotReloadApp"); + + var workingDirectory = testAsset.Path; + var projectPath = Path.Combine(testAsset.Path, "WatchHotReloadApp.csproj"); + var programPath = Path.Combine(testAsset.Path, "Program.cs"); + + await using var w = CreateInProcWatcher(testAsset, [], workingDirectory); + + var fileChangesCompleted = w.CreateCompletionSource(); + w.Watcher.Test_FileChangesCompletedTask = fileChangesCompleted.Task; + + var waitingForChanges = w.Observer.RegisterSemaphore(MessageDescriptor.WaitingForChanges); + + var changeHandled = w.Observer.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); + + var hasUpdatedOutput = w.CreateCompletionSource(); + w.Reporter.OnProcessOutput += line => + { + if (line.Content.Contains("System.Xml.Linq.XDocument")) + { + hasUpdatedOutput.TrySetResult(); + } + }; + + w.Start(); + + Log("Waiting for changes..."); + await waitingForChanges.WaitAsync(w.ShutdownSource.Token); + + // change the project and source files at the same time: + + UpdateSourceFile(programPath, src => src.Replace("""Console.WriteLine(".");""", """Console.WriteLine(typeof(XDocument));""")); + UpdateSourceFile(projectPath, src => src.Replace("", """""")); + + // done updating files: + fileChangesCompleted.TrySetResult(); + + Log("Waiting for change handled ..."); + await changeHandled.WaitAsync(w.ShutdownSource.Token); + + Log("Waiting for output 'System.Xml.Linq.XDocument'..."); + await hasUpdatedOutput.Task; + } + + [Fact] + public async Task ProjectAndSourceFileChange_AddProjectReference() + { + var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps") + .WithSource() + .WithProjectChanges(project => + { + foreach (var r in project.Root!.Descendants().Where(e => e.Name.LocalName == "ProjectReference").ToArray()) + { + r.Remove(); + } + }); + + var appProjDir = Path.Combine(testAsset.Path, "AppWithDeps"); + var appProjFile = Path.Combine(appProjDir, "App.WithDeps.csproj"); + var appFile = Path.Combine(appProjDir, "Program.cs"); + + UpdateSourceFile(appFile, code => code.Replace("Lib.Print();", "// Lib.Print();")); + + await using var w = CreateInProcWatcher(testAsset, [], appProjDir); + + var fileChangesCompleted = w.CreateCompletionSource(); + w.Watcher.Test_FileChangesCompletedTask = fileChangesCompleted.Task; + + var waitingForChanges = w.Observer.RegisterSemaphore(MessageDescriptor.WaitingForChanges); + var projectChangeTriggeredReEvaluation = w.Observer.RegisterSemaphore(MessageDescriptor.ProjectChangeTriggeredReEvaluation); + var projectsRebuilt = w.Observer.RegisterSemaphore(MessageDescriptor.ProjectsRebuilt); + var projectDependenciesDeployed = w.Observer.RegisterSemaphore(MessageDescriptor.ProjectDependenciesDeployed); + var managedCodeChangesApplied = w.Observer.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); + + var hasUpdatedOutput = w.CreateCompletionSource(); + w.Reporter.OnProcessOutput += line => + { + if (line.Content.Contains("")) + { + hasUpdatedOutput.TrySetResult(); + } + }; + + w.Start(); + + Log("Waiting for changes..."); + await waitingForChanges.WaitAsync(w.ShutdownSource.Token); + + // change the project and source files at the same time: + + UpdateSourceFile(appProjFile, src => src.Replace(""" + + """, """ + + + + """)); + + UpdateSourceFile(appFile, code => code.Replace("// Lib.Print();", "Lib.Print();")); + + // done updating files: + fileChangesCompleted.TrySetResult(); + + Log("Waiting for output ''..."); + await hasUpdatedOutput.Task; + + AssertEx.ContainsSubstring("Resolving 'Dependency, Version=1.0.0.0'", w.Reporter.ProcessOutput); + + Assert.Equal(1, projectChangeTriggeredReEvaluation.CurrentCount); + Assert.Equal(1, projectsRebuilt.CurrentCount); + Assert.Equal(1, projectDependenciesDeployed.CurrentCount); + Assert.Equal(1, managedCodeChangesApplied.CurrentCount); + } + + [Fact] + public async Task ProjectAndSourceFileChange_AddPackageReference() + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") + .WithSource(); + + var projFilePath = Path.Combine(testAsset.Path, "WatchHotReloadApp.csproj"); + var programFilePath = Path.Combine(testAsset.Path, "Program.cs"); + + await using var w = CreateInProcWatcher(testAsset, []); + + var fileChangesCompleted = w.CreateCompletionSource(); + w.Watcher.Test_FileChangesCompletedTask = fileChangesCompleted.Task; + + var waitingForChanges = w.Observer.RegisterSemaphore(MessageDescriptor.WaitingForChanges); + var projectChangeTriggeredReEvaluation = w.Observer.RegisterSemaphore(MessageDescriptor.ProjectChangeTriggeredReEvaluation); + var projectsRebuilt = w.Observer.RegisterSemaphore(MessageDescriptor.ProjectsRebuilt); + var projectDependenciesDeployed = w.Observer.RegisterSemaphore(MessageDescriptor.ProjectDependenciesDeployed); + var managedCodeChangesApplied = w.Observer.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); + + var hasUpdatedOutput = w.CreateCompletionSource(); + w.Reporter.OnProcessOutput += line => + { + if (line.Content.Contains("Newtonsoft.Json.Linq.JToken")) + { + hasUpdatedOutput.TrySetResult(); + } + }; + + w.Start(); + + Log("Waiting for changes..."); + await waitingForChanges.WaitAsync(w.ShutdownSource.Token); + + // change the project and source files at the same time: + + UpdateSourceFile(projFilePath, source => source.Replace(""" + + """, """ + + """)); + + UpdateSourceFile(programFilePath, source => source.Replace("Console.WriteLine(\".\");", "Console.WriteLine(typeof(Newtonsoft.Json.Linq.JToken));")); + + // done updating files: + fileChangesCompleted.TrySetResult(); + + Log("Waiting for output 'Newtonsoft.Json.Linq.JToken'..."); + await hasUpdatedOutput.Task; + + AssertEx.ContainsSubstring("Resolving 'Newtonsoft.Json, Version=13.0.0.0'", w.Reporter.ProcessOutput); + + Assert.Equal(1, projectChangeTriggeredReEvaluation.CurrentCount); + Assert.Equal(0, projectsRebuilt.CurrentCount); + Assert.Equal(1, projectDependenciesDeployed.CurrentCount); + Assert.Equal(1, managedCodeChangesApplied.CurrentCount); + } +} diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index 0c73767e5a47..f5ea15cb1218 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -1,12 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; -using Microsoft.Extensions.Logging; - namespace Microsoft.DotNet.Watch.UnitTests; -[Collection(nameof(InProcBuildTestCollection))] public class RuntimeProcessLauncherTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger) { public enum TriggerEvent @@ -16,143 +12,11 @@ public enum TriggerEvent WaitingForChanges, } - private record class TestWatcher( - RuntimeProcessLauncherTests Test, - HotReloadDotNetWatcher Watcher, - DotNetWatchContext Context, - TestReporter Reporter, - TestConsole Console, - StrongBox ServiceHolder, - CancellationTokenSource ShutdownSource) : IAsyncDisposable - { - public TestRuntimeProcessLauncher? Service => ServiceHolder.Value; - private Task? _lazyTask; - - public void Start() - { - Assert.Null(_lazyTask); - - _lazyTask = Task.Run(async () => - { - Test.Log("Starting watch"); - - try - { - await Watcher.WatchAsync(ShutdownSource.Token); - } - catch (Exception e) when (e is not OperationCanceledException) - { - ShutdownSource.Cancel(); - Test.Logger.WriteLine($"Unexpected exception {e}"); - throw; - } - finally - { - Context.Dispose(); - } - }, ShutdownSource.Token); - } - - public async ValueTask DisposeAsync() - { - Assert.NotNull(_lazyTask); - - if (!ShutdownSource.IsCancellationRequested) - { - Test.Log("Shutting down"); - ShutdownSource.Cancel(); - } - - try - { - await _lazyTask; - } - catch (OperationCanceledException) - { - } - } - - public TaskCompletionSource CreateCompletionSource() - { - var source = new TaskCompletionSource(); - ShutdownSource.Token.Register(() => source.TrySetCanceled(ShutdownSource.Token)); - return source; - } - } - - private TestAsset CopyTestAsset(string assetName, params object[] testParameters) - => TestAssets.CopyTestAsset(assetName, identifier: string.Join(";", testParameters)).WithSource(); - - private static async Task Launch(string projectPath, TestRuntimeProcessLauncher service, string workingDirectory, CancellationToken cancellationToken) - { - var projectOptions = new ProjectOptions() - { - IsMainProject = false, - Representation = new ProjectRepresentation(projectPath, entryPointFilePath: null), - WorkingDirectory = workingDirectory, - Command = "run", - CommandArguments = ["--project", projectPath], - LaunchEnvironmentVariables = [], - LaunchProfileName = default, - }; - - RestartOperation? startOp = null; - startOp = new RestartOperation(async cancellationToken => - { - var result = await service.ProjectLauncher.TryLaunchProcessAsync( - projectOptions, - onOutput: null, - onExit: null, - restartOperation: startOp!, - cancellationToken); - - Assert.NotNull(result); - - await result.Clients.WaitForConnectionEstablishedAsync(cancellationToken); - - return result; - }); - - return await startOp(cancellationToken); - } - - private TestWatcher CreateWatcher(TestAsset testAsset, string[] args, string? workingDirectory = null) - { - var console = new TestConsole(Logger); - var reporter = new TestReporter(Logger); - var loggerFactory = new LoggerFactory(reporter, LogLevel.Trace); - var environmentOptions = TestOptions.GetEnvironmentOptions(workingDirectory ?? testAsset.Path, TestContext.Current.ToolsetUnderTest.DotNetHostPath, testAsset); - var processRunner = new ProcessRunner(environmentOptions.GetProcessCleanupTimeout()); - - var program = Program.TryCreate( - TestOptions.GetCommandLineOptions(["--verbose", ..args]), - console, - environmentOptions, - loggerFactory, - reporter, - out var errorCode); - - Assert.Equal(0, errorCode); - Assert.NotNull(program); - - var serviceHolder = new StrongBox(); - var factory = new TestRuntimeProcessLauncher.Factory(s => - { - serviceHolder.Value = s; - }); - - var context = program.CreateContext(processRunner); - var watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: factory); - var shutdownSource = new CancellationTokenSource(); - - return new TestWatcher(this, watcher, context, reporter, console, serviceHolder, shutdownSource); - } - [Theory] [CombinatorialData] public async Task UpdateAndRudeEdit(TriggerEvent trigger) { - var testAsset = CopyTestAsset("WatchAppMultiProc", trigger); + var testAsset = CopyTestAsset("WatchAppMultiProc", [trigger]); var tfm = ToolsetInfo.CurrentTargetFramework; @@ -169,14 +33,14 @@ public async Task UpdateAndRudeEdit(TriggerEvent trigger) var libProject = Path.Combine(libDir, "Lib.csproj"); var libSource = Path.Combine(libDir, "Lib.cs"); - await using var w = CreateWatcher(testAsset, ["--non-interactive", "--project", hostProject], workingDirectory); + await using var w = CreateInProcWatcher(testAsset, ["--non-interactive", "--project", hostProject], workingDirectory); var launchCompletionA = w.CreateCompletionSource(); var launchCompletionB = w.CreateCompletionSource(); - w.Reporter.RegisterAction(trigger switch + w.Observer.RegisterAction(trigger switch { - TriggerEvent.HotReloadSessionStarting => MessageDescriptor.HotReloadSessionStarting, + TriggerEvent.HotReloadSessionStarting => MessageDescriptor.HotReloadSessionStartingNotification, TriggerEvent.HotReloadSessionStarted => MessageDescriptor.HotReloadSessionStarted, TriggerEvent.WaitingForChanges => MessageDescriptor.WaitingForChanges, _ => throw new InvalidOperationException(), @@ -191,19 +55,19 @@ public async Task UpdateAndRudeEdit(TriggerEvent trigger) // service should have been created before Hot Reload session started: Assert.NotNull(w.Service); - Launch(serviceProjectA, w.Service, workingDirectory, w.ShutdownSource.Token).Wait(); + w.Service.Launch(serviceProjectA, workingDirectory, w.ShutdownSource.Token).Wait(); launchCompletionA.TrySetResult(); - Launch(serviceProjectB, w.Service, workingDirectory, w.ShutdownSource.Token).Wait(); + w.Service.Launch(serviceProjectB, workingDirectory, w.ShutdownSource.Token).Wait(); launchCompletionB.TrySetResult(); }); - var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); + var waitingForChanges = w.Observer.RegisterSemaphore(MessageDescriptor.WaitingForChanges); - var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); - var projectsRestarted = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectsRestarted); - var sessionStarted = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadSessionStarted); - var projectBaselinesUpdated = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectsRebuilt); + var changeHandled = w.Observer.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); + var projectsRestarted = w.Observer.RegisterSemaphore(MessageDescriptor.ProjectsRestarted); + var sessionStarted = w.Observer.RegisterSemaphore(MessageDescriptor.HotReloadSessionStarted); + var projectBaselinesUpdated = w.Observer.RegisterSemaphore(MessageDescriptor.ProjectsRebuilt); w.Start(); @@ -313,7 +177,7 @@ async Task MakeRudeEditChange() [CombinatorialData] public async Task UpdateAppliedToNewProcesses(bool sharedOutput) { - var testAsset = CopyTestAsset("WatchAppMultiProc", sharedOutput); + var testAsset = CopyTestAsset("WatchAppMultiProc", [sharedOutput]); var tfm = ToolsetInfo.CurrentTargetFramework; if (sharedOutput) @@ -332,11 +196,11 @@ public async Task UpdateAppliedToNewProcesses(bool sharedOutput) var libProject = Path.Combine(libDir, "Lib.csproj"); var libSource = Path.Combine(libDir, "Lib.cs"); - await using var w = CreateWatcher(testAsset, ["--non-interactive", "--project", hostProject], workingDirectory); + await using var w = CreateInProcWatcher(testAsset, ["--non-interactive", "--project", hostProject], workingDirectory); - var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); - var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); - var updatesApplied = w.Reporter.RegisterSemaphore(MessageDescriptor.UpdateBatchCompleted); + var waitingForChanges = w.Observer.RegisterSemaphore(MessageDescriptor.WaitingForChanges); + var changeHandled = w.Observer.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); + var updatesApplied = w.Observer.RegisterSemaphore(MessageDescriptor.UpdateBatchCompleted); var hasUpdateA = new SemaphoreSlim(initialCount: 0); var hasUpdateB = new SemaphoreSlim(initialCount: 0); @@ -368,7 +232,7 @@ public async Task UpdateAppliedToNewProcesses(bool sharedOutput) // service should have been created before Hot Reload session started: Assert.NotNull(w.Service); - await Launch(serviceProjectA, w.Service, workingDirectory, w.ShutdownSource.Token); + await w.Service.Launch(serviceProjectA, workingDirectory, w.ShutdownSource.Token); UpdateSourceFile(libSource, """ @@ -393,7 +257,7 @@ public static void Common() Log("Waiting for updates applied 2/2 ..."); await updatesApplied.WaitAsync(w.ShutdownSource.Token); - await Launch(serviceProjectB, w.Service, workingDirectory, w.ShutdownSource.Token); + await w.Service.Launch(serviceProjectB, workingDirectory, w.ShutdownSource.Token); // ServiceB received updates: Log("Waiting for updates applied ..."); @@ -414,7 +278,7 @@ public enum UpdateLocation [CombinatorialData] public async Task HostRestart(UpdateLocation updateLocation) { - var testAsset = CopyTestAsset("WatchAppMultiProc", updateLocation); + var testAsset = CopyTestAsset("WatchAppMultiProc", [updateLocation]); var tfm = ToolsetInfo.CurrentTargetFramework; var workingDirectory = testAsset.Path; @@ -424,12 +288,12 @@ public async Task HostRestart(UpdateLocation updateLocation) var libProject = Path.Combine(testAsset.Path, "Lib2", "Lib2.csproj"); var lib = Path.Combine(testAsset.Path, "Lib2", "Lib2.cs"); - await using var w = CreateWatcher(testAsset, args: ["--project", hostProject], workingDirectory); + await using var w = CreateInProcWatcher(testAsset, args: ["--project", hostProject], workingDirectory); - var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); - var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); - var restartNeeded = w.Reporter.RegisterSemaphore(MessageDescriptor.ApplyUpdate_ChangingEntryPoint); - var restartRequested = w.Reporter.RegisterSemaphore(MessageDescriptor.RestartRequested); + var waitingForChanges = w.Observer.RegisterSemaphore(MessageDescriptor.WaitingForChanges); + var changeHandled = w.Observer.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); + var restartNeeded = w.Observer.RegisterSemaphore(MessageDescriptor.ApplyUpdate_ChangingEntryPoint); + var restartRequested = w.Observer.RegisterSemaphore(MessageDescriptor.RestartRequested); var hasUpdate = new SemaphoreSlim(initialCount: 0); w.Reporter.OnProcessOutput += line => @@ -512,13 +376,13 @@ public async Task RudeEditInProjectWithoutRunningProcess() var serviceSourceA2 = Path.Combine(serviceDirA, "A2.cs"); var serviceProjectA = Path.Combine(serviceDirA, "A.csproj"); - await using var w = CreateWatcher(testAsset, ["--non-interactive", "--project", hostProject], workingDirectory); + await using var w = CreateInProcWatcher(testAsset, ["--non-interactive", "--project", hostProject], workingDirectory); - var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); + var waitingForChanges = w.Observer.RegisterSemaphore(MessageDescriptor.WaitingForChanges); - var projectsRebuilt = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectsRebuilt); - var sessionStarted = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadSessionStarted); - var applyUpdateVerbose = w.Reporter.RegisterSemaphore(MessageDescriptor.ApplyUpdate_AutoVerbose); + var projectsRebuilt = w.Observer.RegisterSemaphore(MessageDescriptor.ProjectsRebuilt); + var sessionStarted = w.Observer.RegisterSemaphore(MessageDescriptor.HotReloadSessionStarted); + var applyUpdateVerbose = w.Observer.RegisterSemaphore(MessageDescriptor.ApplyUpdate_AutoVerbose); w.Start(); @@ -529,7 +393,7 @@ public async Task RudeEditInProjectWithoutRunningProcess() // service should have been created before Hot Reload session started: Assert.NotNull(w.Service); - var runningProject = await Launch(serviceProjectA, w.Service, workingDirectory, w.ShutdownSource.Token); + var runningProject = await w.Service.Launch(serviceProjectA, workingDirectory, w.ShutdownSource.Token); Log("Waiting for session started ..."); await sessionStarted.WaitAsync(w.ShutdownSource.Token); @@ -548,369 +412,4 @@ public async Task RudeEditInProjectWithoutRunningProcess() Log("Waiting for verbose rude edit reported ..."); await applyUpdateVerbose.WaitAsync(w.ShutdownSource.Token); } - - public enum DirectoryKind - { - Ordinary, - Hidden, - Bin, - Obj, - } - - [Theory] - [CombinatorialData] - public async Task IgnoredChange(bool isExisting, bool isIncluded, DirectoryKind directoryKind) - { - var testAsset = CopyTestAsset("WatchNoDepsApp", [isExisting, isIncluded, directoryKind]); - - var workingDirectory = testAsset.Path; - string dir; - - switch (directoryKind) - { - case DirectoryKind.Bin: - dir = Path.Combine(workingDirectory, "bin", "Debug", ToolsetInfo.CurrentTargetFramework); - break; - - case DirectoryKind.Obj: - dir = Path.Combine(workingDirectory, "obj", "Debug", ToolsetInfo.CurrentTargetFramework); - break; - - case DirectoryKind.Hidden: - dir = Path.Combine(workingDirectory, ".dir"); - break; - - default: - dir = workingDirectory; - break; - } - - var extension = isIncluded ? ".cs" : ".txt"; - - Directory.CreateDirectory(dir); - - var path = Path.Combine(dir, "File" + extension); - - if (isExisting) - { - File.WriteAllText(path, "class C { int F() => 1; }"); - - if (isIncluded && directoryKind is DirectoryKind.Bin or DirectoryKind.Obj or DirectoryKind.Hidden) - { - var project = Path.Combine(workingDirectory, "WatchNoDepsApp.csproj"); - File.WriteAllText(project, File.ReadAllText(project).Replace( - "", - $""" - - """)); - } - } - - await using var w = CreateWatcher(testAsset, ["--no-exit"], workingDirectory); - - var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); - var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); - var ignoringChangeInHiddenDirectory = w.Reporter.RegisterSemaphore(MessageDescriptor.IgnoringChangeInHiddenDirectory); - var ignoringChangeInExcludedFile = w.Reporter.RegisterSemaphore(MessageDescriptor.IgnoringChangeInExcludedFile); - var fileAdditionTriggeredReEvaluation = w.Reporter.RegisterSemaphore(MessageDescriptor.FileAdditionTriggeredReEvaluation); - var reEvaluationCompleted = w.Reporter.RegisterSemaphore(MessageDescriptor.ReEvaluationCompleted); - var noHotReloadChangesToApply = w.Reporter.RegisterSemaphore(MessageDescriptor.NoManagedCodeChangesToApply); - - w.Start(); - - Log("Waiting for changes..."); - await waitingForChanges.WaitAsync(w.ShutdownSource.Token); - - UpdateSourceFile(path, "class C { int F() => 2; }"); - - switch ((isExisting, isIncluded, directoryKind)) - { - case (isExisting: true, isIncluded: true, directoryKind: _): - Log("Waiting for changed handled ..."); - await changeHandled.WaitAsync(w.ShutdownSource.Token); - break; - - case (isExisting: true, isIncluded: false, directoryKind: DirectoryKind.Ordinary): - Log("Waiting for no hot reload changes to apply ..."); - await noHotReloadChangesToApply.WaitAsync(w.ShutdownSource.Token); - break; - - case (isExisting: false, isIncluded: _, directoryKind: DirectoryKind.Ordinary): - Log("Waiting for file addition re-evalutation ..."); - await fileAdditionTriggeredReEvaluation.WaitAsync(w.ShutdownSource.Token); - Log("Waiting for re-evalutation to complete ..."); - await reEvaluationCompleted.WaitAsync(w.ShutdownSource.Token); - break; - - case (isExisting: _, isIncluded: _, directoryKind: DirectoryKind.Hidden): - Log("Waiting for ignored change in hidden dir ..."); - await ignoringChangeInHiddenDirectory.WaitAsync(w.ShutdownSource.Token); - break; - - case (isExisting: _, isIncluded: _, directoryKind: DirectoryKind.Bin or DirectoryKind.Obj): - Log("Waiting for ignored change in output dir ..."); - await ignoringChangeInExcludedFile.WaitAsync(w.ShutdownSource.Token); - break; - - default: - throw new InvalidOperationException(); - } - } - - [Fact] - public async Task CtrlR_RestartsBuild() - { - var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") - .WithSource(); - - await using var w = CreateWatcher(testAsset, []); - - var buildCounter = 0; - - w.Reporter.RegisterAction(MessageDescriptor.Building, () => - { - if (Interlocked.Increment(ref buildCounter) == 1) - { - w.Console.PressKey(new ConsoleKeyInfo('R', ConsoleKey.R, shift: false, alt: false, control: true)); - } - }); - - var restarting = w.Reporter.RegisterSemaphore(MessageDescriptor.Restarting); - - // Iteration #1 build should be canceled, iteration #2 should build and launch the app. - var hasExpectedOutput = w.CreateCompletionSource(); - w.Reporter.OnProcessOutput += line => - { - Assert.DoesNotContain("DOTNET_WATCH_ITERATION = 1", line.Content); - - if (line.Content.Contains("DOTNET_WATCH_ITERATION = 2")) - { - hasExpectedOutput.TrySetResult(); - } - }; - - w.Start(); - - // 🔄 Restarting - await restarting.WaitAsync(w.ShutdownSource.Token); - - // DOTNET_WATCH_ITERATION = 2 - await hasExpectedOutput.Task; - - Assert.Equal(2, buildCounter); - } - - [Fact] - public async Task CtrlR_CancelsWaitForFileChange() - { - var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") - .WithSource(); - - var programFilePath = Path.Combine(testAsset.Path, "Program.cs"); - - File.WriteAllText(programFilePath, """ - System.Console.WriteLine(""); - """); - - await using var w = CreateWatcher(testAsset, []); - - w.Reporter.RegisterAction(MessageDescriptor.WaitingForFileChangeBeforeRestarting, () => - { - w.Console.PressKey(new ConsoleKeyInfo('R', ConsoleKey.R, shift: false, alt: false, control: true)); - }); - - var buildCounter = 0; - w.Reporter.RegisterAction(MessageDescriptor.Building, () => Interlocked.Increment(ref buildCounter)); - - var counter = 0; - var hasExpectedOutput = w.CreateCompletionSource(); - w.Reporter.OnProcessOutput += line => - { - if (line.Content.Contains("") && Interlocked.Increment(ref counter) == 2) - { - hasExpectedOutput.TrySetResult(); - } - }; - - w.Start(); - - await hasExpectedOutput.Task; - - Assert.Equal(2, buildCounter); - } - - [Fact] - public async Task ProjectAndSourceFileChange() - { - var testAsset = CopyTestAsset("WatchHotReloadApp"); - - var workingDirectory = testAsset.Path; - var projectPath = Path.Combine(testAsset.Path, "WatchHotReloadApp.csproj"); - var programPath = Path.Combine(testAsset.Path, "Program.cs"); - - await using var w = CreateWatcher(testAsset, [], workingDirectory); - - var fileChangesCompleted = w.CreateCompletionSource(); - w.Watcher.Test_FileChangesCompletedTask = fileChangesCompleted.Task; - - var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); - - var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); - - var hasUpdatedOutput = w.CreateCompletionSource(); - w.Reporter.OnProcessOutput += line => - { - if (line.Content.Contains("System.Xml.Linq.XDocument")) - { - hasUpdatedOutput.TrySetResult(); - } - }; - - w.Start(); - - Log("Waiting for changes..."); - await waitingForChanges.WaitAsync(w.ShutdownSource.Token); - - // change the project and source files at the same time: - - UpdateSourceFile(programPath, src => src.Replace("""Console.WriteLine(".");""", """Console.WriteLine(typeof(XDocument));""")); - UpdateSourceFile(projectPath, src => src.Replace("", """""")); - - // done updating files: - fileChangesCompleted.TrySetResult(); - - Log("Waiting for change handled ..."); - await changeHandled.WaitAsync(w.ShutdownSource.Token); - - Log("Waiting for output 'System.Xml.Linq.XDocument'..."); - await hasUpdatedOutput.Task; - } - - [Fact] - public async Task ProjectAndSourceFileChange_AddProjectReference() - { - var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps") - .WithSource() - .WithProjectChanges(project => - { - foreach (var r in project.Root!.Descendants().Where(e => e.Name.LocalName == "ProjectReference").ToArray()) - { - r.Remove(); - } - }); - - var appProjDir = Path.Combine(testAsset.Path, "AppWithDeps"); - var appProjFile = Path.Combine(appProjDir, "App.WithDeps.csproj"); - var appFile = Path.Combine(appProjDir, "Program.cs"); - - UpdateSourceFile(appFile, code => code.Replace("Lib.Print();", "// Lib.Print();")); - - await using var w = CreateWatcher(testAsset, [], appProjDir); - - var fileChangesCompleted = w.CreateCompletionSource(); - w.Watcher.Test_FileChangesCompletedTask = fileChangesCompleted.Task; - - var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); - var projectChangeTriggeredReEvaluation = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectChangeTriggeredReEvaluation); - var projectsRebuilt = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectsRebuilt); - var projectDependenciesDeployed = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectDependenciesDeployed); - var managedCodeChangesApplied = w.Reporter.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); - - var hasUpdatedOutput = w.CreateCompletionSource(); - w.Reporter.OnProcessOutput += line => - { - if (line.Content.Contains("")) - { - hasUpdatedOutput.TrySetResult(); - } - }; - - w.Start(); - - Log("Waiting for changes..."); - await waitingForChanges.WaitAsync(w.ShutdownSource.Token); - - // change the project and source files at the same time: - - UpdateSourceFile(appProjFile, src => src.Replace(""" - - """, """ - - - - """)); - - UpdateSourceFile(appFile, code => code.Replace("// Lib.Print();", "Lib.Print();")); - - // done updating files: - fileChangesCompleted.TrySetResult(); - - Log("Waiting for output ''..."); - await hasUpdatedOutput.Task; - - AssertEx.ContainsSubstring("Resolving 'Dependency, Version=1.0.0.0'", w.Reporter.ProcessOutput); - - Assert.Equal(1, projectChangeTriggeredReEvaluation.CurrentCount); - Assert.Equal(1, projectsRebuilt.CurrentCount); - Assert.Equal(1, projectDependenciesDeployed.CurrentCount); - Assert.Equal(1, managedCodeChangesApplied.CurrentCount); - } - - [Fact] - public async Task ProjectAndSourceFileChange_AddPackageReference() - { - var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") - .WithSource(); - - var projFilePath = Path.Combine(testAsset.Path, "WatchHotReloadApp.csproj"); - var programFilePath = Path.Combine(testAsset.Path, "Program.cs"); - - await using var w = CreateWatcher(testAsset, []); - - var fileChangesCompleted = w.CreateCompletionSource(); - w.Watcher.Test_FileChangesCompletedTask = fileChangesCompleted.Task; - - var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); - var projectChangeTriggeredReEvaluation = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectChangeTriggeredReEvaluation); - var projectsRebuilt = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectsRebuilt); - var projectDependenciesDeployed = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectDependenciesDeployed); - var managedCodeChangesApplied = w.Reporter.RegisterSemaphore(MessageDescriptor.ManagedCodeChangesApplied); - - var hasUpdatedOutput = w.CreateCompletionSource(); - w.Reporter.OnProcessOutput += line => - { - if (line.Content.Contains("Newtonsoft.Json.Linq.JToken")) - { - hasUpdatedOutput.TrySetResult(); - } - }; - - w.Start(); - - Log("Waiting for changes..."); - await waitingForChanges.WaitAsync(w.ShutdownSource.Token); - - // change the project and source files at the same time: - - UpdateSourceFile(projFilePath, source => source.Replace(""" - - """, """ - - """)); - - UpdateSourceFile(programFilePath, source => source.Replace("Console.WriteLine(\".\");", "Console.WriteLine(typeof(Newtonsoft.Json.Linq.JToken));")); - - // done updating files: - fileChangesCompleted.TrySetResult(); - - Log("Waiting for output 'Newtonsoft.Json.Linq.JToken'..."); - await hasUpdatedOutput.Task; - - AssertEx.ContainsSubstring("Resolving 'Newtonsoft.Json, Version=13.0.0.0'", w.Reporter.ProcessOutput); - - Assert.Equal(1, projectChangeTriggeredReEvaluation.CurrentCount); - Assert.Equal(0, projectsRebuilt.CurrentCount); - Assert.Equal(1, projectDependenciesDeployed.CurrentCount); - Assert.Equal(1, managedCodeChangesApplied.CurrentCount); - } } diff --git a/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.HotReloadNotSupported.cs b/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.HotReloadNotSupported.cs new file mode 100644 index 000000000000..a414559d9c12 --- /dev/null +++ b/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.HotReloadNotSupported.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.DotNet.Watch.UnitTests +{ + public class SourceFileUpdateTests_HotReloadNotSupported(ITestOutputHelper logger) : DotNetWatchTestBase(logger) + { + [Theory] + [InlineData("PublishAot", "True")] + [InlineData("PublishTrimmed", "True")] + [InlineData("StartupHookSupport", "False")] + public async Task ChangeFileInAotProject(string propertyName, string propertyValue) + { + var tfm = ToolsetInfo.CurrentTargetFramework; + + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp", identifier: $"{propertyName};{propertyValue}") + .WithSource() + .WithProjectChanges(project => + { + project.Root.Descendants() + .First(e => e.Name.LocalName == "PropertyGroup") + .Add(XElement.Parse($"<{propertyName}>{propertyValue}")); + }); + + var programPath = Path.Combine(testAsset.Path, "Program.cs"); + + App.Start(testAsset, ["--non-interactive"]); + + await App.WaitForOutputLineContaining($"[WatchHotReloadApp ({tfm})] " + MessageDescriptor.ProjectDoesNotSupportHotReload.GetMessage($"'{propertyName}' property is '{propertyValue}'")); + await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); + App.Process.ClearOutput(); + + UpdateSourceFile(programPath, content => content.Replace("Console.WriteLine(\".\");", "Console.WriteLine(\"\");")); + + await App.WaitForOutputLineContaining($"[auto-restart] {programPath}(1,1): error ENC0097"); // Applying source changes while the application is running is not supported by the runtime. + await App.WaitForOutputLineContaining(""); + } + + [Fact] + public async Task ChangeFileInFSharpProject() + { + var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple") + .WithSource(); + + App.Start(testAsset, []); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); + + UpdateSourceFile(Path.Combine(testAsset.Path, "Program.fs"), content => content.Replace("Hello World!", "")); + + await App.WaitUntilOutputContains(""); + } + + [Fact] + public async Task ChangeFileInFSharpProjectWithLoop() + { + var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple") + .WithSource(); + + var source = """ + module ConsoleApplication.Program + + open System + open System.Threading + + [] + let main argv = + printfn "Waiting" + Thread.Sleep(Timeout.Infinite) + 0 + """; + + var sourcePath = Path.Combine(testAsset.Path, "Program.fs"); + + File.WriteAllText(sourcePath, source); + + App.Start(testAsset, ["--non-interactive"]); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + App.Process.ClearOutput(); + + UpdateSourceFile(sourcePath, content => content.Replace("Waiting", "")); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + await App.WaitUntilOutputContains(""); + App.Process.ClearOutput(); + + UpdateSourceFile(sourcePath, content => content.Replace("", "")); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + await App.WaitUntilOutputContains(""); + } + } +} diff --git a/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.cs b/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.cs index 097fae8b52cd..4c53e7478d63 100644 --- a/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.cs +++ b/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.cs @@ -97,92 +97,6 @@ public async Task BaselineCompilationError() await App.WaitUntilOutputContains(""); } - [Theory] - [InlineData("PublishAot", "True")] - [InlineData("PublishTrimmed", "True")] - [InlineData("StartupHookSupport", "False")] - public async Task ChangeFileInAotProject(string propertyName, string propertyValue) - { - var tfm = ToolsetInfo.CurrentTargetFramework; - - var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp", identifier: $"{propertyName};{propertyValue}") - .WithSource() - .WithProjectChanges(project => - { - project.Root.Descendants() - .First(e => e.Name.LocalName == "PropertyGroup") - .Add(XElement.Parse($"<{propertyName}>{propertyValue}")); - }); - - var programPath = Path.Combine(testAsset.Path, "Program.cs"); - - App.Start(testAsset, ["--non-interactive"]); - - await App.WaitForOutputLineContaining($"[WatchHotReloadApp ({tfm})] " + MessageDescriptor.ProjectDoesNotSupportHotReload.GetMessage($"'{propertyName}' property is '{propertyValue}'")); - await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); - App.Process.ClearOutput(); - - UpdateSourceFile(programPath, content => content.Replace("Console.WriteLine(\".\");", "Console.WriteLine(\"\");")); - - await App.WaitForOutputLineContaining($"[auto-restart] {programPath}(1,1): error ENC0097"); // Applying source changes while the application is running is not supported by the runtime. - await App.WaitForOutputLineContaining(""); - } - - [Fact] - public async Task ChangeFileInFSharpProject() - { - var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple") - .WithSource(); - - App.Start(testAsset, []); - - await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); - - UpdateSourceFile(Path.Combine(testAsset.Path, "Program.fs"), content => content.Replace("Hello World!", "")); - - await App.WaitUntilOutputContains(""); - } - - [Fact] - public async Task ChangeFileInFSharpProjectWithLoop() - { - var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple") - .WithSource(); - - var source = """ - module ConsoleApplication.Program - - open System - open System.Threading - - [] - let main argv = - printfn "Waiting" - Thread.Sleep(Timeout.Infinite) - 0 - """; - - var sourcePath = Path.Combine(testAsset.Path, "Program.fs"); - - File.WriteAllText(sourcePath, source); - - App.Start(testAsset, ["--non-interactive"]); - - await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); - App.Process.ClearOutput(); - - UpdateSourceFile(sourcePath, content => content.Replace("Waiting", "")); - - await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); - await App.WaitUntilOutputContains(""); - App.Process.ClearOutput(); - - UpdateSourceFile(sourcePath, content => content.Replace("", "")); - - await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); - await App.WaitUntilOutputContains(""); - } - // Test is timing out on .NET Framework: https://github.com/dotnet/sdk/issues/41669 [CoreMSBuildOnlyFact] public async Task HandleTypeLoadFailure() diff --git a/test/dotnet-watch.Tests/TestUtilities/DotNetWatchTestBase.cs b/test/dotnet-watch.Tests/TestUtilities/DotNetWatchTestBase.cs index 970ffb515545..8789283be5f1 100644 --- a/test/dotnet-watch.Tests/TestUtilities/DotNetWatchTestBase.cs +++ b/test/dotnet-watch.Tests/TestUtilities/DotNetWatchTestBase.cs @@ -2,28 +2,42 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch.UnitTests; /// /// Base class for all tests that create dotnet watch process. /// -public abstract class DotNetWatchTestBase : IDisposable +public abstract partial class DotNetWatchTestBase : IAsyncLifetime { internal TestAssetsManager TestAssets { get; } internal WatchableApp App { get; } public DotNetWatchTestBase(ITestOutputHelper logger) { - var debugLogger = new DebugTestOutputLogger(logger); - App = new WatchableApp(debugLogger); - TestAssets = new TestAssetsManager(debugLogger); + App = WatchableApp.CreateDotnetWatchApp(logger); + TestAssets = new TestAssetsManager(App.Logger); + } + + public Task InitializeAsync() + => Task.CompletedTask; - // disposes the test class if the test execution is cancelled: - AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose(); + public async Task DisposeAsync() + { + Log("Disposing test"); + await App.DisposeAsync(); } - public DebugTestOutputLogger Logger => App.Logger; + public DebugTestOutputLogger Logger + => App.Logger; + + internal TestAsset CopyTestAsset( + string assetName, + object[]? testParameters = null, + [CallerMemberName] string callingMethod = "", + [CallerFilePath] string? callerFilePath = null) + => TestAssets.CopyTestAsset(assetName, callingMethod, callerFilePath, identifier: string.Join(";", testParameters ?? [])).WithSource(); public void Log(string message, [CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0) => App.Logger.Log(message, testPath, testLine); @@ -57,8 +71,37 @@ public static void WriteAllText(string path, string text) public void UpdateSourceFile(string path) => UpdateSourceFile(path, content => content); - public void Dispose() + internal InProcTestWatcher CreateInProcWatcher(TestAsset testAsset, string[] args, string? workingDirectory = null) { - App.Dispose(); + var console = new TestConsole(Logger); + var reporter = new TestReporter(Logger); + var loggerFactory = new TestLoggerFactory(Logger); + var eventObserver = new TestEventObserver(); + var observableLoggerFactory = new TestObservableLoggerFactory(eventObserver, loggerFactory); + var environmentOptions = TestOptions.GetEnvironmentOptions(workingDirectory ?? testAsset.Path, testAsset); + var processRunner = new ProcessRunner(environmentOptions.GetProcessCleanupTimeout()); + + var program = Program.TryCreate( + TestOptions.GetCommandLineOptions(["--verbose", .. args]), + console, + environmentOptions, + observableLoggerFactory, + reporter, + out var errorCode); + + Assert.Equal(0, errorCode); + Assert.NotNull(program); + + var serviceHolder = new StrongBox(); + var factory = new TestRuntimeProcessLauncher.Factory(s => + { + serviceHolder.Value = s; + }); + + var context = program.CreateContext(processRunner); + var watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: factory); + var shutdownSource = new CancellationTokenSource(); + + return new InProcTestWatcher(Logger, watcher, context, eventObserver, reporter, console, serviceHolder, shutdownSource); } } diff --git a/test/dotnet-watch.Tests/TestUtilities/InProcBuildTestCollection.cs b/test/dotnet-watch.Tests/TestUtilities/InProcBuildTestCollection.cs deleted file mode 100644 index f0054a2e5945..000000000000 --- a/test/dotnet-watch.Tests/TestUtilities/InProcBuildTestCollection.cs +++ /dev/null @@ -1,13 +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; - -/// -/// All tests that validate msbuild build in-proc must be included in this collection -/// as mutliple builds can't run in parallel in the same process. -/// -[CollectionDefinition(nameof(InProcBuildTestCollection), DisableParallelization = true)] -public sealed class InProcBuildTestCollection -{ -} diff --git a/test/dotnet-watch.Tests/TestUtilities/InProcTestWatcher.cs b/test/dotnet-watch.Tests/TestUtilities/InProcTestWatcher.cs new file mode 100644 index 000000000000..efe29f6f44da --- /dev/null +++ b/test/dotnet-watch.Tests/TestUtilities/InProcTestWatcher.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +namespace Microsoft.DotNet.Watch.UnitTests; + +internal record class InProcTestWatcher( + DebugTestOutputLogger TestOutput, + HotReloadDotNetWatcher Watcher, + DotNetWatchContext Context, + TestEventObserver Observer, + TestReporter Reporter, + TestConsole Console, + StrongBox ServiceHolder, + CancellationTokenSource ShutdownSource) : IAsyncDisposable +{ + public TestRuntimeProcessLauncher? Service => ServiceHolder.Value; + private Task? _lazyTask; + + public void Start([CallerFilePath] string? testPath = null, [CallerLineNumber] int testLine = 0) + { + Assert.Null(_lazyTask); + Observer.Freeze(); + + _lazyTask = Task.Run(async () => + { + TestOutput.Log("Starting watch", testPath, testLine); + + try + { + await Watcher.WatchAsync(ShutdownSource.Token); + } + catch (Exception e) when (e is not OperationCanceledException) + { + ShutdownSource.Cancel(); + TestOutput.WriteLine($"Unexpected exception {e}"); + throw; + } + finally + { + Context.Dispose(); + } + }, ShutdownSource.Token); + } + + public async ValueTask DisposeAsync() + { + Assert.NotNull(_lazyTask); + + if (!ShutdownSource.IsCancellationRequested) + { + TestOutput.Log("Shutting down"); + ShutdownSource.Cancel(); + } + + try + { + await _lazyTask; + } + catch (OperationCanceledException) + { + } + } + + public TaskCompletionSource CreateCompletionSource() + { + var source = new TaskCompletionSource(); + ShutdownSource.Token.Register(() => source.TrySetCanceled(ShutdownSource.Token)); + return source; + } +} diff --git a/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs b/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs index f567640dfc9b..f5065d47dfb6 100644 --- a/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs +++ b/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs @@ -11,7 +11,7 @@ internal class MockFileSetFactory() : MSBuildFileSetFactory( buildArguments: [], new ProcessRunner(processCleanupTimeout: TimeSpan.Zero), NullLogger.Instance, - TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory, "dotnet") is var options ? options : options) + TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory) is var options ? options : options) { public Func? TryCreateImpl; diff --git a/test/dotnet-watch.Tests/TestUtilities/TestEventObserver.cs b/test/dotnet-watch.Tests/TestUtilities/TestEventObserver.cs new file mode 100644 index 000000000000..7a9b72aa0acb --- /dev/null +++ b/test/dotnet-watch.Tests/TestUtilities/TestEventObserver.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch.UnitTests; + +/// +/// Used to observe events logged by the watcher. +/// +/// Usage pattern: register all actions and semaphores first, then start the watcher. +/// +internal class TestEventObserver +{ + private readonly Dictionary _actions = []; + private bool _frozen; + + public void Freeze() + => _frozen = true; + + private void RequireNotFrozen() + { + if (_frozen) + { + throw new InvalidOperationException("Cannot register actions after the observer is frozen."); + } + } + + public SemaphoreSlim RegisterSemaphore(MessageDescriptor descriptor) + { + RequireNotFrozen(); + + var semaphore = new SemaphoreSlim(initialCount: 0); + RegisterAction(descriptor, () => semaphore.Release()); + return semaphore; + } + + public void RegisterAction(MessageDescriptor eventId, Action action) + => RegisterAction(eventId.Id, action); + + public void RegisterAction(EventId eventId, Action action) + { + RequireNotFrozen(); + + if (_actions.TryGetValue(eventId, out var existing)) + { + existing += action; + } + else + { + existing = action; + } + + _actions[eventId] = existing; + } + + public void Observe(EventId eventId) + { + if (_actions.TryGetValue(eventId, out var action)) + { + action(); + } + } +} diff --git a/test/dotnet-watch.Tests/TestUtilities/TestObservableLoggerFactory.cs b/test/dotnet-watch.Tests/TestUtilities/TestObservableLoggerFactory.cs new file mode 100644 index 000000000000..88caf5e2a9c4 --- /dev/null +++ b/test/dotnet-watch.Tests/TestUtilities/TestObservableLoggerFactory.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch.UnitTests; + +internal class TestObservableLoggerFactory(TestEventObserver observer, ILoggerFactory underlyingFactory) : ILoggerFactory +{ + private class Logger(TestEventObserver observer, ILogger underlyingLogger) : ILogger + { + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + observer.Observe(eventId); + underlyingLogger.Log(logLevel, eventId, state, exception, formatter); + } + + public IDisposable? BeginScope(TState state) where TState : notnull + => underlyingLogger.BeginScope(state); + + public bool IsEnabled(LogLevel logLevel) + => underlyingLogger.IsEnabled(logLevel); + } + + public ILogger CreateLogger(string categoryName) + => new Logger(observer, underlyingFactory.CreateLogger(categoryName)); + + public void AddProvider(ILoggerProvider provider) + => underlyingFactory.AddProvider(provider); + + public void Dispose() + => underlyingFactory.Dispose(); +} diff --git a/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs b/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs index 7c74d6978523..e1f9549f2a50 100644 --- a/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs +++ b/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs @@ -14,8 +14,15 @@ public static int GetTestPort() public static readonly ProjectOptions ProjectOptions = GetProjectOptions(GetCommandLineOptions([])); - public static EnvironmentOptions GetEnvironmentOptions(string workingDirectory = "", string muxerPath = "", TestAsset? asset = null) - => new(workingDirectory, muxerPath, ProcessCleanupTimeout: null, IsPollingEnabled: true, TestFlags: TestFlags.RunningAsTest, TestOutput: asset != null ? asset.GetWatchTestOutputPath() : ""); + public static EnvironmentOptions GetEnvironmentOptions(string workingDirectory = "", TestAsset? asset = null) + => new( + WorkingDirectory: workingDirectory, + SdkDirectory: TestContext.Current.ToolsetUnderTest.SdkFolderUnderTest, + LogMessagePrefix: "dotnet watch", + ProcessCleanupTimeout: null, + IsPollingEnabled: true, + TestFlags: TestFlags.RunningAsTest, + TestOutput: asset != null ? asset.GetWatchTestOutputPath() : ""); public static CommandLineOptions GetCommandLineOptions(string[] args) => CommandLineOptions.Parse(args, NullLogger.Instance, TextWriter.Null, out _) ?? throw new InvalidOperationException(); diff --git a/test/dotnet-watch.Tests/TestUtilities/TestReporter.cs b/test/dotnet-watch.Tests/TestUtilities/TestReporter.cs index 9637de705eed..eda3f6090159 100644 --- a/test/dotnet-watch.Tests/TestUtilities/TestReporter.cs +++ b/test/dotnet-watch.Tests/TestUtilities/TestReporter.cs @@ -7,7 +7,6 @@ namespace Microsoft.DotNet.Watch.UnitTests { internal class TestReporter(ITestOutputHelper output) : IReporter, IProcessOutputReporter { - private readonly Dictionary _actions = []; public readonly List ProcessOutput = []; public readonly List<(LogLevel level, string text)> Messages = []; @@ -24,42 +23,10 @@ void IProcessOutputReporter.ReportOutput(OutputLine line) OnProcessOutput?.Invoke(line); } - public SemaphoreSlim RegisterSemaphore(MessageDescriptor descriptor) - { - var semaphore = new SemaphoreSlim(initialCount: 0); - RegisterAction(descriptor, () => semaphore.Release()); - return semaphore; - } - - public void RegisterAction(MessageDescriptor eventId, Action action) - => RegisterAction(eventId.Id, action); - - public void RegisterAction(EventId eventId, Action action) - { - if (_actions.TryGetValue(eventId, out var existing)) - { - existing += action; - } - else - { - existing = action; - } - - _actions[eventId] = existing; - } - public void Report(EventId id, Emoji emoji, LogLevel level, string message) { - if (level != LogLevel.None) - { - Messages.Add((level, message)); - WriteTestOutput($"{ToString(level)} {emoji.ToDisplay()} {message}"); - } - - if (_actions.TryGetValue(id, out var action)) - { - action(); - } + Messages.Add((level, message)); + WriteTestOutput($"{ToString(level)} {emoji.ToDisplay()} {message}"); } private void WriteTestOutput(string line) diff --git a/test/dotnet-watch.Tests/TestUtilities/TestRuntimeProcessLauncher.cs b/test/dotnet-watch.Tests/TestUtilities/TestRuntimeProcessLauncher.cs index f78dee02e930..b4d7ef9160c6 100644 --- a/test/dotnet-watch.Tests/TestUtilities/TestRuntimeProcessLauncher.cs +++ b/test/dotnet-watch.Tests/TestUtilities/TestRuntimeProcessLauncher.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.Build.Graph; - namespace Microsoft.DotNet.Watch.UnitTests; internal class TestRuntimeProcessLauncher(ProjectLauncher projectLauncher) : IRuntimeProcessLauncher @@ -33,4 +31,41 @@ public ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancellationT TerminateLaunchedProcessesImpl?.Invoke(); return ValueTask.CompletedTask; } + + public async Task Launch(string projectPath, string workingDirectory, CancellationToken cancellationToken) + { + var projectOptions = new ProjectOptions() + { + IsMainProject = false, + Representation = new ProjectRepresentation(projectPath, entryPointFilePath: null), + WorkingDirectory = workingDirectory, + Command = "run", + CommandArguments = ["--project", projectPath], + LaunchEnvironmentVariables = [], + LaunchProfileName = default, + }; + + RunningProject? runningProject = null; + RestartOperation? startOp = null; + startOp = new RestartOperation(async cancellationToken => + { + Assert.NotNull(startOp); + + runningProject = await ProjectLauncher.TryLaunchProcessAsync( + projectOptions, + onOutput: null, + onExit: null, + restartOperation: startOp, + cancellationToken); + + Assert.NotNull(runningProject); + + await runningProject.Clients.WaitForConnectionEstablishedAsync(cancellationToken); + }); + + await startOp(cancellationToken); + + Assert.NotNull(runningProject); + return runningProject; + } } diff --git a/test/dotnet-watch.Tests/Watch/FileUpdateTests.cs b/test/dotnet-watch.Tests/Watch/FileUpdateTests.cs new file mode 100644 index 000000000000..2e4dddca6ada --- /dev/null +++ b/test/dotnet-watch.Tests/Watch/FileUpdateTests.cs @@ -0,0 +1,52 @@ +// 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 FileUpdateTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger) + { + [Fact] + public async Task RestartProcessOnFileChange() + { + var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp") + .WithSource(); + + App.Start(testAsset, ["--no-hot-reload", "--no-exit"]); + var processIdentifier = await App.WaitUntilOutputContains("Process identifier ="); + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + App.Process.ClearOutput(); + + UpdateSourceFile(Path.Combine(testAsset.Path, "Program.cs")); + + await App.WaitUntilOutputContains("Started"); + + var processIdentifier2 = await App.WaitUntilOutputContains("Process identifier ="); + Assert.NotEqual(processIdentifier, processIdentifier2); + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + } + + [Fact] + public async Task RestartProcessThatTerminatesAfterFileChange() + { + var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp") + .WithSource(); + + App.Start(testAsset, []); + + var processIdentifier = await App.WaitUntilOutputContains("Process identifier ="); + + // process should exit after run + await App.WaitUntilOutputContains("Exiting"); + + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); + App.Process.ClearOutput(); + + UpdateSourceFile(Path.Combine(testAsset.Path, "Program.cs")); + await App.WaitUntilOutputContains("Started"); + + var processIdentifier2 = await App.WaitUntilOutputContains("Process identifier ="); + Assert.NotEqual(processIdentifier, processIdentifier2); + await App.WaitUntilOutputContains("Exiting"); // process should exit after run + } + } +} diff --git a/test/dotnet-watch.Tests/Watch/GlobbingAppTests.cs b/test/dotnet-watch.Tests/Watch/GlobbingAppTests.cs index 5a2005532e0f..c3af966604f0 100644 --- a/test/dotnet-watch.Tests/Watch/GlobbingAppTests.cs +++ b/test/dotnet-watch.Tests/Watch/GlobbingAppTests.cs @@ -90,7 +90,7 @@ public async Task ChangeExcludedFile() File.WriteAllText(changedFile, ""); // no file change within timeout: - var fileChanged = App.AssertFileChanged(); + var fileChanged = App.AssertOutputLineStartsWith("dotnet watch ⌚ File changed:"); var finished = await Task.WhenAny(Task.Delay(TimeSpan.FromSeconds(5)), fileChanged); Assert.NotSame(fileChanged, finished); } @@ -103,8 +103,8 @@ public async Task ListsFiles() App.SuppressVerboseLogging(); App.Start(testAsset, ["--list"]); - var lines = await App.Process.GetAllOutputLinesAsync(CancellationToken.None); - var files = lines.Where(l => !l.StartsWith("dotnet watch ⌚") && l.Trim() != ""); + await App.Process.WaitUntilOutputCompleted(); + var files = App.Process.Output.Where(l => !l.StartsWith("dotnet watch ⌚") && l.Trim() != ""); AssertEx.EqualFileList( testAsset.Path, diff --git a/test/dotnet-watch.Tests/Watch/NoDepsAppTests.cs b/test/dotnet-watch.Tests/Watch/NoDepsAppTests.cs deleted file mode 100644 index fe93ef940c90..000000000000 --- a/test/dotnet-watch.Tests/Watch/NoDepsAppTests.cs +++ /dev/null @@ -1,52 +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 NoDepsAppTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger) - { - private const string AppName = "WatchNoDepsApp"; - - [Fact(Skip = "https://github.com/dotnet/sdk/issues/42921")] - public async Task RestartProcessOnFileChange() - { - var testAsset = TestAssets.CopyTestAsset(AppName) - .WithSource(); - - App.Start(testAsset, ["--no-hot-reload", "--no-exit"]); - var processIdentifier = await App.AssertOutputLineStartsWith("Process identifier ="); - - UpdateSourceFile(Path.Combine(testAsset.Path, "Program.cs")); - - await App.AssertStarted(); - Assert.DoesNotContain(App.Process.Output, l => l.StartsWith("Exited with error code")); - - var processIdentifier2 = await App.AssertOutputLineStartsWith("Process identifier ="); - Assert.NotEqual(processIdentifier, processIdentifier2); - } - - [Fact(Skip = "https://github.com/dotnet/sdk/issues/42921")] - public async Task RestartProcessThatTerminatesAfterFileChange() - { - var testAsset = TestAssets.CopyTestAsset(AppName) - .WithSource(); - - App.Start(testAsset, []); - - var processIdentifier = await App.AssertOutputLineStartsWith("Process identifier ="); - - // process should exit after run - await App.AssertExiting(); - - await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForFileChangeBeforeRestarting); - await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); - - UpdateSourceFile(Path.Combine(testAsset.Path, "Program.cs")); - await App.AssertStarted(); - - var processIdentifier2 = await App.AssertOutputLineStartsWith("Process identifier ="); - Assert.NotEqual(processIdentifier, processIdentifier2); - await App.AssertExiting(); // process should exit after run - } - } -}