File: HotReload\ApplyDeltaTests.cs
Web Access
Project: ..\..\..\test\dotnet-watch.Tests\dotnet-watch.Tests.csproj (dotnet-watch.Tests)
// 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.Text.RegularExpressions;
 
namespace Microsoft.DotNet.Watch.UnitTests
{
    public class ApplyDeltaTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger)
    {
        [Fact]
        public async Task AddSourceFile()
        {
            Log("AddSourceFile started");
 
            var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps")
                .WithSource();
 
            var dependencyDir = Path.Combine(testAsset.Path, "Dependency");
 
            App.Start(testAsset, [], "AppWithDeps");
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            // add a new file:
            UpdateSourceFile(Path.Combine(dependencyDir, "AnotherLib.cs"), """
                public class AnotherLib
                {
                    public static void Print()
                        => System.Console.WriteLine("Changed!");
                }
                """);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.ReEvaluationCompleted);
 
            // update existing file:
            UpdateSourceFile(Path.Combine(dependencyDir, "Foo.cs"), """
                public class Lib
                {
                    public static void Print()
                        => AnotherLib.Print();
                }
                """);
 
            await App.AssertOutputLineStartsWith("Changed!");
        }
 
        [Fact]
        public async Task ChangeFileInDependency()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps")
                .WithSource();
 
            var dependencyDir = Path.Combine(testAsset.Path, "Dependency");
 
            App.Start(testAsset, [], "AppWithDeps");
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            var newSrc = """
                public class Lib
                {
                    public static void Print()
                        => System.Console.WriteLine("Changed!");
                }
                """;
 
            UpdateSourceFile(Path.Combine(dependencyDir, "Foo.cs"), newSrc);
 
            await App.AssertOutputLineStartsWith("Changed!");
        }
 
        [Fact]
        public async Task ProjectChange_UpdateDirectoryBuildPropsThenUpdateSource()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps")
                .WithSource();
 
            var dependencyDir = Path.Combine(testAsset.Path, "Dependency");
 
            App.Start(testAsset, [], "AppWithDeps");
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            UpdateSourceFile(
                Path.Combine(testAsset.Path, "Directory.Build.props"),
                src => src.Replace("<AllowUnsafeBlocks>false</AllowUnsafeBlocks>", "<AllowUnsafeBlocks>true</AllowUnsafeBlocks>"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.NoCSharpChangesToApply);
            App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation);
            App.Process.ClearOutput();
 
            var newSrc = """
                public class Lib
                {
                    public static unsafe void Print()
                    {
                        char c = '!';
                        char* pc = &c;
                        System.Console.WriteLine($"Changed{*pc}");
                    }
                }
                """;
 
            UpdateSourceFile(Path.Combine(dependencyDir, "Foo.cs"), newSrc);
 
            await App.AssertOutputLineStartsWith("Changed!");
            await App.WaitUntilOutputContains($"dotnet watch 🔥 [App.WithDeps ({ToolsetInfo.CurrentTargetFramework})] Hot reload succeeded.");
        }
 
        [Theory]
        [CombinatorialData]
        public async Task ProjectChange_Update(bool isDirectoryProps)
        {
            var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps", identifier: isDirectoryProps.ToString())
                .WithSource();
 
            var symbolName = isDirectoryProps ? "BUILD_CONST_IN_PROPS" : "BUILD_CONST_IN_CSPROJ";
 
            var dependencyDir = Path.Combine(testAsset.Path, "Dependency");
            var libSourcePath = Path.Combine(dependencyDir, "Foo.cs");
            var buildFilePath = isDirectoryProps ? Path.Combine(testAsset.Path, "Directory.Build.props") : Path.Combine(dependencyDir, "Dependency.csproj");
 
            File.WriteAllText(libSourcePath, $$"""
                public class Lib
                {
                    public static void Print()
                    {
                #if {{symbolName}}
                        System.Console.WriteLine("{{symbolName}} set");
                #else
                        System.Console.WriteLine("{{symbolName}} not set");
                #endif
                    }
                }
                """);
 
            App.Start(testAsset, ["--non-interactive"], "AppWithDeps");
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
            await App.WaitUntilOutputContains($"{symbolName} set");
            App.Process.ClearOutput();
 
            UpdateSourceFile(buildFilePath, src => src.Replace(symbolName, ""));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
            App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation);
            App.AssertOutputContains("dotnet watch ⌚ [auto-restart] error ENC1102: Changing project setting 'DefineConstants'");
 
            await App.WaitUntilOutputContains($"{symbolName} not set");
        }
 
        [Fact(Skip = "https://github.com/dotnet/msbuild/issues/12001")]
        public async Task ProjectChange_DirectoryBuildProps_Add()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps")
                .WithSource();
 
            var dependencyDir = Path.Combine(testAsset.Path, "Dependency");
            var libSourcePath = Path.Combine(dependencyDir, "Foo.cs");
            var directoryBuildProps = Path.Combine(testAsset.Path, "Directory.Build.props");
 
            // delete the file before we start the app, it will be added later:
            File.Delete(directoryBuildProps);
 
            File.WriteAllText(libSourcePath, """
                public class Lib
                {
                    public static void Print()
                    {
                #if BUILD_CONST_IN_PROPS
                        System.Console.WriteLine("BUILD_CONST_IN_PROPS set");
                #else                
                        System.Console.WriteLine("BUILD_CONST_IN_PROPS not set");
                #endif
                    }
                }
                """);
 
            App.Start(testAsset, [], "AppWithDeps");
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
            await App.WaitUntilOutputContains("BUILD_CONST_IN_PROPS set");
            App.Process.ClearOutput();
 
            UpdateSourceFile(
                directoryBuildProps,
                src => src.Replace("BUILD_CONST_IN_PROPS", ""));
 
            await App.WaitUntilOutputContains($"dotnet watch 🔥 [App.WithDeps ({ToolsetInfo.CurrentTargetFramework})] Hot reload succeeded.");
            await App.WaitUntilOutputContains("BUILD_CONST not set");
 
            App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation);
        }
 
        [Fact(Skip = "https://github.com/dotnet/sdk/issues/49545")]
        public async Task ProjectChange_DirectoryBuildProps_Delete()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps")
                .WithSource();
 
            var dependencyDir = Path.Combine(testAsset.Path, "Dependency");
            var libSourcePath = Path.Combine(dependencyDir, "Foo.cs");
            var directoryBuildProps = Path.Combine(testAsset.Path, "Directory.Build.props");
 
            File.WriteAllText(libSourcePath, """
                public class Lib
                {
                    public static void Print()
                    {
                #if BUILD_CONST_IN_PROPS
                        System.Console.WriteLine("BUILD_CONST_IN_PROPS set");
                #else
                        System.Console.WriteLine("BUILD_CONST_IN_PROPS not set");
                #endif
                    }
                }
                """);
 
            App.Start(testAsset, [], "AppWithDeps");
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
            await App.WaitUntilOutputContains("BUILD_CONST_IN_PROPS set");
 
            Log($"Deleting {directoryBuildProps}");
            File.Delete(directoryBuildProps);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.NoCSharpChangesToApply);
            App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation);
            App.Process.ClearOutput();
 
            await App.AssertOutputLineStartsWith("BUILD_CONST_IN_PROPS not set");
        }
 
        [Fact]
        public async Task DefaultItemExcludes_DefaultItemsEnabled()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
                .WithSource()
                .WithProjectChanges(project =>
                {
                    project.Root.Descendants()
                        .First(e => e.Name.LocalName == "PropertyGroup")
                        .Add(XElement.Parse("""
                            <DefaultItemExcludes>$(DefaultItemExcludes);AppData/**/*.*</DefaultItemExcludes>
                            """));
                });
 
            var appDataDir = Path.Combine(testAsset.Path, "AppData", "dir");
            var appDataFilePath = Path.Combine(appDataDir, "ShouldBeIgnored.cs");
 
            Directory.CreateDirectory(appDataDir);
 
            App.Start(testAsset, []);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
            App.AssertOutputContains(new Regex(@"dotnet watch ⌚ Exclusion glob: 'AppData/[*][*]/[*][.][*];bin[/\\]+Debug[/\\]+[*][*];obj[/\\]+Debug[/\\]+[*][*];bin[/\\]+[*][*];obj[/\\]+[*][*]"));
            App.Process.ClearOutput();
 
            UpdateSourceFile(appDataFilePath, """
            class X;
            """);
 
            await App.WaitUntilOutputContains($"dotnet watch ⌚ Ignoring change in excluded file '{appDataFilePath}': Add. Path matches DefaultItemExcludes glob 'AppData/**/*.*' set in '{testAsset.Path}'.");
        }
 
        [Fact]
        public async Task DefaultItemExcludes_DefaultItemsDisabled()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
                .WithSource()
                .WithProjectChanges(project =>
                {
                    project.Root.Descendants()
                        .First(e => e.Name.LocalName == "PropertyGroup")
                        .Add(XElement.Parse("""
                            <EnableDefaultItems>false</EnableDefaultItems>
                            """));
 
                    project.Root.Descendants()
                        .First(e => e.Name.LocalName == "ItemGroup")
                        .Add(XElement.Parse("""
                            <Compile Include="Program.cs" />
                            """));
                });
 
            var binDir = Path.Combine(testAsset.Path, "bin", "Debug", ToolsetInfo.CurrentTargetFramework);
            var binDirFilePath = Path.Combine(binDir, "ShouldBeIgnored.cs");
 
            var objDir = Path.Combine(testAsset.Path, "obj", "Debug", ToolsetInfo.CurrentTargetFramework);
            var objDirFilePath = Path.Combine(objDir, "ShouldBeIgnored.cs");
 
            Directory.CreateDirectory(binDir);
 
            App.Start(testAsset, []);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
            App.AssertOutputContains($"dotnet watch ⌚ Excluded directory: '{binDir}'");
            App.AssertOutputContains($"dotnet watch ⌚ Excluded directory: '{objDir}'");
            App.Process.ClearOutput();
 
            UpdateSourceFile(binDirFilePath, "class X;");
            UpdateSourceFile(objDirFilePath, "class X;");
 
            await App.WaitUntilOutputContains($"dotnet watch ⌚ Ignoring change in output directory: Add '{binDirFilePath}'");
            await App.WaitUntilOutputContains($"dotnet watch ⌚ Ignoring change in output directory: Add '{objDirFilePath}'");
        }
 
        [Fact]
        public async Task ProjectChange_GlobalUsings()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
                .WithSource();
 
            var programPath = Path.Combine(testAsset.Path, "Program.cs");
            var projectPath = Path.Combine(testAsset.Path, "WatchHotReloadApp.csproj");
 
            App.Start(testAsset, []);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            // missing System.Linq import:
            UpdateSourceFile(programPath, content => content.Replace("""
                Console.WriteLine(".");
                """,
                """
                Console.WriteLine($">>> {typeof(XDocument)}");
                """));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.UnableToApplyChanges);
 
            UpdateSourceFile(projectPath, content => content.Replace("""
                <!-- items placeholder -->
                """,
                """
                <Using Include="System.Xml.Linq" />
                """));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadSucceeded, $"WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})");
 
            await App.WaitUntilOutputContains(">>> System.Xml.Linq.XDocument");
 
            App.AssertOutputContains(MessageDescriptor.ReEvaluationCompleted);
        }
 
        [Fact]
        public async Task BinaryLogs()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
                .WithSource();
 
            var projectPath = Path.Combine(testAsset.Path, "WatchHotReloadApp.csproj");
            var logDir = Path.Combine(testAsset.Path, "logs");
            var binLogPath = Path.Combine(logDir, "Test.binlog");
            var binLogPathBase = Path.ChangeExtension(binLogPath, "").TrimEnd('.');
 
            Assert.False(Directory.Exists(logDir));
 
            App.DotnetWatchArgs.Clear();
            App.Start(testAsset, ["--verbose", $"-bl:{binLogPath}"], testFlags: TestFlags.None);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            var expectedLogs = new List<string>()
            {
                // dotnet build log
                binLogPath,
                // dotnet run log
                binLogPathBase + "-dotnet-run.binlog",
                // initial DTB:
                binLogPathBase + "-dotnet-watch.DesignTimeBuild.WatchHotReloadApp.csproj.1.binlog"
            };
 
            VerifyExpectedLogFiles();
 
            UpdateSourceFile(projectPath, content => content.Replace("""
                <!-- items placeholder -->
                """,
                """
                <Using Include="System.Xml.Linq" />
                """));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.ReEvaluationCompleted);
 
            // project update triggered restore and DTB:
            expectedLogs.Add(binLogPathBase + "-dotnet-watch.Restore.WatchHotReloadApp.csproj.2.binlog");
            expectedLogs.Add(binLogPathBase + "-dotnet-watch.DesignTimeBuild.WatchHotReloadApp.csproj.3.binlog");
 
            VerifyExpectedLogFiles();
 
            void VerifyExpectedLogFiles()
            {
                AssertEx.SequenceEqual(
                    expectedLogs.Order(),
                    Directory.EnumerateFileSystemEntries(logDir, "*.*", SearchOption.AllDirectories).Order());
            }
        }
 
        [Theory]
        [CombinatorialData]
        public async Task AutoRestartOnRudeEdit(bool nonInteractive)
        {
            var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
                .WithSource();
 
            if (!nonInteractive)
            {
                testAsset = testAsset
                    .WithProjectChanges(project =>
                    {
                        project.Root.Descendants()
                            .First(e => e.Name.LocalName == "PropertyGroup")
                            .Add(XElement.Parse("""
                                <HotReloadAutoRestart>true</HotReloadAutoRestart>
                                """));
                    });
            }
 
            var programPath = Path.Combine(testAsset.Path, "Program.cs");
 
            App.Start(testAsset, nonInteractive ? ["--non-interactive"] : []);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            // rude edit: adding virtual method
            UpdateSourceFile(programPath, src => src.Replace("/* member placeholder */", "public virtual void F() {}"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            App.AssertOutputContains(MessageDescriptor.RestartNeededToApplyChanges);
            App.AssertOutputContains($"⌚ [auto-restart] {programPath}(38,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application.");
            App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited");
            App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched");
            App.Process.ClearOutput();
 
            // valid edit:
            UpdateSourceFile(programPath, src => src.Replace("public virtual void F() {}", "public virtual void F() { Console.WriteLine(1); }"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadSucceeded);
        }
 
        [Fact]
        public async Task AutoRestartOnRudeEditAfterRestartPrompt()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
                .WithSource();
 
            var programPath = Path.Combine(testAsset.Path, "Program.cs");
 
            App.Start(testAsset, [], testFlags: TestFlags.ReadKeyFromStdin);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
            App.Process.ClearOutput();
 
            // rude edit: adding virtual method
            UpdateSourceFile(programPath, src => src.Replace("/* member placeholder */", "public virtual void F() {}"));
 
            await App.AssertOutputLineStartsWith("  ❔ Do you want to restart your app? Yes (y) / No (n) / Always (a) / Never (v)", failure: _ => false);
 
            App.AssertOutputContains(MessageDescriptor.RestartNeededToApplyChanges);
            App.AssertOutputContains($"{programPath}(38,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application.");
            App.Process.ClearOutput();
 
            App.SendKey('a');
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited");
            App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched");
            App.Process.ClearOutput();
 
            // rude edit: deleting virtual method
            UpdateSourceFile(programPath, src => src.Replace("public virtual void F() {}", ""));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            App.AssertOutputContains(MessageDescriptor.RestartNeededToApplyChanges);
            App.AssertOutputContains($"⌚ [auto-restart] {programPath}(38,1): error ENC0033: Deleting method 'F()' requires restarting the application.");
            App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited");
            App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched");
        }
 
        [Theory]
        [CombinatorialData]
        public async Task AutoRestartOnNoEffectEdit(bool nonInteractive)
        {
            var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
                .WithSource();
 
            if (!nonInteractive)
            {
                testAsset = testAsset
                    .WithProjectChanges(project =>
                    {
                        project.Root.Descendants()
                            .First(e => e.Name.LocalName == "PropertyGroup")
                            .Add(XElement.Parse("""
                                <HotReloadAutoRestart>true</HotReloadAutoRestart>
                                """));
                    });
            }
 
            var programPath = Path.Combine(testAsset.Path, "Program.cs");
 
            App.Start(testAsset, nonInteractive ? ["--non-interactive"] : []);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
            App.Process.ClearOutput();
 
            // top-level code change:
            UpdateSourceFile(programPath, src => src.Replace("Started", "<Updated>"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            App.AssertOutputContains(MessageDescriptor.RestartNeededToApplyChanges);
            App.AssertOutputContains($"⌚ [auto-restart] {programPath}(16,19): warning ENC0118: Changing 'top-level code' might not have any effect until the application is restarted.");
            App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited");
            App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched");
            App.AssertOutputContains("<Updated>");
            App.Process.ClearOutput();
 
            // valid edit:
            UpdateSourceFile(programPath, src => src.Replace("/* member placeholder */", "public void F() {}"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadSucceeded);
        }
 
        /// <summary>
        /// Unchanged project doesn't build. Wait for source change and rebuild.
        /// </summary>
        [Fact]
        public async Task BaselineCompilationError()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchNoDepsApp")
                .WithSource();
 
            var programPath = Path.Combine(testAsset.Path, "Program.cs");
            File.WriteAllText(programPath,
                """
                Console.Write
                """);
 
            App.Start(testAsset, []);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForFileChangeBeforeRestarting);
 
            UpdateSourceFile(programPath, """
                System.Console.WriteLine("<Updated>");
                """);
 
            await App.WaitUntilOutputContains("<Updated>");
        }
 
        [Fact]
        public async Task ChangeFileInFSharpProject()
        {
            var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple")
                .WithSource();
 
            App.Start(testAsset, []);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForFileChangeBeforeRestarting);
 
            UpdateSourceFile(Path.Combine(testAsset.Path, "Program.fs"), content => content.Replace("Hello World!", "<Updated>"));
 
            await App.WaitUntilOutputContains("<Updated>");
        }
 
        [Fact]
        public async Task ChangeFileInFSharpProjectWithLoop()
        {
            var testAsset = TestAssets.CopyTestAsset("FSharpTestAppSimple")
                .WithSource();
 
            var source = """
            module ConsoleApplication.Program
 
            open System
            open System.Threading
 
            [<EntryPoint>]
            let main argv =
                while true do
                    printfn "Waiting"
                    Thread.Sleep(200)
                0
            """;
 
            var sourcePath = Path.Combine(testAsset.Path, "Program.fs");
 
            File.WriteAllText(sourcePath, source);
 
            App.Start(testAsset, []);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            UpdateSourceFile(sourcePath, content => content.Replace("Waiting", "<Updated>"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
            await App.AssertOutputLineStartsWith("<Updated>");
 
            UpdateSourceFile(sourcePath, content => content.Replace("<Updated>", "<Updated2>"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
            await App.AssertOutputLineStartsWith("<Updated2>");
        }
 
        // Test is timing out on .NET Framework: https://github.com/dotnet/sdk/issues/41669
        [CoreMSBuildOnlyFact]
        public async Task HandleTypeLoadFailure()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchAppTypeLoadFailure")
                .WithSource();
 
            App.Start(testAsset, [], "App");
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            var newSrc = """
                class DepSubType : Dep
                {
                    int F() => 2;
                }
 
                class Printer
                {
                    public static void Print()
                    {
                        Console.WriteLine("Changed!");
                    }
                }
                """;
 
            UpdateSourceFile(Path.Combine(testAsset.Path, "App", "Update.cs"), newSrc);
 
            await App.AssertOutputLineStartsWith("Updated types: Printer");
        }
 
        [Fact]
        public async Task MetadataUpdateHandler_NoActions()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
                .WithSource();
 
            var sourcePath = Path.Combine(testAsset.Path, "Program.cs");
 
            var source = File.ReadAllText(sourcePath, Encoding.UTF8)
                .Replace("// <metadata update handler placeholder>", """
                [assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(AppUpdateHandler))]
                """)
                + """
                class AppUpdateHandler
                {
                }
                """;
 
            File.WriteAllText(sourcePath, source, Encoding.UTF8);
 
            App.Start(testAsset, []);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            UpdateSourceFile(sourcePath, source.Replace("Console.WriteLine(\".\");", "Console.WriteLine(\"<Updated>\");"));
 
            await App.WaitForOutputLineContaining("<Updated>");
 
            await App.WaitUntilOutputContains(
                $"dotnet watch ⚠ [WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Expected to find a static method 'ClearCache', 'UpdateApplication' or 'UpdateContent' on type 'AppUpdateHandler, WatchHotReloadApp, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' but neither exists.");
        }
 
        [Theory]
        [CombinatorialData]
        public async Task MetadataUpdateHandler_Exception(bool verbose)
        {
            var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp", identifier: verbose.ToString())
                .WithSource();
 
            var sourcePath = Path.Combine(testAsset.Path, "Program.cs");
 
            var source = File.ReadAllText(sourcePath, Encoding.UTF8)
                .Replace("// <metadata update handler placeholder>", """
                [assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(AppUpdateHandler))]
                """)
                + """
                class AppUpdateHandler
                {
                    public static void ClearCache(Type[] types) => throw new System.InvalidOperationException("Bug!");
                }
                """;
 
            File.WriteAllText(sourcePath, source, Encoding.UTF8);
 
            if (!verbose)
            {
                // remove default --verbose arg
                App.DotnetWatchArgs.Clear();
            }
 
            App.Start(testAsset, [], testFlags: TestFlags.ElevateWaitingForChangesMessageSeverity);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            UpdateSourceFile(sourcePath, source.Replace("Console.WriteLine(\".\");", "Console.WriteLine(\"<Updated>\");"));
 
            await App.WaitForOutputLineContaining("<Updated>");
 
            await App.WaitUntilOutputContains($"dotnet watch ⚠ [WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exception from 'AppUpdateHandler.ClearCache': System.InvalidOperationException: Bug!");
 
            if (verbose)
            {
                await App.WaitUntilOutputContains(MessageDescriptor.UpdatesApplied);
            }
            else
            {
                // shouldn't see any agent messages:
                App.AssertOutputDoesNotContain("🕵️");
            }
        }
 
        [PlatformSpecificFact(TestPlatforms.Windows)]
        public async Task GracefulTermination_Windows()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
               .WithSource();
 
            var programPath = Path.Combine(testAsset.Path, "Program.cs");
 
            UpdateSourceFile(programPath, src => src.Replace("// <metadata update handler placeholder>", """
                Console.CancelKeyPress += (sender, e) =>
                {
                    e.Cancel = true;
                    Console.WriteLine("Ctrl+C detected! Performing cleanup...");
                    Environment.Exit(0);
                };
                """));
 
            App.Start(testAsset, [], testFlags: TestFlags.ReadKeyFromStdin);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            await App.WaitUntilOutputContains(new Regex(@"dotnet watch 🕵️ \[.*\] Windows Ctrl\+C handling enabled."));
 
            await App.WaitUntilOutputContains("Started");
 
            App.SendControlC();
 
            await App.WaitForOutputLineContaining("Ctrl+C detected! Performing cleanup...");
            await App.WaitUntilOutputContains("exited with exit code 0.");
        }
 
        [PlatformSpecificTheory(TestPlatforms.Windows, Skip = "https://github.com/dotnet/sdk/issues/49928")] // https://github.com/dotnet/sdk/issues/49307
        [CombinatorialData]
        public async Task BlazorWasm(bool projectSpecifiesCapabilities)
        {
            var testAsset = TestAssets.CopyTestAsset("WatchBlazorWasm", identifier: projectSpecifiesCapabilities.ToString())
                .WithSource();
 
            if (projectSpecifiesCapabilities)
            {
                testAsset = testAsset.WithProjectChanges(proj =>
                {
                    proj.Root.Descendants()
                        .First(e => e.Name.LocalName == "PropertyGroup")
                        .Add(XElement.Parse("""
                            <WebAssemblyHotReloadCapabilities>Baseline;AddMethodToExistingType</WebAssemblyHotReloadCapabilities>
                            """));
                });
            }
 
            var port = TestOptions.GetTestPort();
            App.Start(testAsset, ["--urls", "http://localhost:" + port], testFlags: TestFlags.MockBrowser);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            App.AssertOutputContains(MessageDescriptor.ConfiguredToUseBrowserRefresh);
            App.AssertOutputContains(MessageDescriptor.ConfiguredToLaunchBrowser);
 
            // Browser is launched based on blazor-devserver output "Now listening on: ...".
            await App.WaitUntilOutputContains($"dotnet watch ⌚ Launching browser: http://localhost:{port}");
 
            // Middleware should have been loaded to blazor-devserver before the browser is launched:
            App.AssertOutputContains("dbug: Microsoft.AspNetCore.Watch.BrowserRefresh.BlazorWasmHotReloadMiddleware[0]");
            App.AssertOutputContains("dbug: Microsoft.AspNetCore.Watch.BrowserRefresh.BrowserScriptMiddleware[0]");
            App.AssertOutputContains("Middleware loaded. Script /_framework/aspnetcore-browser-refresh.js");
            App.AssertOutputContains("Middleware loaded. Script /_framework/blazor-hotreload.js");
            App.AssertOutputContains("dbug: Microsoft.AspNetCore.Watch.BrowserRefresh.BrowserRefreshMiddleware");
            App.AssertOutputContains("Middleware loaded: DOTNET_MODIFIABLE_ASSEMBLIES=debug, __ASPNETCORE_BROWSER_TOOLS=true");
 
            // shouldn't see any agent messages (agent is not loaded into blazor-devserver):
            App.AssertOutputDoesNotContain("🕵️");
 
            var newSource = """
                @page "/"
                <h1>Updated</h1>
                """;
 
            UpdateSourceFile(Path.Combine(testAsset.Path, "Pages", "Index.razor"), newSource);
            await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadSucceeded, $"blazorwasm ({ToolsetInfo.CurrentTargetFramework})");
 
            // check project specified capapabilities:
            if (projectSpecifiesCapabilities)
            {
                App.AssertOutputContains("dotnet watch 🔥 Hot reload capabilities: Baseline AddMethodToExistingType.");
            }
            else
            {
                App.AssertOutputContains("dotnet watch 🔥 Hot reload capabilities: Baseline AddMethodToExistingType AddStaticFieldToExistingType NewTypeDefinition ChangeCustomAttributes AddInstanceFieldToExistingType GenericAddMethodToExistingType GenericUpdateMethod UpdateParameters GenericAddFieldToExistingType.");
            }
        }
 
        [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307")
        public async Task BlazorWasm_MSBuildWarning()
        {
            var testAsset = TestAssets
                .CopyTestAsset("WatchBlazorWasm")
                .WithSource()
                .WithProjectChanges(proj =>
                {
                    proj.Root.Descendants()
                        .Single(e => e.Name.LocalName == "ItemGroup")
                        .Add(XElement.Parse("""
                            <AdditionalFiles Include="Pages\Index.razor" />
                            """));
                });
 
            var port = TestOptions.GetTestPort();
            App.Start(testAsset, ["--urls", "http://localhost:" + port], testFlags: TestFlags.MockBrowser);
 
            await App.AssertOutputLineStartsWith("dotnet watch ⚠ msbuild: [Warning] Duplicate source file");
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
        }
 
        [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307")
        public async Task BlazorWasm_Restart()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchBlazorWasm")
                .WithSource();
 
            var port = TestOptions.GetTestPort();
            App.Start(testAsset, ["--urls", "http://localhost:" + port, "--non-interactive"], testFlags: TestFlags.ReadKeyFromStdin | TestFlags.MockBrowser);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            App.AssertOutputContains(MessageDescriptor.ConfiguredToUseBrowserRefresh);
            App.AssertOutputContains(MessageDescriptor.ConfiguredToLaunchBrowser);
            App.AssertOutputContains(MessageDescriptor.PressCtrlRToRestart);
 
            // Browser is launched based on blazor-devserver output "Now listening on: ...".
            await App.WaitUntilOutputContains($"dotnet watch ⌚ Launching browser: http://localhost:{port}");
 
            App.SendControlR();
 
            await App.WaitUntilOutputContains(MessageDescriptor.ReloadingBrowser);
        }
 
        [PlatformSpecificFact(TestPlatforms.Windows, Skip = "https://github.com/dotnet/sdk/issues/49928")] // "https://github.com/dotnet/sdk/issues/49307")
        public async Task BlazorWasmHosted()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchBlazorWasmHosted")
                .WithSource();
 
            var tfm = ToolsetInfo.CurrentTargetFramework;
 
            var port = TestOptions.GetTestPort();
            App.Start(testAsset, ["--urls", "http://localhost:" + port], "blazorhosted", testFlags: TestFlags.MockBrowser);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            App.AssertOutputContains(MessageDescriptor.ConfiguredToUseBrowserRefresh);
            App.AssertOutputContains(MessageDescriptor.ConfiguredToLaunchBrowser);
            App.AssertOutputContains(MessageDescriptor.ApplicationKind_BlazorHosted);
 
            // client capabilities:
            App.AssertOutputContains($"dotnet watch ⌚ [blazorhosted ({tfm})] Project 'blazorwasm ({tfm})' specifies capabilities: 'Baseline AddMethodToExistingType AddStaticFieldToExistingType NewTypeDefinition ChangeCustomAttributes AddInstanceFieldToExistingType GenericAddMethodToExistingType GenericUpdateMethod UpdateParameters GenericAddFieldToExistingType'");
 
            // server capabilities:
            App.AssertOutputContains($"dotnet watch ⌚ [blazorhosted ({tfm})] Capabilities: 'Baseline AddMethodToExistingType AddStaticFieldToExistingType AddInstanceFieldToExistingType NewTypeDefinition ChangeCustomAttributes UpdateParameters GenericUpdateMethod GenericAddMethodToExistingType GenericAddFieldToExistingType AddFieldRva'");
        }
 
        [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307")
        public async Task Razor_Component_ScopedCssAndStaticAssets()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchRazorWithDeps")
                .WithSource();
 
            var port = TestOptions.GetTestPort();
            App.Start(testAsset, ["--urls", "http://localhost:" + port], relativeProjectDirectory: "RazorApp", testFlags: TestFlags.MockBrowser);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            App.AssertOutputContains(MessageDescriptor.ConfiguredToUseBrowserRefresh);
            App.AssertOutputContains(MessageDescriptor.ConfiguredToLaunchBrowser);
            App.AssertOutputContains($"dotnet watch ⌚ Launching browser: http://localhost:{port}");
            App.Process.ClearOutput();
 
            var scopedCssPath = Path.Combine(testAsset.Path, "RazorClassLibrary", "Components", "Example.razor.css");
 
            var newCss = """
                .example {
                    color: blue;
                }
                """;
 
            UpdateSourceFile(scopedCssPath, newCss);
            await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadChangeHandled);
 
            App.AssertOutputContains(MessageDescriptor.SendingStaticAssetUpdateRequest.GetMessage("RazorApp.css"));
            App.AssertOutputContains(MessageDescriptor.HotReloadOfScopedCssSucceeded);
            App.AssertOutputContains(MessageDescriptor.NoCSharpChangesToApply);
            App.Process.ClearOutput();
 
            var cssPath = Path.Combine(testAsset.Path, "RazorApp", "wwwroot", "app.css");
            UpdateSourceFile(cssPath, content => content.Replace("background-color: white;", "background-color: red;"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadChangeHandled);
 
            // "wwwroot" directory is required for MAUI. Web sites work with or without it.
            App.AssertOutputContains(MessageDescriptor.SendingStaticAssetUpdateRequest.GetMessage("wwwroot/app.css"));
            App.AssertOutputContains(MessageDescriptor.HotReloadOfStaticAssetsSucceeded);
            App.AssertOutputContains(MessageDescriptor.NoCSharpChangesToApply);
            App.Process.ClearOutput();
        }
 
        /// <summary>
        /// Currently only works on Windows.
        /// Add TestPlatforms.OSX once https://github.com/dotnet/sdk/issues/45521 is fixed.
        /// </summary>
        [PlatformSpecificFact(TestPlatforms.Windows, Skip = "https://github.com/dotnet/sdk/issues/40006")]
        public async Task MauiBlazor()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchMauiBlazor")
                .WithSource();
 
            var workloadInstallCommandSpec = new DotnetCommand(Logger, ["workload", "install", "maui", "--include-previews"])
            {
                WorkingDirectory = testAsset.Path,
            };
 
            var result = workloadInstallCommandSpec.Execute();
            Assert.Equal(0, result.ExitCode);
 
            var platform = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows10.0.19041.0" : "maccatalyst";
            var tfm = $"{ToolsetInfo.CurrentTargetFramework}-{platform}";
            App.Start(testAsset, ["-f", tfm]);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            // update code file:
            var razorPath = Path.Combine(testAsset.Path, "Components", "Pages", "Home.razor");
            UpdateSourceFile(razorPath, content => content.Replace("Hello, world!", "Updated"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadChangeHandled);
 
            // TODO: Warning is currently reported because UpdateContent is not recognized
            App.AssertOutputContains("Updates applied: 1 out of 1.");
            App.AssertOutputContains("Microsoft.AspNetCore.Components.HotReload.HotReloadManager.UpdateApplication");
            App.Process.ClearOutput();
 
            // update static asset:
            var cssPath = Path.Combine(testAsset.Path, "wwwroot", "css", "app.css");
            UpdateSourceFile(cssPath, content => content.Replace("background-color: white;", "background-color: red;"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadChangeHandled);
            App.AssertOutputContains("Updates applied: 1 out of 1.");
            App.AssertOutputContains("Microsoft.AspNetCore.Components.WebView.StaticContentHotReloadManager.UpdateContent");
            App.AssertOutputContains("No C# changes to apply.");
        }
 
        // Test is timing out on .NET Framework: https://github.com/dotnet/sdk/issues/41669
        [CoreMSBuildOnlyFact]
        public async Task HandleMissingAssemblyFailure()
        {
            var testAsset = TestAssets.CopyTestAsset("WatchAppMissingAssemblyFailure")
                .WithSource();
 
            App.Start(testAsset, [], "App");
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            var newSrc = /* lang=c#-test */"""
                using System;
 
                public class DepType
                {
                    int F() => 1;
                }
 
                public class Printer
                {
                    public static void Print()
                        => Console.WriteLine("Updated!");
                }
                """;
 
            // Delete all files in testAsset.Path named Dep.dll
            foreach (var depDll in Directory.GetFiles(testAsset.Path, "Dep2.dll", SearchOption.AllDirectories))
            {
                File.Delete(depDll);
            }
 
            File.WriteAllText(Path.Combine(testAsset.Path, "App", "Update.cs"), newSrc);
 
            await App.AssertOutputLineStartsWith("Updated types: Printer");
        }
 
        [Theory]
        [InlineData(true, Skip = "https://github.com/dotnet/sdk/issues/43320")]
        [InlineData(false)]
        public async Task RenameSourceFile(bool useMove)
        {
            Log("RenameSourceFile started");
 
            var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps")
                .WithSource();
 
            var dependencyDir = Path.Combine(testAsset.Path, "Dependency");
            var oldFilePath = Path.Combine(dependencyDir, "Foo.cs");
            var newFilePath = Path.Combine(dependencyDir, "Renamed.cs");
 
            var source = """
                using System;
                using System.IO;
                using System.Runtime.CompilerServices;
 
                public class Lib
                {
                    public static void Print() => PrintFileName();
 
                    public static void PrintFileName([CallerFilePathAttribute] string filePath = null)
                    {
                        Console.WriteLine($"> {Path.GetFileName(filePath)}");
                    }
                }
                """;
 
            File.WriteAllText(oldFilePath, source);
 
            App.Start(testAsset, [], "AppWithDeps");
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            // rename the file:
            if (useMove)
            {
                File.Move(oldFilePath, newFilePath);
            }
            else
            {
                File.Delete(oldFilePath);
                File.WriteAllText(newFilePath, source);
            }
 
            Log($"Renamed '{oldFilePath}' to '{newFilePath}'.");
 
            await App.AssertOutputLineStartsWith("> Renamed.cs");
        }
 
        [Theory]
        [InlineData(true, Skip = "https://github.com/dotnet/sdk/issues/43320")]
        [InlineData(false)]
        public async Task RenameDirectory(bool useMove)
        {
            Log("RenameSourceFile started");
 
            var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps")
                .WithSource();
 
            var dependencyDir = Path.Combine(testAsset.Path, "Dependency");
            var oldSubdir = Path.Combine(dependencyDir, "Subdir");
            var newSubdir = Path.Combine(dependencyDir, "NewSubdir");
 
            var source = """
                using System;
                using System.IO;
                using System.Runtime.CompilerServices;
 
                public class Lib
                {
                    public static void Print() => PrintDirectoryName();
 
                    public static void PrintDirectoryName([CallerFilePathAttribute] string filePath = null)
                    {
                        Console.WriteLine($"> {Path.GetFileName(Path.GetDirectoryName(filePath))}");
                    }
                }
                """;
 
            File.Delete(Path.Combine(dependencyDir, "Foo.cs"));
            Directory.CreateDirectory(oldSubdir);
            File.WriteAllText(Path.Combine(oldSubdir, "Foo.cs"), source);
 
            App.Start(testAsset, ["--non-interactive"], "AppWithDeps");
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            // rename the directory:
            if (useMove)
            {
                Directory.Move(oldSubdir, newSubdir);
            }
            else
            {
                Directory.Delete(oldSubdir, recursive: true);
                Directory.CreateDirectory(newSubdir);
                File.WriteAllText(Path.Combine(newSubdir, "Foo.cs"), source);
            }
 
            Log($"Renamed '{oldSubdir}' to '{newSubdir}'.");
 
            // dotnet-watch may observe the delete separately from the new file write.
            // If so, rude edit is reported, the app is auto-restarted and we should observe the final result.
 
            await App.AssertOutputLineStartsWith("> NewSubdir", failure: _ => false);
        }
 
        [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307")
        public async Task Aspire_BuildError_ManualRestart()
        {
            var tfm = ToolsetInfo.CurrentTargetFramework;
            var testAsset = TestAssets.CopyTestAsset("WatchAspire")
                .WithSource();
 
            var serviceSourcePath = Path.Combine(testAsset.Path, "WatchAspire.ApiService", "Program.cs");
            var serviceProjectPath = Path.Combine(testAsset.Path, "WatchAspire.ApiService", "WatchAspire.ApiService.csproj");
            var serviceSource = File.ReadAllText(serviceSourcePath, Encoding.UTF8);
 
            var webSourcePath = Path.Combine(testAsset.Path, "WatchAspire.Web", "Program.cs");
            var webProjectPath = Path.Combine(testAsset.Path, "WatchAspire.Web", "WatchAspire.Web.csproj");
 
            App.Start(testAsset, ["-lp", "http"], relativeProjectDirectory: "WatchAspire.AppHost", testFlags: TestFlags.ReadKeyFromStdin);
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            // check that Aspire server output is logged via dotnet-watch reporter:
            await App.WaitUntilOutputContains("dotnet watch ⭐ Now listening on:");
 
            // wait until after DCP session started:
            await App.WaitUntilOutputContains("dotnet watch ⭐ Session started: #1");
 
            // working directory of the service should be it's project directory:
            await App.WaitUntilOutputContains($"ApiService working directory: '{Path.GetDirectoryName(serviceProjectPath)}'");
 
            // Service -- valid code change:
            UpdateSourceFile(
                serviceSourcePath,
                serviceSource.Replace("Enumerable.Range(1, 5)", "Enumerable.Range(1, 10)"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadChangeHandled);
 
            App.AssertOutputContains("Using Aspire process launcher.");
            App.AssertOutputContains(MessageDescriptor.HotReloadSucceeded, $"WatchAspire.AppHost ({tfm})");
            App.AssertOutputContains(MessageDescriptor.HotReloadSucceeded, $"WatchAspire.ApiService ({tfm})");
            App.AssertOutputContains(MessageDescriptor.HotReloadSucceeded, $"WatchAspire.Web ({tfm})");
 
            // Only one browser should be launched (dashboard). The child process shouldn't launch a browser.
            Assert.Equal(1, App.Process.Output.Count(line => line.StartsWith("dotnet watch ⌚ Launching browser: ")));
            App.Process.ClearOutput();
 
            // rude edit with build error:
            UpdateSourceFile(
                serviceSourcePath,
                serviceSource.Replace("record WeatherForecast", "record WeatherForecast2"));
 
            await App.WaitForOutputLineContaining("  ❔ Do you want to restart these projects? Yes (y) / No (n) / Always (a) / Never (v)");
 
            App.AssertOutputContains(MessageDescriptor.RestartNeededToApplyChanges);
            App.AssertOutputContains($"dotnet watch ❌ {serviceSourcePath}(40,1): error ENC0020: Renaming record 'WeatherForecast' requires restarting the application.");
            App.AssertOutputContains("dotnet watch ⌚ Affected projects:");
            App.AssertOutputContains("dotnet watch ⌚   WatchAspire.ApiService");
            App.Process.ClearOutput();
 
            App.SendKey('y');
 
            await App.WaitForOutputLineContaining(MessageDescriptor.FixBuildError);
 
            App.AssertOutputContains("Application is shutting down...");
 
            // We don't have means to gracefully terminate process on Windows, see https://github.com/dotnet/runtime/issues/109432
            App.AssertOutputContains($"[WatchAspire.ApiService ({tfm})] Exited");
            App.AssertOutputContains(new Regex(@"dotnet watch ⌚ \[WatchAspire.ApiService \(net.*\)\] Process id [0-9]+ ran for [0-9]+ms and exited with exit code 0"));
 
            App.AssertOutputContains(MessageDescriptor.Building.GetMessage(serviceProjectPath));
            App.AssertOutputContains("error CS0246: The type or namespace name 'WeatherForecast' could not be found");
            App.Process.ClearOutput();
 
            // fix build error:
            UpdateSourceFile(
                serviceSourcePath,
                serviceSource.Replace("WeatherForecast", "WeatherForecast2"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.Capabilities, $"WatchAspire.ApiService ({tfm})");
 
            App.AssertOutputContains(MessageDescriptor.BuildSucceeded.GetMessage(serviceProjectPath));
            App.AssertOutputContains(MessageDescriptor.ProjectsRebuilt);
            App.AssertOutputContains($"dotnet watch ⭐ Starting project: {serviceProjectPath}");
 
            App.SendControlC();
 
            await App.WaitForOutputLineContaining(MessageDescriptor.ShutdownRequested);
 
            await App.WaitUntilOutputContains($"[WatchAspire.ApiService ({tfm})] Exited");
            await App.WaitUntilOutputContains($"[WatchAspire.AppHost ({tfm})] Exited");
            await App.WaitUntilOutputContains(new Regex(@"dotnet watch ⌚ \[WatchAspire.ApiService \(net.*\)\] Process id [0-9]+ ran for [0-9]+ms and exited with exit code 0"));
            await App.WaitUntilOutputContains(new Regex(@"dotnet watch ⌚ \[WatchAspire.AppHost \(net.*\)\] Process id [0-9]+ ran for [0-9]+ms and exited with exit code 0"));
 
            await App.WaitUntilOutputContains("dotnet watch ⭐ Waiting for server to shutdown ...");
 
            App.AssertOutputContains("dotnet watch ⭐ Stop session #1");
            App.AssertOutputContains("dotnet watch ⭐ [#1] Sending 'sessionTerminated'");
        }
 
        [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307")
        public async Task Aspire_NoEffect_AutoRestart()
        {
            var tfm = ToolsetInfo.CurrentTargetFramework;
            var testAsset = TestAssets.CopyTestAsset("WatchAspire")
                .WithSource();
 
            var webSourcePath = Path.Combine(testAsset.Path, "WatchAspire.Web", "Program.cs");
            var webProjectPath = Path.Combine(testAsset.Path, "WatchAspire.Web", "WatchAspire.Web.csproj");
            var webSource = File.ReadAllText(webSourcePath, Encoding.UTF8);
 
            App.Start(testAsset, ["-lp", "http", "--non-interactive"], relativeProjectDirectory: "WatchAspire.AppHost");
 
            await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges);
 
            // wait until after DCP sessions have been started for all projects:
            await App.WaitUntilOutputContains("dotnet watch ⭐ Session started: #2");
            App.Process.ClearOutput();
 
            // no-effect edit:
            UpdateSourceFile(webSourcePath, src => src.Replace("/* top-level placeholder */", "builder.Services.AddRazorComponents();"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadChangeHandled);
            await App.WaitUntilOutputContains("dotnet watch ⭐ Session started: #2");
 
            App.AssertOutputContains(MessageDescriptor.ProjectsRestarted.GetMessage(1));
            App.AssertOutputDoesNotContain("⚠");
 
            App.Process.ClearOutput();
 
            // lambda body edit:
            UpdateSourceFile(webSourcePath, src => src.Replace("Hello world!", "<Updated>"));
 
            await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadChangeHandled);
            App.AssertOutputContains($"dotnet watch 🕵️ [WatchAspire.Web ({tfm})] Updates applied.");
            App.AssertOutputDoesNotContain("Projects rebuilt");
            App.AssertOutputDoesNotContain("Projects restarted");
            App.AssertOutputDoesNotContain("⚠");
        }
    }
}