File: Infrastructure\TestReadinessHostingStartup.cs
Web Access
Project: src\src\Components\Testing\src\Microsoft.AspNetCore.Components.Testing.csproj (Microsoft.AspNetCore.Components.Testing)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Reflection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
 
namespace Microsoft.AspNetCore.Components.Testing.Infrastructure;
 
/// <summary>
/// <see cref="IHostingStartup"/> that registers all E2E test infrastructure
/// services into the Blazor app under test. Injected via the
/// <c>ASPNETCORE_HOSTINGSTARTUPASSEMBLIES</c> environment variable.
/// </summary>
/// <remarks>
/// <para>Registers:</para>
/// <list type="bullet">
///   <item>Readiness notification service that POSTs to the test harness when the app starts</item>
///   <item>Parent PID watcher for auto-termination on test crash</item>
///   <item><see cref="TestSessionContext"/> + <see cref="TestLockProvider"/> + middleware for deterministic async state control</item>
///   <item>Static method service overrides (when <c>E2E_TEST_SERVICES_TYPE</c> + <c>E2E_TEST_SERVICES_METHOD</c> are set)</item>
/// </list>
/// <para>
/// The <c>[assembly: HostingStartup]</c> attribute is NOT in this file — it is emitted by the
/// source generator into the consuming test assembly.
/// </para>
/// </remarks>
public class TestReadinessHostingStartup : IHostingStartup
{
    /// <inheritdoc />
    public void Configure(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.AddHostedService<ReadinessNotificationService>();
            services.AddHostedService<ParentProcessWatcher>();
 
            // Test infrastructure: session context, lock provider, and middleware.
            // Always registered; no-op when tests don't use them.
            services.AddScoped<TestSessionContext>();
            services.AddSingleton<TestLockProvider>();
            services.AddTransient<IStartupFilter, TestInfrastructureStartupFilter>();
        });
 
        // Wire up service overrides if the env vars are set
        var overrideTypeName = Environment.GetEnvironmentVariable("E2E_TEST_SERVICES_TYPE");
        var overrideMethodName = Environment.GetEnvironmentVariable("E2E_TEST_SERVICES_METHOD");
 
        if (!string.IsNullOrEmpty(overrideTypeName) && !string.IsNullOrEmpty(overrideMethodName))
        {
            // Try the source-generated resolver first (no reflection on the target method)
            Action<IServiceCollection>? configureServices = TryResolveViaGeneratedResolver(overrideTypeName, overrideMethodName);
 
            // Fall back to reflection if the resolver didn't handle it
            if (configureServices is null)
            {
                var type = Type.GetType(overrideTypeName)
                    ?? throw new InvalidOperationException(
                        $"Could not resolve service override type '{overrideTypeName}'. " +
                        "Ensure the type name is assembly-qualified and the assembly is loadable.");
 
                var method = type.GetMethod(overrideMethodName,
                        BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic,
                        null, [typeof(IServiceCollection)], null)
                    ?? throw new InvalidOperationException(
                        $"Could not find static method '{overrideMethodName}(IServiceCollection)' on type '{type.FullName}'.");
 
                configureServices = services =>
                {
                    method.Invoke(null, [services]);
                };
            }
 
            DiagnosticListener.AllListeners.Subscribe(
                new TestServiceOverrideObserver(configureServices));
        }
    }
 
    static Action<IServiceCollection>? TryResolveViaGeneratedResolver(
        string overrideTypeName, string overrideMethodName)
    {
        try
        {
            // The generated resolver lives in the test assembly, which is identified
            // by ASPNETCORE_HOSTINGSTARTUPASSEMBLIES (set by ServerInstance).
            var assemblyName = Environment.GetEnvironmentVariable("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES");
            if (string.IsNullOrEmpty(assemblyName))
            {
                return null;
            }
 
            Assembly? testAssembly = null;
            foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
            {
                if (asm.GetName().Name == assemblyName)
                {
                    testAssembly = asm;
                    break;
                }
            }
 
            if (testAssembly is null)
            {
                return null;
            }
 
            // The source generator emits this well-known type in every test assembly
            var resolverType = testAssembly.GetType("Microsoft.AspNetCore.Components.Testing.Generated.ServiceOverrideResolver");
            if (resolverType is null)
            {
                return null;
            }
 
            var resolver = (IE2EServiceOverrideResolver)Activator.CreateInstance(resolverType)!;
            return resolver.TryResolve(overrideTypeName, overrideMethodName);
        }
        catch
        {
            // If anything goes wrong with the generated resolver, fall back to reflection
            return null;
        }
    }
}