diff --git a/sdk.sln b/sdk.sln index 9e6140637b8b..8107e6e40cfe 100644 --- a/sdk.sln +++ b/sdk.sln @@ -1161,6 +1161,7 @@ Global src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{03c5a84a-982b-4f38-ac73-ab832c645c4a}*SharedItemsImports = 5 src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{0a3c9afd-f6e6-4a5d-83fb-93bf66732696}*SharedItemsImports = 5 src\BuiltInTools\AspireService\Microsoft.WebTools.AspireService.projitems*{1f0b4b3c-dc88-4740-b04f-1707102e9930}*SharedItemsImports = 5 + src\BuiltInTools\AspireService\Microsoft.WebTools.AspireService.projitems*{445efbd5-6730-4f09-943d-278e77501ffd}*SharedItemsImports = 5 src\BuiltInTools\AspireService\Microsoft.WebTools.AspireService.projitems*{94c8526e-dcc2-442f-9868-3dd0ba2688be}*SharedItemsImports = 13 src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{9d36039f-d0a1-462f-85b4-81763c6b02cb}*SharedItemsImports = 13 src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{a9103b98-d888-4260-8a05-fa36f640698a}*SharedItemsImports = 5 diff --git a/src/BuiltInTools/dotnet-watch/Aspire/AspireServerService.Extensions.cs b/src/BuiltInTools/dotnet-watch/Aspire/AspireServerService.Extensions.cs new file mode 100644 index 000000000000..3196e1cd7df8 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Aspire/AspireServerService.Extensions.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Watcher; + +namespace Microsoft.WebTools.AspireServer; + +internal partial class AspireServerService : IRuntimeProcessLauncher +{ + public bool SupportsPartialRestart => false; + + public async ValueTask> GetEnvironmentVariablesAsync(CancellationToken cancelationToken) + { + var environment = await GetServerConnectionEnvironmentAsync(cancelationToken).ConfigureAwait(false); + return environment.Select(kvp => (kvp.Key, kvp.Value)); + } +} diff --git a/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs b/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs new file mode 100644 index 000000000000..632f4df85628 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Microsoft.Build.Graph; +using Microsoft.DotNet.Watcher.Tools; +using Microsoft.Extensions.Tools.Internal; +using Microsoft.WebTools.AspireServer; +using Microsoft.WebTools.AspireServer.Contracts; + +namespace Microsoft.DotNet.Watcher; + +internal class AspireServiceFactory : IRuntimeProcessLauncherFactory +{ + private sealed class ServerEvents(ProjectLauncher projectLauncher, IReadOnlyList<(string name, string value)> buildProperties) : IAspireServerEvents + { + /// + /// Lock to access: + /// + /// + /// + private readonly object _guard = new(); + + private readonly Dictionary _sessions = []; + private int _sessionIdDispenser; + + private IReporter Reporter + => projectLauncher.Reporter; + + /// + /// Implements https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#create-session-request. + /// + public async ValueTask StartProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo, CancellationToken cancellationToken) + { + Reporter.Verbose($"Starting project: {projectLaunchInfo.ProjectPath}", MessageEmoji); + + var projectOptions = GetProjectOptions(projectLaunchInfo); + + var processTerminationSource = new CancellationTokenSource(); + + var runningProject = await projectLauncher.TryLaunchProcessAsync(projectOptions, processTerminationSource, build: false, cancellationToken); + if (runningProject == null) + { + // detailed error already reported: + throw new ApplicationException($"Failed to launch project '{projectLaunchInfo.ProjectPath}'."); + } + + string sessionId; + lock (_guard) + { + sessionId = _sessionIdDispenser++.ToString(CultureInfo.InvariantCulture); + _sessions.Add(sessionId, runningProject); + } + + Reporter.Verbose($"Session started: {sessionId}"); + return sessionId; + } + + /// + /// Implements https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#stop-session-request. + /// + public async ValueTask StopSessionAsync(string dcpId, string sessionId, CancellationToken cancellationToken) + { + Reporter.Verbose($"Stop Session {sessionId}", MessageEmoji); + + RunningProject? runningProject; + lock (_guard) + { + if (!_sessions.TryGetValue(sessionId, out runningProject)) + { + return false; + } + } + + _ = await projectLauncher.TerminateProcessesAsync([runningProject.ProjectNode.ProjectInstance.FullPath], cancellationToken); + return true; + } + + private ProjectOptions GetProjectOptions(ProjectLaunchRequest projectLaunchInfo) + { + var arguments = new List + { + "--project", + projectLaunchInfo.ProjectPath, + // TODO: https://github.com/dotnet/sdk/issues/43946 + // Need to suppress launch profile for now, otherwise it would override the port set via env variable. + "--no-launch-profile", + }; + + //if (projectLaunchInfo.DisableLaunchProfile) + //{ + // arguments.Add("--no-launch-profile"); + //} + //else if (!string.IsNullOrEmpty(projectLaunchInfo.LaunchProfile)) + //{ + // arguments.Add("--launch-profile"); + // arguments.Add(projectLaunchInfo.LaunchProfile); + //} + + if (projectLaunchInfo.Arguments != null) + { + arguments.AddRange(projectLaunchInfo.Arguments); + } + + return new() + { + IsRootProject = false, + ProjectPath = projectLaunchInfo.ProjectPath, + WorkingDirectory = projectLauncher.EnvironmentOptions.WorkingDirectory, // TODO: Should DCP protocol specify? + BuildProperties = buildProperties, // TODO: Should DCP protocol specify? + Command = "run", + CommandArguments = arguments, + LaunchEnvironmentVariables = projectLaunchInfo.Environment?.Select(kvp => (kvp.Key, kvp.Value)).ToArray() ?? [], + LaunchProfileName = projectLaunchInfo.LaunchProfile, + NoLaunchProfile = projectLaunchInfo.DisableLaunchProfile, + TargetFramework = null, // TODO: Should DCP protocol specify? + }; + } + } + + public const string MessageEmoji = "⭐"; + + public static readonly AspireServiceFactory Instance = new(); + public const string AppHostProjectCapability = "Aspire"; + + public IRuntimeProcessLauncher? TryCreate(ProjectGraphNode projectNode, ProjectLauncher projectLauncher, IReadOnlyList<(string name, string value)> buildProperties) + { + if (!projectNode.GetCapabilities().Contains(AppHostProjectCapability)) + { + return null; + } + + // TODO: implement notifications: + // 1) Process restarted notification + // 2) Session terminated notification + return new AspireServerService(new ServerEvents(projectLauncher, buildProperties), displayName: ".NET Watch Aspire Server", m => projectLauncher.Reporter.Verbose(m, MessageEmoji)); + } +} diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs index 52fbc003ed1f..c3547bd5b146 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs @@ -209,6 +209,8 @@ public async ValueTask SendJsonWithSecret(Func valueFac { try { + bool messageSent = false; + for (var i = 0; i < _clientSockets.Count; i++) { var (clientSocket, secret) = _clientSockets[i]; @@ -221,7 +223,10 @@ public async ValueTask SendJsonWithSecret(Func valueFac var messageBytes = JsonSerializer.SerializeToUtf8Bytes(value, _jsonSerializerOptions); await clientSocket.SendAsync(messageBytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); + messageSent = true; } + + _reporter.Verbose(messageSent ? "Browser message sent." : "Unable to send message to browser, no socket is open."); } catch (TaskCanceledException) { @@ -237,6 +242,8 @@ public async ValueTask SendMessage(ReadOnlyMemory messageBytes, Cancellati { try { + bool messageSent = false; + for (var i = 0; i < _clientSockets.Count; i++) { var (clientSocket, _) = _clientSockets[i]; @@ -244,8 +251,12 @@ public async ValueTask SendMessage(ReadOnlyMemory messageBytes, Cancellati { continue; } + await clientSocket.SendAsync(messageBytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); + messageSent = true; } + + _reporter.Verbose(messageSent ? "Browser message sent." : "Unable to send message to browser, no socket is open."); } catch (TaskCanceledException) { diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index d6cfaa5b212a..49e1040f52d1 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.cs @@ -159,8 +159,15 @@ internal Watcher CreateWatcher(IRuntimeProcessLauncherFactory? runtimeProcessLau var projectGraph = TryReadProject(rootProjectOptions, reporter); if (projectGraph != null) { + var rootProject = projectGraph.GraphRoots.Single(); + // use normalized MSBuild path so that we can index into the ProjectGraph - rootProjectOptions = rootProjectOptions with { ProjectPath = projectGraph.GraphRoots.Single().ProjectInstance.FullPath }; + rootProjectOptions = rootProjectOptions with { ProjectPath = rootProject.ProjectInstance.FullPath }; + + if (rootProject.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability)) + { + runtimeProcessLauncherFactory ??= AspireServiceFactory.Instance; + } } var fileSetFactory = new MSBuildFileSetFactory( diff --git a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj index f405fff96f9b..ec3777d58079 100644 --- a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj +++ b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj @@ -1,4 +1,5 @@  + diff --git a/test/Microsoft.NET.TestFramework/Microsoft.NET.TestFramework.csproj b/test/Microsoft.NET.TestFramework/Microsoft.NET.TestFramework.csproj index 3d0f2fcf8637..b26d3363ae66 100644 --- a/test/Microsoft.NET.TestFramework/Microsoft.NET.TestFramework.csproj +++ b/test/Microsoft.NET.TestFramework/Microsoft.NET.TestFramework.csproj @@ -33,6 +33,10 @@ <_Parameter1>MicrosoftAspNetCoreAppRefPackageVersion <_Parameter2>$(MicrosoftAspNetCoreAppRefPackageVersion) + + <_Parameter1>MicrosoftNETSdkAspireManifest80100PackageVersion + <_Parameter2>$(MicrosoftNETSdkAspireManifest80100PackageVersion) + diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/Program.cs b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/Program.cs new file mode 100644 index 000000000000..d846ba189770 --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/Program.cs @@ -0,0 +1,39 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add service defaults & Aspire components. +builder.AddServiceDefaults(); + +// Add services to the container. +builder.Services.AddProblemDetails(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +app.UseExceptionHandler(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +}); + +app.MapDefaultEndpoints(); + +app.Run(); + +internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/Properties/launchSettings.json b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/Properties/launchSettings.json new file mode 100644 index 000000000000..4139a6bdb05b --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "http://localhost:5303", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/WatchAspire.ApiService.csproj b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/WatchAspire.ApiService.csproj new file mode 100644 index 000000000000..22dc3f3b39a2 --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/WatchAspire.ApiService.csproj @@ -0,0 +1,14 @@ + + + + Exe + $(CurrentTargetFramework) + enable + enable + + + + + + + diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/appsettings.Development.json b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/appsettings.Development.json new file mode 100644 index 000000000000..0c208ae9181e --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/appsettings.json b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/appsettings.json new file mode 100644 index 000000000000..10f68b8c8b4f --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/Program.cs b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/Program.cs new file mode 100644 index 000000000000..f56c3b8319aa --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/Program.cs @@ -0,0 +1,5 @@ +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddProject("apiservice"); + +builder.Build().Run(); diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/Properties/launchSettings.json b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000000..b72d77798ccb --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17211;http://localhost:15137", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21185", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22024" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15137", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19235", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20033", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } +} diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/WatchAspire.AppHost.csproj b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/WatchAspire.AppHost.csproj new file mode 100644 index 000000000000..bb5e72a5337b --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/WatchAspire.AppHost.csproj @@ -0,0 +1,22 @@ + + + + Exe + $(CurrentTargetFramework) + enable + enable + true + ad800ccc-954c-40cc-920b-2e09fc9eee7a + + 9.0.0 + + + + + + + + + + + diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/appsettings.Development.json b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/appsettings.Development.json new file mode 100644 index 000000000000..0c208ae9181e --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/appsettings.json b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/appsettings.json new file mode 100644 index 000000000000..31c092aa4501 --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ServiceDefaults/Extensions.cs b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000000..332cb237f92a --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ServiceDefaults/Extensions.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) + // app.MapPrometheusScrapingEndpoint(); + } + + return app; + } +} diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ServiceDefaults/WatchAspire.ServiceDefaults.csproj b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ServiceDefaults/WatchAspire.ServiceDefaults.csproj new file mode 100644 index 000000000000..cbfdf8929cb2 --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ServiceDefaults/WatchAspire.ServiceDefaults.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + true + + + + + + + + diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 2ad317fad6ba..504c6e95b9ba 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -191,19 +191,19 @@ public async Task BlazorWasm() App.Start(testAsset, [], testFlags: TestFlags.MockBrowser); - await App.AssertOutputLineStartsWith(MessageDescriptor.ConfiguredToUseBrowserRefresh); - await App.AssertOutputLineStartsWith(MessageDescriptor.ConfiguredToLaunchBrowser); - await App.AssertOutputLineStartsWith("dotnet watch ⌚ Launching browser: http://localhost:5000/"); await App.AssertWaitingForChanges(); - // TODO: enable once https://github.com/dotnet/razor/issues/10818 is fixed - //var newSource = """ - // @page "/" - //

Updated

- // """; + App.AssertOutputContains(MessageDescriptor.ConfiguredToUseBrowserRefresh); + App.AssertOutputContains(MessageDescriptor.ConfiguredToLaunchBrowser); + App.AssertOutputContains("dotnet watch ⌚ Launching browser: http://localhost:5000/"); - //UpdateSourceFile(Path.Combine(testAsset.Path, "Pages", "Index.razor"), newSource); - //await App.AssertOutputLineStartsWith(MessageDescriptor.HotReloadSucceeded); + var newSource = """ + @page "/" +

Updated

+ """; + + UpdateSourceFile(Path.Combine(testAsset.Path, "Pages", "Index.razor"), newSource); + await App.AssertOutputLineStartsWith(MessageDescriptor.HotReloadSucceeded, "blazorwasm (net9.0)"); } [Fact] @@ -370,5 +370,34 @@ public static void PrintDirectoryName([CallerFilePathAttribute] string filePath await App.AssertOutputLineStartsWith("> NewSubdir"); } + + [Fact] + public async Task Aspire() + { + var testAsset = TestAssets.CopyTestAsset("WatchAspire") + .WithSource(); + + var workloadInstallCommandSpec = new DotnetCommand(Logger, ["workload", "install", "aspire", "--include-previews"]) + { + WorkingDirectory = testAsset.Path, + }; + + var result = workloadInstallCommandSpec.Execute(); + Assert.Equal(0, result.ExitCode); + + var serviceSourcePath = Path.Combine(testAsset.Path, "WatchAspire.ApiService", "Program.cs"); + App.Start(testAsset, ["-lp", "http"], relativeProjectDirectory: "WatchAspire.AppHost"); + + await App.AssertWaitingForChanges(); + + var newSource = File.ReadAllText(serviceSourcePath, Encoding.UTF8); + newSource = newSource.Replace("Enumerable.Range(1, 5)", "Enumerable.Range(1, 10)"); + UpdateSourceFile(serviceSourcePath, newSource); + + await App.AssertOutputLineStartsWith("dotnet watch 🔥 Hot reload change handled"); + + App.AssertOutputContains(MessageDescriptor.HotReloadSucceeded, "WatchAspire.AppHost (net9.0)"); + App.AssertOutputContains(MessageDescriptor.HotReloadSucceeded, "WatchAspire.ApiService (net9.0)"); + } } } diff --git a/test/dotnet-watch.Tests/Utilities/AssertEx.cs b/test/dotnet-watch.Tests/Utilities/AssertEx.cs index cca94b1aae94..ef9a0b7f939d 100644 --- a/test/dotnet-watch.Tests/Utilities/AssertEx.cs +++ b/test/dotnet-watch.Tests/Utilities/AssertEx.cs @@ -225,7 +225,7 @@ public static void EqualFileList(IEnumerable expectedFiles, IEnumerable< public static void Contains(string expected, IEnumerable items) { - if (items.Any(item => item == expected)) + if (items.Any(item => item.Contains(expected))) { return; } diff --git a/test/dotnet-watch.Tests/Watch/Utilities/WatchableApp.cs b/test/dotnet-watch.Tests/Watch/Utilities/WatchableApp.cs index b79829dfa6ed..544c19d151c7 100644 --- a/test/dotnet-watch.Tests/Watch/Utilities/WatchableApp.cs +++ b/test/dotnet-watch.Tests/Watch/Utilities/WatchableApp.cs @@ -34,11 +34,17 @@ public WatchableApp(ITestOutputHelper logger) public bool UsePollingWatcher { get; set; } - public static string GetLinePrefix(MessageDescriptor descriptor) - => $"dotnet watch {descriptor.Emoji} {descriptor.Format}"; + public static string GetLinePrefix(MessageDescriptor descriptor, string projectDisplay = null) + => $"dotnet watch {descriptor.Emoji}{(projectDisplay != null ? $" [{projectDisplay}]" : "")} {descriptor.Format}"; - public Task AssertOutputLineStartsWith(MessageDescriptor descriptor, Predicate failure = null) - => AssertOutputLineStartsWith(GetLinePrefix(descriptor), failure); + public Task AssertOutputLineStartsWith(MessageDescriptor descriptor, string projectDisplay = null, Predicate failure = null) + => AssertOutputLineStartsWith(GetLinePrefix(descriptor, projectDisplay), failure); + + public void AssertOutputContains(string message) + => AssertEx.Contains(message, Process.Output); + + public void AssertOutputContains(MessageDescriptor descriptor, string projectDisplay = null) + => AssertOutputContains(GetLinePrefix(descriptor, projectDisplay)); /// /// Asserts that the watched process outputs a line starting with and returns the remainder of that line.