Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,408 changes: 0 additions & 1,408 deletions test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs

This file was deleted.

176 changes: 176 additions & 0 deletions test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// 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
{
Comment thread
tmat marked this conversation as resolved.
public class AspireHotReloadTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger)
{
[PlatformSpecificFact(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/53058, https://github.com/dotnet/sdk/issues/53061, https://github.com/dotnet/sdk/issues/53114
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.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges);

// check that Aspire server output is logged via dotnet-watch reporter:
await App.WaitUntilOutputContains("dotnet watch ⭐ Now listening on:");

// wait until after all DCP sessions have started:
await App.WaitUntilOutputContains("dotnet watch ⭐ [#1] Session started");
await App.WaitUntilOutputContains("dotnet watch ⭐ [#2] Session started");
await App.WaitUntilOutputContains("dotnet watch ⭐ [#3] Session started");

// MigrationService terminated:
await App.WaitUntilOutputContains("dotnet watch ⭐ [#1] Sending 'sessionTerminated'");

// working directory of the service should be its 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.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied);

await App.WaitUntilOutputContains("Using Aspire process launcher.");

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

// the prompt is printed into stdout while the error is printed into stderr, so they might arrive in any order:
await App.WaitUntilOutputContains(" ❔ Do you want to restart these projects? Yes (y) / No (n) / Always (a) / Never (v)");
await App.WaitUntilOutputContains(MessageDescriptor.RestartNeededToApplyChanges);

await App.WaitUntilOutputContains($"dotnet watch ❌ {serviceSourcePath}(40,1): error ENC0020: Renaming record 'WeatherForecast' requires restarting the application.");
await App.WaitUntilOutputContains("dotnet watch ⌚ Affected projects:");
await App.WaitUntilOutputContains("dotnet watch ⌚ WatchAspire.ApiService");
App.Process.ClearOutput();

App.SendKey('y');

await App.WaitUntilOutputContains(MessageDescriptor.FixBuildError);

await App.WaitUntilOutputContains("Application is shutting down...");

await App.WaitUntilOutputContains($"[WatchAspire.ApiService ({tfm})] Exited");

await App.WaitUntilOutputContains(MessageDescriptor.Building.GetMessage(serviceProjectPath));
await App.WaitUntilOutputContains("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.WaitUntilOutputContains(MessageDescriptor.ProjectsRestarted.GetMessage(1));

await App.WaitUntilOutputContains(MessageDescriptor.BuildSucceeded.GetMessage(serviceProjectPath));
await App.WaitUntilOutputContains(MessageDescriptor.ProjectsRebuilt);
await App.WaitUntilOutputContains($"Starting: '{serviceProjectPath}'");

// Wait for the process to start before shutting down, so we can reliably verify Exited message below.
// The agent startup hook might not be initialized yet (signal handlers registered),
// so the process might need to be forcefully killed. We could wait until the agent is initialized
// but it's good to test this scenario.
await App.WaitUntilOutputContains(MessageDescriptor.LaunchedProcess, $"WatchAspire.ApiService ({tfm})");

App.Process.ClearOutput();

App.SendControlC();

await App.WaitUntilOutputContains(MessageDescriptor.ShutdownRequested);

await App.WaitUntilOutputContains($"[WatchAspire.ApiService ({tfm})] Exited");
await App.WaitUntilOutputContains($"[WatchAspire.Web ({tfm})] Exited");
await App.WaitUntilOutputContains($"[WatchAspire.AppHost ({tfm})] Exited");

await App.WaitUntilOutputContains("dotnet watch ⭐ Waiting for server to shutdown ...");

await App.WaitUntilOutputContains("dotnet watch ⭐ [#1] Stop session");
await App.WaitUntilOutputContains("dotnet watch ⭐ [#2] Stop session");
await App.WaitUntilOutputContains("dotnet watch ⭐ [#3] Stop session");
await App.WaitUntilOutputContains("dotnet watch ⭐ [#2] Sending 'sessionTerminated'");
await App.WaitUntilOutputContains("dotnet watch ⭐ [#3] Sending 'sessionTerminated'");
}

[PlatformSpecificFact(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/53058, https://github.com/dotnet/sdk/issues/53061, https://github.com/dotnet/sdk/issues/53114
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.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges);

await App.WaitUntilOutputContains("dotnet watch ⭐ [#1] Session started");
await App.WaitUntilOutputContains(MessageDescriptor.Exited, $"WatchAspire.MigrationService ({tfm})");
await App.WaitUntilOutputContains("dotnet watch ⭐ [#1] Sending 'sessionTerminated'");

// migration service output should not be printed to dotnet-watch output, it should be sent via DCP as a notification:
await App.WaitUntilOutputContains("dotnet watch ⭐ [#1] Sending 'serviceLogs': log_message=' Migration complete', is_std_err=False");

// wait until after DCP sessions have been started for all projects:
await App.WaitUntilOutputContains("dotnet watch ⭐ [#3] Session started");

App.AssertOutputDoesNotContain(new Regex("^ +Migration complete"));

App.Process.ClearOutput();

// no-effect edit:
UpdateSourceFile(webSourcePath, src => src.Replace("/* top-level placeholder */", "builder.Services.AddRazorComponents();"));

await App.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied);
await App.WaitUntilOutputContains("dotnet watch ⭐ [#3] Session started");
await App.WaitUntilOutputContains(MessageDescriptor.ProjectsRestarted.GetMessage(1));
App.AssertOutputDoesNotContain("⚠");

// The process exited and should not participate in Hot Reload:
App.AssertOutputDoesNotContain($"[WatchAspire.MigrationService ({tfm})]");
App.AssertOutputDoesNotContain("dotnet watch ⭐ [#1]");

App.Process.ClearOutput();

// lambda body edit:
UpdateSourceFile(webSourcePath, src => src.Replace("Hello world!", "<Updated>"));

await App.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied);
await App.WaitUntilOutputContains($"dotnet watch 🕵️ [WatchAspire.Web ({tfm})] Updates applied.");
App.AssertOutputDoesNotContain(MessageDescriptor.ProjectsRebuilt);
App.AssertOutputDoesNotContain(MessageDescriptor.ProjectsRestarted);
App.AssertOutputDoesNotContain("⚠");

// The process exited and should not participate in Hot Reload:
App.AssertOutputDoesNotContain($"[WatchAspire.MigrationService ({tfm})]");
App.AssertOutputDoesNotContain("dotnet watch ⭐ [#1]");
}
}
}
206 changes: 206 additions & 0 deletions test/dotnet-watch.Tests/HotReload/AutoRestartTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// 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
{
Comment thread
tmat marked this conversation as resolved.
public class AutoRestartTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger)
{
[Theory]
[CombinatorialData]
public async Task AutoRestartOnRudeEdit(bool nonInteractive)
{
var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp", identifier: nonInteractive.ToString())
.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.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges);
App.Process.ClearOutput();

// rude edit: adding virtual method
UpdateSourceFile(programPath, src => src.Replace("/* member placeholder */", "public virtual void F() {}"));

await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges);

await App.WaitUntilOutputContains(MessageDescriptor.RestartNeededToApplyChanges);
await App.WaitUntilOutputContains($"⌚ [auto-restart] {programPath}(39,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application.");
await App.WaitUntilOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited");
await App.WaitUntilOutputContains($"[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.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied);
}

[Theory(Skip = "https://github.com/dotnet/sdk/issues/51469")]
[CombinatorialData]
public async Task AutoRestartOnRuntimeRudeEdit(bool nonInteractive)
{
var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp", identifier: nonInteractive.ToString())
.WithSource();

var tfm = ToolsetInfo.CurrentTargetFramework;
var programPath = Path.Combine(testAsset.Path, "Program.cs");

// Changes the type of lambda without updating top-level code.
// The loop will end up calling the old version of the lambda resulting in runtime rude edit.

File.WriteAllText(programPath, """
using System;
using System.Threading;

var d = C.F();

while (true)
{
Thread.Sleep(250);
d(1);
}

class C
{
public static Action<int> F()
{
return a =>
{
Console.WriteLine(a.GetType());
};
}
}
""");

App.Start(testAsset, nonInteractive ? ["--non-interactive"] : []);

await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges);
await App.WaitUntilOutputContains("System.Int32");
App.Process.ClearOutput();

UpdateSourceFile(programPath, src => src.Replace("Action<int>", "Action<byte>"));

// The following agent messages must be reported in order.
// The HotReloadException handler needs to be installed and update handlers invoked and completed before the
// HotReloadException handler may proceed with runtime rude edit processing and application restart.
await App.WaitForOutputLineContaining($"dotnet watch 🕵️ [WatchHotReloadApp ({tfm})] HotReloadException handler installed.");
await App.WaitForOutputLineContaining($"dotnet watch 🕵️ [WatchHotReloadApp ({tfm})] Invoking metadata update handlers.");
await App.WaitForOutputLineContaining($"dotnet watch 🕵️ [WatchHotReloadApp ({tfm})] Updates applied.");
await App.WaitForOutputLineContaining($"dotnet watch 🕵️ [WatchHotReloadApp ({tfm})] Runtime rude edit detected:");

await App.WaitUntilOutputContains($"dotnet watch ⚠ [WatchHotReloadApp ({tfm})] " +
"Attempted to invoke a deleted lambda or local function implementation. " +
"This can happen when lambda or local function is deleted while the application is running.");

await App.WaitUntilOutputContains(MessageDescriptor.RestartingApplication, $"WatchHotReloadApp ({tfm})");

await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges);
await App.WaitUntilOutputContains("System.Byte");
}

[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.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges);
App.Process.ClearOutput();

// rude edit: adding virtual method
UpdateSourceFile(programPath, src => src.Replace("/* member placeholder */", "public virtual void F() {}"));

// the prompt is printed into stdout while the error is printed into stderr, so they might arrive in any order:
await App.WaitUntilOutputContains(" ❔ Do you want to restart your app? Yes (y) / No (n) / Always (a) / Never (v)");
await App.WaitUntilOutputContains(MessageDescriptor.RestartNeededToApplyChanges);

await App.WaitUntilOutputContains($"❌ {programPath}(39,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application.");
App.Process.ClearOutput();

App.SendKey('a');

await App.WaitUntilOutputContains(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.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges);

await App.WaitUntilOutputContains(MessageDescriptor.RestartNeededToApplyChanges);
await App.WaitUntilOutputContains($"⌚ [auto-restart] {programPath}(39,1): error ENC0033: Deleting method 'F()' requires restarting the application.");
await App.WaitUntilOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited");
await App.WaitUntilOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched");
}

[Theory]
[CombinatorialData]
public async Task AutoRestartOnNoEffectEdit(bool nonInteractive)
{
var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp", identifier: nonInteractive.ToString())
.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.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges);
App.Process.ClearOutput();

// top-level code change:
UpdateSourceFile(programPath, src => src.Replace("Started", "<Updated>"));

await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges);

await App.WaitUntilOutputContains(MessageDescriptor.RestartNeededToApplyChanges);
await App.WaitUntilOutputContains($"⌚ [auto-restart] {programPath}(17,19): warning ENC0118: Changing 'top-level code' might not have any effect until the application is restarted.");
await App.WaitUntilOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited");
await App.WaitUntilOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched");
await App.WaitUntilOutputContains("<Updated>");
App.Process.ClearOutput();

// valid edit:
UpdateSourceFile(programPath, src => src.Replace("/* member placeholder */", "public void F() {}"));

await App.WaitUntilOutputContains(MessageDescriptor.ManagedCodeChangesApplied);
}
}
}
Loading
Loading