Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CodeDom.Compiler;
using System.Diagnostics;
using System.Reflection;
using Microsoft.Extensions.Hosting;
Expand All @@ -9,29 +10,54 @@ namespace Aspire.Hosting.Testing;

internal static class DistributedApplicationEntryPointInvoker
{
private const string AspireHostingGeneratedCodeToolName = "Aspire.Hosting";
private const string MicrosoftTestingPlatformApplicationMetadataKey = "Microsoft.Testing.Platform.Application";

// This helpers encapsulates all of the complex logic required to:
// 1. Execute the entry point of the specified assembly in a different thread.
// 2. Wait for the diagnostic source events to fire
// 3. Give the caller a chance to execute logic to mutate the IDistributedApplicationBuilder
// 4. Resolve the instance of the DistributedApplication
// 5. Allow the caller to determine if the entry point has completed
public static Func<string[], CancellationToken, Task<DistributedApplication>>? ResolveEntryPoint(
Assembly assembly,
Type entryPointType,
Action<DistributedApplicationOptions, HostApplicationBuilderSettings>? onConstructing = null,
Action<DistributedApplicationBuilder>? onConstructed = null,
Action<DistributedApplicationBuilder>? onBuilding = null,
Action<Exception?>? entryPointCompleted = null)
{
if (assembly.EntryPoint is null)
var assembly = entryPointType.Assembly;
var entryPoint = assembly.EntryPoint;
if (entryPoint is null)
{
return null;
}

if (IsMicrosoftTestingPlatformApplication(assembly))
{
if (GetAspireProjectMetadata(entryPointType) is { } projectMetadata)
{
throw new InvalidOperationException(
$"The specified entry point type '{entryPointType.FullName}' is generated project metadata for '{projectMetadata.ProjectPath}', " +
$"but it was resolved from the Microsoft.Testing.Platform test application '{assembly.GetName().Name}'. " +
"This can happen when an Aspire.AppHost.Sdk test project generates a Projects.* type that shadows the AppHost's generated marker. " +
"Test projects should use Microsoft.NET.Sdk instead of Aspire.AppHost.Sdk and reference the AppHost project so the entry point type belongs to the AppHost executable assembly. " +
$"If the AppHost is discovered dynamically, load its assembly and pass a type from that assembly to {nameof(DistributedApplicationTestingBuilder)}.{nameof(DistributedApplicationTestingBuilder.CreateAsync)}(Type).");
}

throw new InvalidOperationException(
$"The assembly '{assembly.GetName().Name}' is a Microsoft.Testing.Platform test application. " +
$"Invoking its entry point from {nameof(DistributedApplicationFactory)} would recursively run the test application. " +
"Test projects should use Microsoft.NET.Sdk instead of Aspire.AppHost.Sdk and reference the AppHost project so the entry point type belongs to the AppHost executable assembly. " +
"Alternatively, use " +
$"{nameof(DistributedApplicationTestingBuilder)}.{nameof(DistributedApplicationTestingBuilder.Create)} to construct the application without invoking an entry point.");
}

return async (args, ct) =>
{
var invoker = new EntryPointInvoker(
args,
assembly.EntryPoint,
entryPoint,
onConstructing,
onConstructed,
onBuilding,
Expand All @@ -40,6 +66,29 @@ internal static class DistributedApplicationEntryPointInvoker
};
}

private static IProjectMetadata? GetAspireProjectMetadata(Type type)
{
// Aspire generates referenced project markers as:
// [GeneratedCode("Aspire.Hosting", null)]
// public class MyProject : IProjectMetadata
if (!typeof(IProjectMetadata).IsAssignableFrom(type) ||
type.GetCustomAttribute<GeneratedCodeAttribute>() is not { Tool: AspireHostingGeneratedCodeToolName })
{
return null;
}

return (IProjectMetadata)Activator.CreateInstance(type, nonPublic: true)!;
}

private static bool IsMicrosoftTestingPlatformApplication(Assembly assembly)
{
// MTP identifies test application assemblies with:
// [assembly: AssemblyMetadata("Microsoft.Testing.Platform.Application", "true")]
return assembly.GetCustomAttributes<AssemblyMetadataAttribute>().Any(static metadata =>
string.Equals(metadata.Key, MicrosoftTestingPlatformApplicationMetadataKey, StringComparison.Ordinal) &&
string.Equals(metadata.Value, bool.TrueString, StringComparison.OrdinalIgnoreCase));
}

private sealed class EntryPointInvoker : IObserver<DiagnosticListener>
{
private static readonly AsyncLocal<EntryPointInvoker> s_currentListener = new();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ private void EnsureEntryPointStarted()
// This helper launches the target assembly's entry point and hooks into the lifecycle
// so we can intercept execution at key stages.
var factory = DistributedApplicationEntryPointInvoker.ResolveEntryPoint(
_entryPoint.Assembly,
_entryPoint,
onConstructing: OnBuilderCreatingCore,
onConstructed: OnBuilderCreatedCore,
onBuilding: OnBuildingCore,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CodeDom.Compiler;
using Xunit;

namespace Aspire.Hosting.Testing.Tests;

public class DistributedApplicationEntryPointInvokerTests
{
[Fact]
public void ResolveEntryPointThrowsForGeneratedProjectMetadataType()
{
var entryPointType = typeof(GeneratedAppHostProjectMetadata);
IProjectMetadata projectMetadata = new GeneratedAppHostProjectMetadata();
var assembly = entryPointType.Assembly;

var exception = Assert.Throws<InvalidOperationException>(
() => DistributedApplicationEntryPointInvoker.ResolveEntryPoint(entryPointType));

Assert.Equal(
$"The specified entry point type '{entryPointType.FullName}' is generated project metadata for '{projectMetadata.ProjectPath}', " +
$"but it was resolved from the Microsoft.Testing.Platform test application '{assembly.GetName().Name}'. " +
"This can happen when an Aspire.AppHost.Sdk test project generates a Projects.* type that shadows the AppHost's generated marker. " +
"Test projects should use Microsoft.NET.Sdk instead of Aspire.AppHost.Sdk and reference the AppHost project so the entry point type belongs to the AppHost executable assembly. " +
$"If the AppHost is discovered dynamically, load its assembly and pass a type from that assembly to {nameof(DistributedApplicationTestingBuilder)}.{nameof(DistributedApplicationTestingBuilder.CreateAsync)}(Type).",
exception.Message);
}

[Fact]
public void ResolveEntryPointThrowsForMicrosoftTestingPlatformApplication()
{
var entryPointType = typeof(DistributedApplicationEntryPointInvokerTests);
var assembly = entryPointType.Assembly;

var exception = Assert.Throws<InvalidOperationException>(
() => DistributedApplicationEntryPointInvoker.ResolveEntryPoint(entryPointType));

Assert.Equal(
$"The assembly '{assembly.GetName().Name}' is a Microsoft.Testing.Platform test application. " +
$"Invoking its entry point from {nameof(DistributedApplicationFactory)} would recursively run the test application. " +
"Test projects should use Microsoft.NET.Sdk instead of Aspire.AppHost.Sdk and reference the AppHost project so the entry point type belongs to the AppHost executable assembly. " +
"Alternatively, use " +
$"{nameof(DistributedApplicationTestingBuilder)}.{nameof(DistributedApplicationTestingBuilder.Create)} to construct the application without invoking an entry point.",
exception.Message);
}

[Fact]
public void ResolveEntryPointAcceptsAppHostApplication()
{
var entryPoint = DistributedApplicationEntryPointInvoker.ResolveEntryPoint(
typeof(Projects.TestingAppHost1_AppHost));

Assert.NotNull(entryPoint);
}

[Fact]
public void ResolveEntryPointAcceptsProjectMetadataTypeFromAppHostApplication()
{
var entryPoint = DistributedApplicationEntryPointInvoker.ResolveEntryPoint(
typeof(Projects.TestingAppHost1_MyWorker));

Assert.NotNull(entryPoint);
}

[GeneratedCode("Aspire.Hosting", null)]
private sealed class GeneratedAppHostProjectMetadata : IProjectMetadata
{
public string ProjectPath => "/path/to/Test.AppHost.csproj";
}
}
Loading