diff --git a/eng/scripts/analyze-watch-test-output.cs b/eng/scripts/analyze-watch-test-output.cs new file mode 100644 index 000000000000..3321228d4b7f --- /dev/null +++ b/eng/scripts/analyze-watch-test-output.cs @@ -0,0 +1,419 @@ +// Usage: dotnet run analyze-watch-test-output.cs [output-html-file] +// Parses xUnit test output from dotnet watch, groups interleaved lines by test name, +// and generates an HTML page with collapsible test output sections. +// The first argument can be a local file path or a URL. If a URL is provided, +// the file is downloaded to a temp directory before processing. + +using System.Text; +using System.Text.RegularExpressions; + +if (args.Length < 1) +{ + Console.Error.WriteLine("Usage: dotnet run analyze-watch-test-output.cs [output-html-file]"); + return 1; +} + +string inputFile; +string? tempFile = null; + +if (Uri.TryCreate(args[0], UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https")) +{ + try + { + using var httpClient = new HttpClient(); + Console.Error.WriteLine($"Downloading {args[0]}..."); + var bytes = httpClient.GetByteArrayAsync(uri).GetAwaiter().GetResult(); + var fileName = Path.GetFileName(uri.LocalPath); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = "downloaded-log.txt"; + tempFile = Path.Combine(Path.GetTempPath(), fileName); + File.WriteAllBytes(tempFile, bytes); + inputFile = tempFile; + Console.Error.WriteLine($"Downloaded to {tempFile}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: Failed to download {args[0]}: {ex.Message}"); + return 1; + } +} +else +{ + inputFile = args[0]; +} + +if (!File.Exists(inputFile)) +{ + Console.Error.WriteLine($"Error: File not found: {inputFile}"); + return 1; +} + +var outputFile = args.Length > 1 ? args[1] : Path.ChangeExtension(inputFile, ".html"); + +try +{ + var lines = File.ReadAllLines(inputFile); + var tests = ParseTestOutput(lines); + var html = GenerateHtml(tests, inputFile); + File.WriteAllText(outputFile, html, Encoding.UTF8); + Console.WriteLine($"Wrote {outputFile} ({tests.Count} tests, {lines.Length} input lines)"); + return 0; +} +finally +{ + if (tempFile != null && File.Exists(tempFile)) + File.Delete(tempFile); +} + +// --- Parsing --- + +List ParseTestOutput(string[] allLines) +{ + // [xUnit.net HH:MM:SS.ss] TestName [TAG] content + var testLineRegex = new Regex( + @"^\[xUnit\.net\s+([^\]]+)\]\s{4,}(.+?)\s+\[(\w+)\]\s?(.*)?$"); + + // [xUnit.net HH:MM:SS.ss] continuation (6+ spaces, no tag — e.g. skip reason) + var continuationRegex = new Regex( + @"^\[xUnit\.net\s+([^\]]+)\]\s{6,}(.+)$"); + + // [xUnit.net HH:MM:SS.ss] assembly: [Long Running Test] 'TestName', Elapsed: ... + var longRunningRegex = new Regex( + @"^\[xUnit\.net\s+([^\]]+)\]\s+\S+:\s+\[Long Running Test\]\s+'([^']+)'"); + + // Non-xunit result lines: Passed/Failed/Skipped TestName [time] + var resultRegex = new Regex( + @"^\s+(Passed|Failed|Skipped)\s+(.+?)\s+\["); + + var testMap = new Dictionary(StringComparer.Ordinal); + string? lastTestName = null; + + TestInfo GetOrCreate(string name) + { + if (!testMap.TryGetValue(name, out var info)) + { + info = new TestInfo(name); + testMap[name] = info; + } + return info; + } + + for (int i = 0; i < allLines.Length; i++) + { + var line = allLines[i]; + + // 1. Test-specific xUnit line with tag + var m = testLineRegex.Match(line); + if (m.Success) + { + var timestamp = m.Groups[1].Value; + var testName = m.Groups[2].Value; + var tag = m.Groups[3].Value; + var content = m.Groups[4].Value; + + var info = GetOrCreate(testName); + lastTestName = testName; + + if (tag.Equals("PASS", StringComparison.OrdinalIgnoreCase)) + { + info.Status = TestStatus.Passed; + info.Lines.Add("[PASS]"); + } + else if (tag.Equals("FAIL", StringComparison.OrdinalIgnoreCase)) + { + info.Status = TestStatus.Failed; + info.Lines.Add("[FAIL]"); + } + else if (tag.Equals("SKIP", StringComparison.OrdinalIgnoreCase)) + { + info.Status = TestStatus.Skipped; + info.Lines.Add("[SKIP]"); + } + else + { + // [OUTPUT] and other tags — show just the content + info.Lines.Add(content); + } + continue; + } + + // 2. Continuation line (e.g. skip reason) + m = continuationRegex.Match(line); + if (m.Success && lastTestName != null) + { + var info = GetOrCreate(lastTestName); + info.Lines.Add(m.Groups[2].Value); + continue; + } + + // 3. Long Running Test line — skip entirely + if (longRunningRegex.IsMatch(line)) + continue; + + // 4. Non-xunit result line (Passed/Failed/Skipped) + m = resultRegex.Match(line); + if (m.Success) + { + var result = m.Groups[1].Value; + var testName = m.Groups[2].Value; + var info = GetOrCreate(testName); + // Extract duration from e.g. " Passed TestName [67 ms]" + var durationMatch = Regex.Match(line, @"\[([^\]]+)\]\s*$"); + var duration = durationMatch.Success ? durationMatch.Groups[1].Value : ""; + info.Lines.Add($"{result} [{duration}]"); + + if (result.Equals("Passed", StringComparison.OrdinalIgnoreCase) && info.Status == TestStatus.Unknown) + info.Status = TestStatus.Passed; + else if (result.Equals("Failed", StringComparison.OrdinalIgnoreCase)) + info.Status = TestStatus.Failed; + else if (result.Equals("Skipped", StringComparison.OrdinalIgnoreCase) && info.Status == TestStatus.Unknown) + info.Status = TestStatus.Skipped; + continue; + } + } + + // Sort: failed first, then skipped, then unknown/running, then passed + var sorted = testMap.Values.ToList(); + sorted.Sort((a, b) => + { + int Order(TestStatus s) => s switch + { + TestStatus.Failed => 0, + TestStatus.Skipped => 1, + TestStatus.Unknown => 2, + TestStatus.Passed => 3, + _ => 4 + }; + int cmp = Order(a.Status).CompareTo(Order(b.Status)); + return cmp != 0 ? cmp : string.Compare(a.Name, b.Name, StringComparison.Ordinal); + }); + + return sorted; +} + +// --- HTML Generation --- + +string GenerateHtml(List tests, string sourceFile) +{ + int passed = tests.Count(t => t.Status == TestStatus.Passed); + int failed = tests.Count(t => t.Status == TestStatus.Failed); + int skipped = tests.Count(t => t.Status == TestStatus.Skipped); + int unknown = tests.Count(t => t.Status == TestStatus.Unknown); + + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"Test Output: {HtmlEncode(Path.GetFileName(sourceFile))}"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"

Test Output: {HtmlEncode(Path.GetFileName(sourceFile))}

"); + + sb.AppendLine("
"); + sb.AppendLine($" {tests.Count} tests"); + if (passed > 0) sb.AppendLine($" ✅ {passed} passed"); + if (failed > 0) sb.AppendLine($" ❌ {failed} failed"); + if (skipped > 0) sb.AppendLine($" ⏭ {skipped} skipped"); + if (unknown > 0) sb.AppendLine($" ⏳ {unknown} unknown"); + sb.AppendLine("
"); + + sb.AppendLine("
"); + + sb.AppendLine("
"); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine("
"); + + sb.AppendLine("
"); + foreach (var test in tests) + { + var badgeClass = test.Status switch + { + TestStatus.Passed => "b-passed", + TestStatus.Failed => "b-failed", + TestStatus.Skipped => "b-skipped", + _ => "b-unknown" + }; + var badgeText = test.Status switch + { + TestStatus.Passed => "PASS", + TestStatus.Failed => "FAIL", + TestStatus.Skipped => "SKIP", + _ => "???" + }; + var openAttr = test.Status == TestStatus.Failed ? " open" : ""; + var arrow = test.Status == TestStatus.Failed ? "▼" : "▶"; + + sb.AppendLine($"
"); + sb.AppendLine($" {arrow}{badgeText}{HtmlEncode(test.Name)}({test.Lines.Count} lines)"); + sb.AppendLine("
"); + RenderTestLines(sb, test.Lines); + sb.AppendLine("
"); + sb.AppendLine("
"); + } + sb.AppendLine("
"); + + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + return sb.ToString(); +} + +void RenderTestLines(StringBuilder sb, List lines) +{ + var watchingRegex = new Regex(@"Watching \d+ file\(s\) for changes"); + var fileLineRegex = new Regex(@">\s+\S"); + var solutionRegex = new Regex(@"Solution after (project|document) update:"); + var solutionChildRegex = new Regex(@"(Project:|Document:|Additional:|Config:)\s"); + + int i = 0; + while (i < lines.Count) + { + if (watchingRegex.IsMatch(lines[i])) + { + i = RenderCollapsibleGroup(sb, lines, i, fileLineRegex); + } + else if (solutionRegex.IsMatch(lines[i])) + { + i = RenderCollapsibleGroup(sb, lines, i, solutionChildRegex); + } + else + { + sb.AppendLine(RenderLine(lines[i])); + i++; + } + } +} + +int RenderCollapsibleGroup(StringBuilder sb, List lines, int i, Regex childRegex) +{ + var headerLine = lines[i]; + var children = new List(); + int j = i + 1; + while (j < lines.Count && childRegex.IsMatch(lines[j])) + { + children.Add(lines[j]); + j++; + } + + if (children.Count > 0) + { + sb.AppendLine($"
{HtmlEncode(headerLine)}
"); + foreach (var child in children) + sb.AppendLine(RenderLine(child)); + sb.AppendLine("
"); + return j; + } + + sb.AppendLine(RenderLine(lines[i])); + return i + 1; +} + +static string HtmlEncode(string s) => s + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """); + +static string HtmlAttrEncode(string s) => HtmlEncode(s).Replace("'", "'"); + +static bool HasEmoji(string s) +{ + foreach (var rune in s.EnumerateRunes()) + { + int v = rune.Value; + if ((v >= 0x2300 && v <= 0x27BF) || (v >= 0x2900 && v <= 0x2BFF) || + (v >= 0x2600 && v <= 0x26FF) || (v >= 0x1F300 && v <= 0x1F9FF)) + return true; + } + return false; +} + +static string RenderLine(string line) +{ + if (Regex.IsMatch(line, @"^\[TEST [^\]]+:\d+\]")) + return $"
{HtmlEncode(line)}
"; + var cls = HasEmoji(line) ? "line" : "line no-emoji"; + return $"
{HtmlEncode(line)}
"; +} + +// --- Types --- + +enum TestStatus { Unknown, Passed, Failed, Skipped } + +class TestInfo(string name) +{ + public string Name { get; } = name; + public List Lines { get; } = []; + public TestStatus Status { get; set; } = TestStatus.Unknown; +} diff --git a/src/Cli/dotnet/Program.cs b/src/Cli/dotnet/Program.cs index 976dc64524a2..b7b075f9a336 100644 --- a/src/Cli/dotnet/Program.cs +++ b/src/Cli/dotnet/Program.cs @@ -107,7 +107,15 @@ public static int Main(string[] args) { // If telemetry object has not been initialized yet. It cannot be collected TelemetryEventEntry.SendFiltered(e); - Reporter.Error.WriteLine(e.ToString().Red().Bold()); + + var str = e.ToString(); + if (str.Contains("Internal CLR error")) + { + Reporter.Error.WriteLine("!!!".Red().Bold()); + Debugger.Break(); + } + + Reporter.Error.WriteLine(str.Red().Bold()); return 1; } 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/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/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs index 4ecca0d7bef7..58db01399cff 100644 --- a/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs @@ -14,7 +14,7 @@ internal class AwaitableProcess(ITestOutputHelper logger) : IDisposable { // 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); + ? TimeSpan.Parse(value).Subtract(TimeSpan.FromSeconds(10)) : TimeSpan.FromMinutes(10); private readonly object _testOutputLock = new(); diff --git a/test/dotnet-watch.Tests/Build/EvaluationTests.cs b/test/dotnet-watch.Tests/Build/EvaluationTests.cs index 50ce37dca37e..a800369fe676 100644 --- a/test/dotnet-watch.Tests/Build/EvaluationTests.cs +++ b/test/dotnet-watch.Tests/Build/EvaluationTests.cs @@ -141,7 +141,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 ? 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/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..d99a8b9a6080 100644 --- a/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs +++ b/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs @@ -45,278 +45,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() { 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/HotReload/CtrlRTests.cs b/test/dotnet-watch.Tests/HotReload/CtrlRTests.cs new file mode 100644 index 000000000000..ba71eb121128 --- /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.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 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.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); + } +} diff --git a/test/dotnet-watch.Tests/HotReload/FileExclusionTests.cs b/test/dotnet-watch.Tests/HotReload/FileExclusionTests.cs new file mode 100644 index 000000000000..d0169647e2ba --- /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.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(); + } + } +} diff --git a/test/dotnet-watch.Tests/HotReload/ProjectUpdateInProcTests.cs b/test/dotnet-watch.Tests/HotReload/ProjectUpdateInProcTests.cs new file mode 100644 index 000000000000..4d75d31a66a6 --- /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.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 = CreateInProcWatcher(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 = CreateInProcWatcher(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/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index 0c73767e5a47..36cc83deae30 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -1,158 +1,15 @@ // 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 - { - HotReloadSessionStarting, - HotReloadSessionStarted, - 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,7 +26,7 @@ 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(); @@ -191,10 +48,10 @@ 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(); }); @@ -313,7 +170,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,7 +189,7 @@ 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); @@ -368,7 +225,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 +250,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 +271,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,7 +281,7 @@ 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); @@ -512,7 +369,7 @@ 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); @@ -529,7 +386,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 +405,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..a62edb70670c 100644 --- a/test/dotnet-watch.Tests/TestUtilities/DotNetWatchTestBase.cs +++ b/test/dotnet-watch.Tests/TestUtilities/DotNetWatchTestBase.cs @@ -2,13 +2,14 @@ // 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 : IDisposable { internal TestAssetsManager TestAssets { get; } internal WatchableApp App { get; } @@ -23,8 +24,20 @@ public DotNetWatchTestBase(ITestOutputHelper logger) AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose(); } + public void Dispose() + { + App.Dispose(); + } + 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 +70,43 @@ public static void WriteAllText(string path, string text) public void UpdateSourceFile(string path) => UpdateSourceFile(path, content => content); - public void Dispose() + + public enum TriggerEvent { - App.Dispose(); + HotReloadSessionStarting, + HotReloadSessionStarted, + WaitingForChanges, + } + + internal InProcTestWatcher CreateInProcWatcher(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 InProcTestWatcher(Logger, watcher, context, 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..195f0cdc1fe4 --- /dev/null +++ b/test/dotnet-watch.Tests/TestUtilities/InProcTestWatcher.cs @@ -0,0 +1,70 @@ +// 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 Logger, + 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 () => + { + Logger.Log("Starting watch"); + + try + { + await Watcher.WatchAsync(ShutdownSource.Token); + } + catch (Exception e) when (e is not OperationCanceledException) + { + ShutdownSource.Cancel(); + Logger.WriteLine($"Unexpected exception {e}"); + throw; + } + finally + { + Context.Dispose(); + } + }, ShutdownSource.Token); + } + + public async ValueTask DisposeAsync() + { + Assert.NotNull(_lazyTask); + + if (!ShutdownSource.IsCancellationRequested) + { + Logger.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/TestRuntimeProcessLauncher.cs b/test/dotnet-watch.Tests/TestUtilities/TestRuntimeProcessLauncher.cs index f78dee02e930..83b87bd46055 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,37 @@ 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, + }; + + RestartOperation? startOp = null; + startOp = new RestartOperation(async cancellationToken => + { + var result = await ProjectLauncher.TryLaunchProcessAsync( + projectOptions, + onOutput: null, + onExit: null, + restartOperation: startOp!, + cancellationToken); + + Assert.NotNull(result); + + await result.Clients.WaitForConnectionEstablishedAsync(cancellationToken); + + return result; + }); + + return await startOp(cancellationToken); + } } diff --git a/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs b/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs index 5aca2df2b4c7..33a85a40d5fa 100644 --- a/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs +++ b/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs @@ -212,6 +212,13 @@ public void Start(TestAsset asset, IEnumerable arguments, string relativ commandSpec.WithEnvironmentVariable("DCP_IDE_NOTIFICATION_KEEPALIVE_SECONDS", "100000"); commandSpec.WithEnvironmentVariable("ASPIRE_ALLOW_UNSECURED_TRANSPORT", "1"); + // Set up automatic dump collection on uncaught exception for launched processes + // See https://learn.microsoft.com/en-us/dotnet/core/diagnostics/collect-dumps-crash + commandSpec.WithEnvironmentVariable("DOTNET_DbgEnableMiniDump", "1"); + commandSpec.WithEnvironmentVariable("DOTNET_DbgMiniDumpType", "2"); // heap dump + commandSpec.WithEnvironmentVariable("DOTNET_DbgMiniDumpName", Path.Combine(testOutputPath, "%e.%p.%t.dmp")); // ...dmp + commandSpec.WithEnvironmentVariable("DOTNET_EnableCrashReport", "1"); + foreach (var env in EnvironmentVariables) { commandSpec.WithEnvironmentVariable(env.Key, env.Value);