|
// 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 System.Text.RegularExpressions;
namespace Microsoft.DotNet.Watch.UnitTests;
[Collection(nameof(InProcBuildTestCollection))]
public class RuntimeProcessLauncherTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger)
{
public enum TriggerEvent
{
HotReloadSessionStarting,
HotReloadSessionStarted,
WaitingForChanges,
}
private record class RunningWatcher(
RuntimeProcessLauncherTests Test,
HotReloadDotNetWatcher Watcher,
Task Task,
TestReporter Reporter,
TestConsole Console,
StrongBox<TestRuntimeProcessLauncher?> ServiceHolder,
CancellationTokenSource ShutdownSource) : IAsyncDisposable
{
public TestRuntimeProcessLauncher? Service => ServiceHolder.Value;
public async ValueTask DisposeAsync()
{
if (!ShutdownSource.IsCancellationRequested)
{
Test.Log("Shutting down");
ShutdownSource.Cancel();
}
try
{
await Task;
}
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<RunningProject> Launch(string projectPath, TestRuntimeProcessLauncher service, string workingDirectory, CancellationToken cancellationToken)
{
var projectOptions = new ProjectOptions()
{
IsRootProject = false,
ProjectPath = projectPath,
WorkingDirectory = workingDirectory,
BuildArguments = [],
Command = "run",
CommandArguments = ["--project", projectPath],
LaunchEnvironmentVariables = [],
LaunchProfileName = null,
NoLaunchProfile = true,
TargetFramework = null,
};
RestartOperation? startOp = null;
startOp = new RestartOperation(async cancellationToken =>
{
var result = await service.ProjectLauncher.TryLaunchProcessAsync(
projectOptions,
new CancellationTokenSource(),
onOutput: null,
restartOperation: startOp!,
cancellationToken);
Assert.NotNull(result);
await result.WaitForProcessRunningAsync(cancellationToken);
return result;
});
return await startOp(cancellationToken);
}
private RunningWatcher StartWatcher(TestAsset testAsset, string[] args, string? workingDirectory = null)
{
var console = new TestConsole(Logger);
var reporter = new TestReporter(Logger);
var loggerFactory = new LoggerFactory(reporter);
var environmentOptions = TestOptions.GetEnvironmentOptions(workingDirectory ?? testAsset.Path, TestContext.Current.ToolsetUnderTest.DotNetHostPath, testAsset);
var processRunner = new ProcessRunner(environmentOptions.GetProcessCleanupTimeout(isHotReloadEnabled: true));
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<TestRuntimeProcessLauncher?>();
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();
var watchTask = Task.Run(async () =>
{
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);
return new RunningWatcher(this, watcher, watchTask, reporter, console, serviceHolder, shutdownSource);
}
[Theory]
[CombinatorialData]
public async Task UpdateAndRudeEdit(TriggerEvent trigger)
{
var testAsset = CopyTestAsset("WatchAppMultiProc", trigger);
var tfm = ToolsetInfo.CurrentTargetFramework;
var workingDirectory = testAsset.Path;
var hostDir = Path.Combine(testAsset.Path, "Host");
var hostProject = Path.Combine(hostDir, "Host.csproj");
var serviceDirA = Path.Combine(testAsset.Path, "ServiceA");
var serviceSourceA1 = Path.Combine(serviceDirA, "A1.cs");
var serviceSourceA2 = Path.Combine(serviceDirA, "A2.cs");
var serviceProjectA = Path.Combine(serviceDirA, "A.csproj");
var serviceDirB = Path.Combine(testAsset.Path, "ServiceB");
var serviceProjectB = Path.Combine(serviceDirB, "B.csproj");
var libDir = Path.Combine(testAsset.Path, "Lib");
var libProject = Path.Combine(libDir, "Lib.csproj");
var libSource = Path.Combine(libDir, "Lib.cs");
await using var w = StartWatcher(testAsset, ["--non-interactive", "--project", hostProject], workingDirectory);
var launchCompletionA = w.CreateCompletionSource();
var launchCompletionB = w.CreateCompletionSource();
w.Reporter.RegisterAction(trigger switch
{
TriggerEvent.HotReloadSessionStarting => MessageDescriptor.HotReloadSessionStarting,
TriggerEvent.HotReloadSessionStarted => MessageDescriptor.HotReloadSessionStarted,
TriggerEvent.WaitingForChanges => MessageDescriptor.WaitingForChanges,
_ => throw new InvalidOperationException(),
}, () =>
{
// only launch once
if (launchCompletionA.Task.IsCompleted)
{
return;
}
// service should have been created before Hot Reload session started:
Assert.NotNull(w.Service);
Launch(serviceProjectA, w.Service, workingDirectory, w.ShutdownSource.Token).Wait();
launchCompletionA.TrySetResult();
Launch(serviceProjectB, w.Service, workingDirectory, w.ShutdownSource.Token).Wait();
launchCompletionB.TrySetResult();
});
var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges);
var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled);
var sessionStarted = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadSessionStarted);
var projectBaselinesUpdated = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectsRebuilt);
await launchCompletionA.Task;
await launchCompletionB.Task;
// let the host process start:
Log("Waiting for changes...");
await waitingForChanges.WaitAsync(w.ShutdownSource.Token);
Log("Waiting for session started...");
await sessionStarted.WaitAsync(w.ShutdownSource.Token);
await MakeRudeEditChange();
Log("Waiting for changed handled ...");
await changeHandled.WaitAsync(w.ShutdownSource.Token);
// Wait for project baselines to be updated, so that we capture the new solution snapshot
// and further changes are treated as another update.
Log("Waiting for baselines updated...");
await projectBaselinesUpdated.WaitAsync(w.ShutdownSource.Token);
await MakeValidDependencyChange();
Log("Waiting for changed handled ...");
await changeHandled.WaitAsync(w.ShutdownSource.Token);
// Hot Reload shared dependency - should update both service projects
async Task MakeValidDependencyChange()
{
var hasUpdateSourceA = w.CreateCompletionSource();
var hasUpdateSourceB = w.CreateCompletionSource();
w.Reporter.OnProcessOutput += line =>
{
if (line.Content.Contains("<Updated Lib>"))
{
if (line.Content.StartsWith($"[A ({tfm})]"))
{
if (!hasUpdateSourceA.Task.IsCompleted)
{
hasUpdateSourceA.SetResult();
}
}
else if (line.Content.StartsWith($"[B ({tfm})]"))
{
if (!hasUpdateSourceB.Task.IsCompleted)
{
hasUpdateSourceB.SetResult();
}
}
else
{
Assert.Fail($"Only service projects should be updated: '{line.Content}'");
}
}
};
await Task.Delay(TimeSpan.FromSeconds(1));
UpdateSourceFile(libSource,
"""
using System;
public class Lib
{
public static void Common()
{
Console.WriteLine("<Updated Lib>");
}
}
""");
Log("Waiting for updated output from project A ...");
await hasUpdateSourceA.Task;
Log("Waiting for updated output from project B ...");
await hasUpdateSourceB.Task;
Assert.True(hasUpdateSourceA.Task.IsCompletedSuccessfully);
Assert.True(hasUpdateSourceB.Task.IsCompletedSuccessfully);
}
// make a rude edit and check that the process is restarted
async Task MakeRudeEditChange()
{
var hasUpdateSource = w.CreateCompletionSource();
w.Reporter.OnProcessOutput += line =>
{
if (line.Content.StartsWith($"[A ({tfm})]") && line.Content.Contains("Started A: 2"))
{
hasUpdateSource.SetResult();
}
};
await Task.Delay(TimeSpan.FromSeconds(1));
// rude edit in A (changing assembly level attribute):
UpdateSourceFile(serviceSourceA2, """
[assembly: System.Reflection.AssemblyMetadata("TestAssemblyMetadata", "2")]
""");
Log("Waiting for updated output from project A ...");
await hasUpdateSource.Task;
Assert.True(hasUpdateSource.Task.IsCompletedSuccessfully);
}
}
[Theory]
[CombinatorialData]
public async Task UpdateAppliedToNewProcesses(bool sharedOutput)
{
var testAsset = CopyTestAsset("WatchAppMultiProc", sharedOutput);
var tfm = ToolsetInfo.CurrentTargetFramework;
if (sharedOutput)
{
testAsset = testAsset.UpdateProjProperty("OutDir", "bin", @"..\Shared");
}
var workingDirectory = testAsset.Path;
var hostDir = Path.Combine(testAsset.Path, "Host");
var hostProject = Path.Combine(hostDir, "Host.csproj");
var serviceDirA = Path.Combine(testAsset.Path, "ServiceA");
var serviceProjectA = Path.Combine(serviceDirA, "A.csproj");
var serviceDirB = Path.Combine(testAsset.Path, "ServiceB");
var serviceProjectB = Path.Combine(serviceDirB, "B.csproj");
var libDir = Path.Combine(testAsset.Path, "Lib");
var libProject = Path.Combine(libDir, "Lib.csproj");
var libSource = Path.Combine(libDir, "Lib.cs");
await using var w = StartWatcher(testAsset, ["--non-interactive", "--project", hostProject], workingDirectory);
var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges);
var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled);
var updatesApplied = w.Reporter.RegisterSemaphore(MessageDescriptor.UpdatesApplied);
var hasUpdateA = new SemaphoreSlim(initialCount: 0);
var hasUpdateB = new SemaphoreSlim(initialCount: 0);
w.Reporter.OnProcessOutput += line =>
{
if (line.Content.Contains("<Updated Lib>"))
{
if (line.Content.StartsWith($"[A ({tfm})]"))
{
hasUpdateA.Release();
}
else if (line.Content.StartsWith($"[B ({tfm})]"))
{
hasUpdateB.Release();
}
else
{
Assert.Fail($"Only service projects should be updated: '{line.Content}'");
}
}
};
// let the host process start:
Log("Waiting for changes...");
await waitingForChanges.WaitAsync(w.ShutdownSource.Token);
// service should have been created before Hot Reload session started:
Assert.NotNull(w.Service);
await Launch(serviceProjectA, w.Service, workingDirectory, w.ShutdownSource.Token);
UpdateSourceFile(libSource,
"""
using System;
public class Lib
{
public static void Common()
{
Console.WriteLine("<Updated Lib>");
}
}
""");
Log("Waiting for updated output from A ...");
await hasUpdateA.WaitAsync(w.ShutdownSource.Token);
// Host and ServiceA received updates:
Log("Waiting for updates applied 1/2 ...");
await updatesApplied.WaitAsync(w.ShutdownSource.Token);
Log("Waiting for updates applied 2/2 ...");
await updatesApplied.WaitAsync(w.ShutdownSource.Token);
await Launch(serviceProjectB, w.Service, workingDirectory, w.ShutdownSource.Token);
// ServiceB received updates:
Log("Waiting for updates applied ...");
await updatesApplied.WaitAsync(w.ShutdownSource.Token);
Log("Waiting for updated output from B ...");
await hasUpdateB.WaitAsync(w.ShutdownSource.Token);
}
public enum UpdateLocation
{
Dependency,
TopLevel,
TopFunction,
}
[Theory]
[CombinatorialData]
public async Task HostRestart(UpdateLocation updateLocation)
{
var testAsset = CopyTestAsset("WatchAppMultiProc", updateLocation);
var tfm = ToolsetInfo.CurrentTargetFramework;
var workingDirectory = testAsset.Path;
var hostDir = Path.Combine(testAsset.Path, "Host");
var hostProject = Path.Combine(hostDir, "Host.csproj");
var hostProgram = Path.Combine(hostDir, "Program.cs");
var libProject = Path.Combine(testAsset.Path, "Lib2", "Lib2.csproj");
var lib = Path.Combine(testAsset.Path, "Lib2", "Lib2.cs");
await using var w = StartWatcher(testAsset, args: ["--project", hostProject], workingDirectory);
var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges);
var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled);
var restartNeeded = w.Reporter.RegisterSemaphore(MessageDescriptor.ApplyUpdate_ChangingEntryPoint);
var restartRequested = w.Reporter.RegisterSemaphore(MessageDescriptor.RestartRequested);
var hasUpdate = new SemaphoreSlim(initialCount: 0);
w.Reporter.OnProcessOutput += line =>
{
if (line.Content.Contains("<Updated>"))
{
if (line.Content.StartsWith($"[Host ({tfm})]"))
{
hasUpdate.Release();
}
else
{
Assert.Fail($"Only service projects should be updated: '{line.Content}'");
}
}
};
await Task.Delay(TimeSpan.FromSeconds(1));
// let the host process start:
Log("Waiting for changes...");
await waitingForChanges.WaitAsync(w.ShutdownSource.Token);
switch (updateLocation)
{
case UpdateLocation.Dependency:
UpdateSourceFile(lib, """
using System;
public class Lib2
{
public static void Print()
{
Console.WriteLine("<Updated>");
}
}
""");
// Host received Hot Reload updates:
Log("Waiting for change handled ...");
await changeHandled.WaitAsync(w.ShutdownSource.Token);
break;
case UpdateLocation.TopFunction:
// Update top-level function body:
UpdateSourceFile(hostProgram, content => content.Replace("Waiting", "<Updated>"));
// Host received Hot Reload updates:
Log("Waiting for change handled ...");
await changeHandled.WaitAsync(w.ShutdownSource.Token);
break;
case UpdateLocation.TopLevel:
// Update top-level code that does not get reloaded until the app restarts:
UpdateSourceFile(hostProgram, content => content.Replace("Started", "<Updated>"));
// ⚠ ENC0118: Changing 'top-level code' might not have any effect until the application is restarted. Press "Ctrl + R" to restart.
Log("Waiting for restart needed ...");
await restartNeeded.WaitAsync(w.ShutdownSource.Token);
w.Console.PressKey(new ConsoleKeyInfo('R', ConsoleKey.R, shift: false, alt: false, control: true));
Log("Waiting for restart requested ...");
await restartRequested.WaitAsync(w.ShutdownSource.Token);
break;
}
Log("Waiting updated output from Host ...");
await hasUpdate.WaitAsync(w.ShutdownSource.Token);
}
[Fact]
public async Task RudeEditInProjectWithoutRunningProcess()
{
var testAsset = CopyTestAsset("WatchAppMultiProc");
var workingDirectory = testAsset.Path;
var hostDir = Path.Combine(testAsset.Path, "Host");
var hostProject = Path.Combine(hostDir, "Host.csproj");
var serviceDirA = Path.Combine(testAsset.Path, "ServiceA");
var serviceSourceA2 = Path.Combine(serviceDirA, "A2.cs");
var serviceProjectA = Path.Combine(serviceDirA, "A.csproj");
await using var w = StartWatcher(testAsset, ["--non-interactive", "--project", hostProject], workingDirectory);
var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges);
var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled);
var sessionStarted = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadSessionStarted);
var applyUpdateVerbose = w.Reporter.RegisterSemaphore(MessageDescriptor.ApplyUpdate_Verbose);
// let the host process start:
Log("Waiting for changes...");
await waitingForChanges.WaitAsync(w.ShutdownSource.Token);
// 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);
Log("Waiting for session started ...");
await sessionStarted.WaitAsync(w.ShutdownSource.Token);
// Terminate the process:
Log($"Terminating process {runningProject.ProjectNode.GetDisplayName()} ...");
await w.Service.ProjectLauncher.TerminateProcessAsync(runningProject, CancellationToken.None);
// rude edit in A (changing assembly level attribute):
UpdateSourceFile(serviceSourceA2, """
[assembly: System.Reflection.AssemblyMetadata("TestAssemblyMetadata", "2")]
""");
Log("Waiting for change handled ...");
await changeHandled.WaitAsync(w.ShutdownSource.Token);
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(
"<!-- add item -->",
$"""
<Compile Include="{path}"/>
"""));
}
}
await using var w = StartWatcher(testAsset, ["--no-exit"], workingDirectory);
var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges);
var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled);
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.NoCSharpChangesToApply);
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 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 = StartWatcher(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.HotReloadChangeHandled);
var hasUpdatedOutput = w.CreateCompletionSource();
w.Reporter.OnProcessOutput += line =>
{
if (line.Content.Contains("System.Xml.Linq.XDocument"))
{
hasUpdatedOutput.TrySetResult();
}
};
// start process:
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("<!-- items placeholder -->", """<Using Include="System.Xml.Linq"/>"""));
// 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 = StartWatcher(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 hotReloadSucceeded = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadSucceeded);
var hasUpdatedOutput = w.CreateCompletionSource();
w.Reporter.OnProcessOutput += line =>
{
if (line.Content.Contains("<Lib>"))
{
hasUpdatedOutput.TrySetResult();
}
};
// start process:
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("""
<ItemGroup />
""", """
<ItemGroup>
<ProjectReference Include="..\Dependency\Dependency.csproj" />
</ItemGroup>
"""));
UpdateSourceFile(appFile, code => code.Replace("// Lib.Print();", "Lib.Print();"));
// done updating files:
fileChangesCompleted.TrySetResult();
Log("Waiting for output '<Lib>'...");
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, hotReloadSucceeded.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 = StartWatcher(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 hotReloadSucceeded = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadSucceeded);
var hasUpdatedOutput = w.CreateCompletionSource();
w.Reporter.OnProcessOutput += line =>
{
if (line.Content.Contains("Newtonsoft.Json.Linq.JToken"))
{
hasUpdatedOutput.TrySetResult();
}
};
// start process:
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("""
<!-- items placeholder -->
""", """
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
"""));
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, hotReloadSucceeded.CurrentCount);
}
}
|