Skip to content

Commit 51ff4e3

Browse files
authored
Testing: Set child project launch profile name based on AppHost launch profiles. Propagate ContentRoot. (dotnet#4287)
1 parent 460d1c3 commit 51ff4e3

16 files changed

+214
-36
lines changed

src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj

+6
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
<Description>Testing support for the .NET Aspire application model.</Description>
1111
</PropertyGroup>
1212

13+
<ItemGroup>
14+
<Compile Include="$(SharedDir)LaunchSettings.cs" Link="LaunchSettings.cs" />
15+
<Compile Include="$(SharedDir)LaunchProfile.cs" Link="LaunchProfile.cs" />
16+
<Compile Include="$(SharedDir)LaunchSettingsSerializerContext.cs" Link="LaunchSettingsSerializerContext.cs" />
17+
</ItemGroup>
18+
1319
<ItemGroup>
1420
<ProjectReference Include="..\Aspire.Hosting\Aspire.Hosting.csproj" />
1521
</ItemGroup>

src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs

+58-6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using Microsoft.Extensions.DependencyInjection;
66
using Microsoft.Extensions.Hosting;
77
using System.Diagnostics;
8+
using System.Reflection;
9+
using System.Text.Json;
810

911
namespace Aspire.Hosting.Testing;
1012

@@ -154,16 +156,66 @@ private void OnBuilderCreatingCore(DistributedApplicationOptions applicationOpti
154156
applicationOptions.AssemblyName = _entryPoint.Assembly.GetName().Name ?? string.Empty;
155157
applicationOptions.DisableDashboard = true;
156158
var cfg = hostBuilderOptions.Configuration ??= new();
157-
cfg.AddInMemoryCollection(new Dictionary<string, string?>
159+
var additionalConfig = new Dictionary<string, string?>
158160
{
159161
["DcpPublisher:RandomizePorts"] = "true",
160162
["DcpPublisher:DeleteResourcesOnShutdown"] = "true",
161163
["DcpPublisher:ResourceNameSuffix"] = $"{Random.Shared.Next():x}",
162-
});
164+
};
165+
166+
var appHostProjectPath = ResolveProjectPath(_entryPoint.Assembly);
167+
if (!string.IsNullOrEmpty(appHostProjectPath))
168+
{
169+
hostBuilderOptions.ContentRootPath = appHostProjectPath;
170+
}
171+
172+
var appHostLaunchSettings = GetLaunchSettings(appHostProjectPath);
173+
if (appHostLaunchSettings?.Profiles.FirstOrDefault().Key is { } profileName)
174+
{
175+
additionalConfig["AppHost:DefaultLaunchProfileName"] = profileName;
176+
}
177+
178+
cfg.AddInMemoryCollection(additionalConfig);
163179

164180
OnBuilderCreating(applicationOptions, hostBuilderOptions);
165181
}
166182

183+
private static string? ResolveProjectPath(Assembly? assembly)
184+
{
185+
var assemblyMetadata = assembly?.GetCustomAttributes<AssemblyMetadataAttribute>();
186+
return GetMetadataValue(assemblyMetadata, "AppHostProjectPath");
187+
}
188+
189+
private static string? GetMetadataValue(IEnumerable<AssemblyMetadataAttribute>? assemblyMetadata, string key)
190+
{
191+
return assemblyMetadata?.FirstOrDefault(m => string.Equals(m.Key, key, StringComparison.OrdinalIgnoreCase))?.Value;
192+
}
193+
194+
private static LaunchSettings? GetLaunchSettings(string? appHostPath)
195+
{
196+
if (appHostPath is null || !Directory.Exists(appHostPath))
197+
{
198+
return null;
199+
}
200+
201+
var projectFileInfo = new DirectoryInfo(appHostPath);
202+
var launchSettingsFilePath = projectFileInfo.FullName switch
203+
{
204+
null => Path.Combine("Properties", "launchSettings.json"),
205+
_ => Path.Combine(projectFileInfo.FullName, "Properties", "launchSettings.json")
206+
};
207+
208+
// It isn't mandatory that the launchSettings.json file exists!
209+
if (!File.Exists(launchSettingsFilePath))
210+
{
211+
return null;
212+
}
213+
214+
using var stream = File.OpenRead(launchSettingsFilePath);
215+
var settings = JsonSerializer.Deserialize(stream, LaunchSettingsSerializerContext.Default.LaunchSettings);
216+
return settings;
217+
}
218+
167219
private void OnBuilderCreatedCore(DistributedApplicationBuilder applicationBuilder)
168220
{
169221
OnBuilderCreated(applicationBuilder);
@@ -324,10 +376,10 @@ public virtual async ValueTask DisposeAsync()
324376
return;
325377
}
326378

327-
if (_hostApplicationLifetime is { } hostLifetime && hostLifetime.ApplicationStarted.IsCancellationRequested)
328-
{
329-
hostLifetime.StopApplication();
330-
}
379+
// If the application has started, or when it starts, stop it.
380+
using var applicationStartedRegistration = _hostApplicationLifetime?.ApplicationStarted.Register(
381+
static state => (state as IHostApplicationLifetime)?.StopApplication(),
382+
_hostApplicationLifetime);
331383

332384
await _exitTcs.Task.ConfigureAwait(false);
333385

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
6+
namespace Aspire.Hosting.ApplicationModel;
7+
8+
[DebuggerDisplay("Type = {GetType().Name,nq}, LaunchProfileName = {LaunchProfileName}")]
9+
internal sealed class DefaultLaunchProfileAnnotation : IResourceAnnotation
10+
{
11+
public DefaultLaunchProfileAnnotation(string launchProfileName)
12+
{
13+
ArgumentNullException.ThrowIfNull(launchProfileName);
14+
15+
LaunchProfileName = launchProfileName;
16+
}
17+
18+
public string LaunchProfileName { get; }
19+
}

src/Aspire.Hosting/Aspire.Hosting.csproj

+3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
<Compile Include="$(SharedDir)LoggingHelpers.cs" Link="Utils\LoggingHelpers.cs" />
3030
<Compile Include="$(SharedDir)StringUtils.cs" Link="Utils\StringUtils.cs" />
3131
<Compile Include="$(SharedDir)SchemaUtils.cs" Link="Utils\SchemaUtils.cs" />
32+
<Compile Include="$(SharedDir)LaunchSettings.cs" Link="LaunchSettings.cs" />
33+
<Compile Include="$(SharedDir)LaunchProfile.cs" Link="LaunchProfile.cs" />
34+
<Compile Include="$(SharedDir)LaunchSettingsSerializerContext.cs" Link="LaunchSettingsSerializerContext.cs" />
3235
</ItemGroup>
3336

3437
<ItemGroup>

src/Aspire.Hosting/Dcp/ApplicationExecutor.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,7 @@ appModelResource is IResourceWithEndpoints resourceWithEndpoints &&
687687

688688
if (ep.EndpointAnnotation.FromLaunchProfile &&
689689
appModelResource is ProjectResource p &&
690-
p.GetEffectiveLaunchProfile() is LaunchProfile profile &&
690+
p.GetEffectiveLaunchProfile()?.LaunchProfile is LaunchProfile profile &&
691691
profile.LaunchUrl is string launchUrl)
692692
{
693693
// Concat the launch url from the launch profile to the urls with IsFromLaunchProfile set to true
@@ -1078,7 +1078,7 @@ private void PrepareProjectExecutables()
10781078
// and the environment variables/application URLs inside CreateExecutableAsync().
10791079
exeSpec.Args.Add("--no-launch-profile");
10801080

1081-
var launchProfile = project.GetEffectiveLaunchProfile();
1081+
var launchProfile = project.GetEffectiveLaunchProfile()?.LaunchProfile;
10821082
if (launchProfile is not null && !string.IsNullOrWhiteSpace(launchProfile.CommandLineArgs))
10831083
{
10841084
var cmdArgs = CommandLineArgsParser.Parse(launchProfile.CommandLineArgs);

src/Aspire.Hosting/LaunchProfileExtensions.cs

+12-18
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
using System.Diagnostics.CodeAnalysis;
55
using System.Globalization;
66
using System.Text.Json;
7-
using System.Text.Json.Serialization;
87
using Aspire.Hosting.ApplicationModel;
98
using Aspire.Hosting.Properties;
109

@@ -29,7 +28,7 @@ internal static class LaunchProfileExtensions
2928
return projectMetadata.GetLaunchSettings();
3029
}
3130

32-
internal static LaunchProfile? GetEffectiveLaunchProfile(this ProjectResource projectResource, bool throwIfNotFound = false)
31+
internal static NamedLaunchProfile? GetEffectiveLaunchProfile(this ProjectResource projectResource, bool throwIfNotFound = false)
3332
{
3433
string? launchProfileName = projectResource.SelectLaunchProfileName();
3534
if (string.IsNullOrEmpty(launchProfileName))
@@ -49,7 +48,8 @@ internal static class LaunchProfileExtensions
4948
var message = string.Format(CultureInfo.InvariantCulture, Resources.LaunchSettingsFileDoesNotContainProfileExceptionMessage, launchProfileName);
5049
throw new DistributedApplicationException(message);
5150
}
52-
return found == true ? launchProfile : null;
51+
52+
return launchProfile is not null ? new (launchProfileName, launchProfile) : default;
5353
}
5454

5555
private static LaunchSettings? GetLaunchSettings(this IProjectMetadata projectMetadata)
@@ -87,7 +87,7 @@ internal static class LaunchProfileExtensions
8787
private static readonly LaunchProfileSelector[] s_launchProfileSelectors =
8888
[
8989
TrySelectLaunchProfileFromAnnotation,
90-
TrySelectLaunchProfileFromEnvironment,
90+
TrySelectLaunchProfileFromDefaultAnnotation,
9191
TrySelectLaunchProfileByOrder
9292
];
9393

@@ -105,31 +105,30 @@ private static bool TrySelectLaunchProfileByOrder(ProjectResource projectResourc
105105
return true;
106106
}
107107

108-
private static bool TrySelectLaunchProfileFromEnvironment(ProjectResource projectResource, [NotNullWhen(true)] out string? launchProfileName)
108+
private static bool TrySelectLaunchProfileFromDefaultAnnotation(ProjectResource projectResource, [NotNullWhen(true)] out string? launchProfileName)
109109
{
110-
var launchProfileEnvironmentVariable = Environment.GetEnvironmentVariable("DOTNET_LAUNCH_PROFILE");
111-
112-
if (launchProfileEnvironmentVariable is null)
110+
if (!projectResource.TryGetLastAnnotation<DefaultLaunchProfileAnnotation>(out var launchProfileAnnotation))
113111
{
114112
launchProfileName = null;
115113
return false;
116114
}
117115

116+
var appHostDefaultLaunchProfileName = launchProfileAnnotation.LaunchProfileName;
118117
var launchSettings = GetLaunchSettings(projectResource);
119118
if (launchSettings == null)
120119
{
121120
launchProfileName = null;
122121
return false;
123122
}
124123

125-
if (!launchSettings.Profiles.TryGetValue(launchProfileEnvironmentVariable, out var launchProfile))
124+
if (!launchSettings.Profiles.TryGetValue(appHostDefaultLaunchProfileName, out var launchProfile) || launchProfile is null)
126125
{
127126
launchProfileName = null;
128127
return false;
129128
}
130129

131-
launchProfileName = launchProfileEnvironmentVariable;
132-
return launchProfile != null;
130+
launchProfileName = appHostDefaultLaunchProfileName;
131+
return true;
133132
}
134133

135134
private static bool TrySelectLaunchProfileFromAnnotation(ProjectResource projectResource, [NotNullWhen(true)] out string? launchProfileName)
@@ -166,11 +165,6 @@ private static bool TrySelectLaunchProfileFromAnnotation(ProjectResource project
166165
}
167166
}
168167

169-
internal delegate bool LaunchProfileSelector(ProjectResource project, out string? launchProfile);
170-
171-
[JsonSerializable(typeof(LaunchSettings))]
172-
[JsonSourceGenerationOptions(ReadCommentHandling = JsonCommentHandling.Skip)]
173-
internal sealed partial class LaunchSettingsSerializerContext : JsonSerializerContext
174-
{
168+
internal sealed record class NamedLaunchProfile(string Name, LaunchProfile LaunchProfile);
175169

176-
}
170+
internal delegate bool LaunchProfileSelector(ProjectResource project, out string? launchProfile);

src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs

+17-2
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,22 @@ private static IResourceBuilder<ProjectResource> WithProjectDefaults(this IResou
220220
{
221221
builder.WithAnnotation(new LaunchProfileAnnotation(launchProfileName));
222222
}
223+
else
224+
{
225+
var appHostDefaultLaunchProfileName = builder.ApplicationBuilder.Configuration["AppHost:DefaultLaunchProfileName"]
226+
?? Environment.GetEnvironmentVariable("DOTNET_LAUNCH_PROFILE");
227+
if (!string.IsNullOrEmpty(appHostDefaultLaunchProfileName))
228+
{
229+
builder.WithAnnotation(new DefaultLaunchProfileAnnotation(appHostDefaultLaunchProfileName));
230+
}
231+
}
232+
233+
var effectiveLaunchProfile = excludeLaunchProfile ? null : projectResource.GetEffectiveLaunchProfile(throwIfNotFound: true);
234+
var launchProfile = effectiveLaunchProfile?.LaunchProfile;
223235

236+
// Process the launch profile and turn it into environment variables and endpoints.
224237
var config = GetConfiguration(projectResource);
225238
var kestrelEndpoints = config.GetSection("Kestrel:Endpoints").GetChildren();
226-
var launchProfile = excludeLaunchProfile ? null : projectResource.GetEffectiveLaunchProfile(throwIfNotFound: true);
227239

228240
// Get all the Kestrel configuration endpoint bindings, grouped by scheme
229241
var kestrelEndpointsByScheme = kestrelEndpoints
@@ -296,7 +308,10 @@ private static IResourceBuilder<ProjectResource> WithProjectDefaults(this IResou
296308
builder.WithEnvironment(context =>
297309
{
298310
// Populate DOTNET_LAUNCH_PROFILE environment variable for consistency with "dotnet run" and "dotnet watch".
299-
context.EnvironmentVariables.TryAdd("DOTNET_LAUNCH_PROFILE", launchProfileName!);
311+
if (effectiveLaunchProfile is not null)
312+
{
313+
context.EnvironmentVariables.TryAdd("DOTNET_LAUNCH_PROFILE", effectiveLaunchProfile.Name);
314+
}
300315

301316
foreach (var envVar in launchProfile.EnvironmentVariables)
302317
{
File renamed without changes.
File renamed without changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Text.Json;
5+
using System.Text.Json.Serialization;
6+
7+
namespace Aspire.Hosting;
8+
9+
[JsonSerializable(typeof(LaunchSettings))]
10+
[JsonSourceGenerationOptions(ReadCommentHandling = JsonCommentHandling.Skip)]
11+
internal sealed partial class LaunchSettingsSerializerContext : JsonSerializerContext
12+
{
13+
14+
}

tests/Aspire.Hosting.Testing.Tests/DistributedApplicationFixtureOfT.cs

+2-4
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,15 @@ namespace Aspire.Hosting.Testing.Tests;
99

1010
public class DistributedApplicationFixture<TEntryPoint> : DistributedApplicationFactory, IAsyncLifetime where TEntryPoint : class
1111
{
12-
public DistributedApplicationFixture(string[] args)
13-
: base(typeof(TEntryPoint), args)
12+
public DistributedApplicationFixture()
13+
: base(typeof(TEntryPoint), [])
1414
{
1515
if (Environment.GetEnvironmentVariable("BUILD_BUILDID") != null)
1616
{
1717
throw new SkipException("These tests can only run in local environments.");
1818
}
1919
}
2020

21-
public DistributedApplicationFixture() : this([]) { }
22-
2321
protected override void OnBuilderCreating(DistributedApplicationOptions applicationOptions, HostApplicationBuilderSettings hostOptions)
2422
{
2523
base.OnBuilderCreating(applicationOptions, hostOptions);

tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs

+37
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
using System.Net.Http.Json;
55
using Aspire.Hosting.Tests.Helpers;
6+
using Microsoft.Extensions.Configuration;
67
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Hosting;
79
using Xunit;
810

911
namespace Aspire.Hosting.Testing.Tests;
@@ -78,6 +80,41 @@ public async Task GetHttpClientBeforeStart(bool genericEntryPoint)
7880
Assert.Throws<InvalidOperationException>(() => app.CreateHttpClient("mywebapp1"));
7981
}
8082

83+
[LocalOnlyTheory]
84+
[InlineData(false)]
85+
[InlineData(true)]
86+
public async Task SetsCorrectContentRoot(bool genericEntryPoint)
87+
{
88+
var appHost = await (genericEntryPoint
89+
? DistributedApplicationTestingBuilder.CreateAsync<Projects.TestingAppHost1_AppHost>()
90+
: DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost)));
91+
await using var app = await appHost.BuildAsync();
92+
await app.StartAsync();
93+
var hostEnvironment = app.Services.GetRequiredService<IHostEnvironment>();
94+
Assert.Contains("TestingAppHost1", hostEnvironment.ContentRootPath);
95+
}
96+
97+
[LocalOnlyTheory]
98+
[InlineData(false)]
99+
[InlineData(true)]
100+
public async Task SelectsFirstLaunchProfile(bool genericEntryPoint)
101+
{
102+
var appHost = await (genericEntryPoint
103+
? DistributedApplicationTestingBuilder.CreateAsync<Projects.TestingAppHost1_AppHost>()
104+
: DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost)));
105+
await using var app = await appHost.BuildAsync();
106+
await app.StartAsync();
107+
var config = app.Services.GetRequiredService<IConfiguration>();
108+
var profileName = config["AppHost:DefaultLaunchProfileName"];
109+
Assert.Equal("https", profileName);
110+
111+
// Explicitly get the HTTPS endpoint - this is only available on the "https" launch profile.
112+
var httpClient = app.CreateHttpClient("mywebapp1", "https");
113+
var result = await httpClient.GetFromJsonAsync<WeatherForecast[]>("/weatherforecast");
114+
Assert.NotNull(result);
115+
Assert.True(result.Length > 0);
116+
}
117+
81118
// Tests that DistributedApplicationTestingBuilder throws exceptions at the right times when the app crashes.
82119
[LocalOnlyTheory]
83120
[InlineData(true, "before-build")]

0 commit comments

Comments
 (0)