File: src\ProjectTemplates\Shared\AspNetProcess.cs
Web Access
Project: src\src\ProjectTemplates\test\Templates.Blazor.WebAssembly.Auth.Tests\Templates.Blazor.WebAssembly.Auth.Tests.csproj (Templates.Blazor.WebAssembly.Auth.Tests)
// 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.Net;
using System.Net.Http;
using System.Net.Security;
using AngleSharp.Dom.Html;
using AngleSharp.Parser.Html;
using Microsoft.AspNetCore.Internal;
using Microsoft.AspNetCore.Server.IntegrationTesting;
using Microsoft.Extensions.CommandLineUtils;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Playwright;
using Xunit.Abstractions;
 
namespace Templates.Test.Helpers;
 
[DebuggerDisplay("{ToString(),nq}")]
public class AspNetProcess : IDisposable
{
    private const string ListeningMessagePrefix = "Now listening on: ";
    private readonly HttpClient _httpClient;
    private readonly ITestOutputHelper _output;
    private readonly DevelopmentCertificate _developmentCertificate;
 
    internal readonly Uri ListeningUri;
    internal ProcessEx Process { get; }
 
    public AspNetProcess(
        DevelopmentCertificate cert,
        ITestOutputHelper output,
        string workingDirectory,
        string dllPath,
        IDictionary<string, string> environmentVariables,
        bool published,
        bool hasListeningUri = true,
        bool usePublishedAppHost = false,
        ILogger logger = null)
    {
        _developmentCertificate = cert;
        _output = output;
        _httpClient = new HttpClient(new HttpClientHandler()
        {
            ServerCertificateCustomValidationCallback = (request, certificate, chain, errors) => (certificate.Subject != "CN=localhost" && errors == SslPolicyErrors.None) || certificate?.Thumbprint == _developmentCertificate.CertificateThumbprint,
        })
        {
            Timeout = TimeSpan.FromMinutes(2)
        };
 
        output.WriteLine("Running ASP.NET Core application...");
 
        string process;
        string arguments;
        if (published)
        {
            if (usePublishedAppHost)
            {
                // When publishing, use the app host to run the app. This makes it easy to consistently run for regular and single-file publish
                process = Path.ChangeExtension(dllPath, OperatingSystem.IsWindows() ? ".exe" : null);
                arguments = null;
            }
            else
            {
                process = DotNetMuxer.MuxerPathOrDefault();
                arguments = $"exec {dllPath}";
            }
        }
        else
        {
            process = DotNetMuxer.MuxerPathOrDefault();
 
            // When executing "dotnet run", the launch urls specified in the app's launchSettings.json have higher precedence
            // than ambient environment variables. We specify the urls using command line arguments instead to allow us
            // to continue binding to "port 0" and avoid test flakiness due to port conflicts.
            arguments = $"run --no-build --urls \"{environmentVariables["ASPNETCORE_URLS"]}\"";
        }
 
        if (logger is not null && logger.IsEnabled(LogLevel.Information))
        {
            logger.LogInformation($"AspNetProcess - process: {process} arguments: {arguments}");
        }
 
        var finalEnvironmentVariables = new Dictionary<string, string>(environmentVariables)
        {
            ["ASPNETCORE_Kestrel__Certificates__Default__Path"] = _developmentCertificate.CertificatePath,
            ["ASPNETCORE_Kestrel__Certificates__Default__Password"] = _developmentCertificate.CertificatePassword,
        };
 
        Process = ProcessEx.Run(output, workingDirectory, process, arguments, envVars: finalEnvironmentVariables);
 
        logger?.LogInformation("AspNetProcess - process started");
 
        if (hasListeningUri)
        {
            logger?.LogInformation("AspNetProcess - Getting listening uri");
            ListeningUri = ResolveListeningUrl(output);
            if (logger is not null && logger.IsEnabled(LogLevel.Information))
            {
                logger?.LogInformation($"AspNetProcess - Got {ListeningUri}");
            }
        }
    }
 
    public async Task VisitInBrowserAsync(IPage page)
    {
        _output.WriteLine($"Opening browser at {ListeningUri}...");
        await page.GotoAsync(ListeningUri.AbsoluteUri);
    }
 
    public async Task AssertPagesOk(IEnumerable<Page> pages)
    {
        foreach (var page in pages)
        {
            await AssertOk(page.Url);
            if (page.Links?.Count() > 0)
            {
                await ContainsLinks(page);
            }
        }
    }
 
    public async Task AssertPagesNotFound(IEnumerable<string> urls)
    {
        foreach (var url in urls)
        {
            await AssertNotFound(url);
        }
    }
 
    public async Task ContainsLinks(Page page)
    {
        var response = await RetryHelper.RetryRequest(async () =>
        {
            var request = new HttpRequestMessage(
                HttpMethod.Get,
                new Uri(ListeningUri, page.Url));
            return await _httpClient.SendAsync(request);
        }, logger: NullLogger.Instance);
 
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var parser = new HtmlParser();
        var html = await parser.ParseAsync(await response.Content.ReadAsStreamAsync());
 
        foreach (IHtmlLinkElement styleSheet in html.GetElementsByTagName("link"))
        {
            Assert.Equal("stylesheet", styleSheet.Relation);
            // Workaround for https://github.com/dotnet/aspnetcore/issues/31030#issuecomment-811334450
            // Cleans up incorrectly generated filename for scoped CSS files
            var styleSheetHref = styleSheet.Href.Replace("_", string.Empty).Replace("about://", string.Empty);
            await AssertOk(styleSheetHref);
        }
        foreach (var script in html.Scripts)
        {
            if (!string.IsNullOrEmpty(script.Source))
            {
                await AssertOk(script.Source);
            }
        }
 
        Assert.True(html.Links.Length == page.Links.Count(), $"Expected {page.Url} to have {page.Links.Count()} links but it had {html.Links.Length}");
        foreach ((var link, var expectedLink) in html.Links.Zip(page.Links, Tuple.Create))
        {
            IHtmlAnchorElement anchor = (IHtmlAnchorElement)link;
            if (string.Equals(anchor.Protocol, "about:"))
            {
                Assert.True(anchor.PathName.EndsWith(expectedLink, StringComparison.Ordinal), $"Expected next link on {page.Url} to be {expectedLink} but it was {anchor.PathName}: {html.Source.Text}");
                await AssertOk(anchor.PathName);
            }
            else
            {
                Assert.True(string.Equals(anchor.Href, expectedLink), $"Expected next link to be {expectedLink} but it was {anchor.Href}.");
 
                if (!string.Equals(anchor.Protocol, "https:") || string.Equals(anchor.HostName, "localhost", StringComparison.OrdinalIgnoreCase))
                {
                    // This is a relative or same-site URI, so verify it goes to a real destination within the app.
                    // We don't do this for external (absolute) URIs as it would introduce flakiness, and the code
                    // above already checks that the URI is what we expect.
                    var result = await RetryHelper.RetryRequest(async () =>
                    {
                        return await _httpClient.GetAsync(anchor.Href);
                    }, logger: NullLogger.Instance);
 
                    Assert.True(IsSuccessStatusCode(result), $"{anchor.Href} is a broken link!");
                }
            }
        }
    }
 
    private Uri ResolveListeningUrl(ITestOutputHelper output)
    {
        // Wait until the app is accepting HTTP requests
        output.WriteLine("Waiting until ASP.NET application is accepting connections...");
        var listeningMessage = GetListeningMessage();
 
        if (!string.IsNullOrEmpty(listeningMessage))
        {
            listeningMessage = listeningMessage.Trim();
            // Verify we have a valid URL to make requests to
            var listeningUrlString = listeningMessage.Substring(listeningMessage.IndexOf(
                ListeningMessagePrefix, StringComparison.Ordinal) + ListeningMessagePrefix.Length);
 
            output.WriteLine($"Detected that ASP.NET application is accepting connections on: {listeningUrlString}");
            listeningUrlString = string.Concat(listeningUrlString.AsSpan(0, listeningUrlString.IndexOf(':')),
                "://localhost",
                listeningUrlString.AsSpan(listeningUrlString.LastIndexOf(':')));
 
            output.WriteLine("Sending requests to " + listeningUrlString);
            return new Uri(listeningUrlString, UriKind.Absolute);
        }
        else
        {
            return null;
        }
    }
 
    private string GetListeningMessage()
    {
        var buffer = new List<string>();
        try
        {
            foreach (var line in Process.OutputLinesAsEnumerable)
            {
                if (line != null)
                {
                    buffer.Add(line);
                    if (line.Trim().Contains(ListeningMessagePrefix, StringComparison.Ordinal))
                    {
                        return line;
                    }
                }
            }
        }
        catch (OperationCanceledException)
        {
        }
 
        throw new InvalidOperationException(@$"Couldn't find listening url:
{string.Join(Environment.NewLine, buffer)}");
    }
 
    private static bool IsSuccessStatusCode(HttpResponseMessage response)
    {
        return response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.Redirect;
    }
 
    public Task AssertOk(string requestUrl)
        => AssertStatusCode(requestUrl, HttpStatusCode.OK);
 
    public Task AssertNotFound(string requestUrl)
        => AssertStatusCode(requestUrl, HttpStatusCode.NotFound);
 
    internal Task<HttpResponseMessage> SendRequest(string path) =>
        RetryHelper.RetryRequest(() => _httpClient.GetAsync(new Uri(ListeningUri, path)), logger: NullLogger.Instance);
 
    internal Task<HttpResponseMessage> SendRequest(Func<HttpRequestMessage> requestFactory)
        => RetryHelper.RetryRequest(() => _httpClient.SendAsync(requestFactory()), logger: NullLogger.Instance);
 
    public async Task AssertStatusCode(string requestUrl, HttpStatusCode statusCode, string acceptContentType = null)
    {
        var response = await RetryHelper.RetryRequest(() =>
        {
            var request = new HttpRequestMessage(
                HttpMethod.Get,
                new Uri(ListeningUri, requestUrl));
 
            if (!string.IsNullOrEmpty(acceptContentType))
            {
                request.Headers.Add("Accept", acceptContentType);
            }
 
            return _httpClient.SendAsync(request);
        }, logger: NullLogger.Instance);
 
        Assert.True(statusCode == response.StatusCode, $"Expected {requestUrl} to have status '{statusCode}' but it was '{response.StatusCode}'.");
    }
 
    public void Dispose()
    {
        _httpClient.Dispose();
        Process.Dispose();
    }
 
    public override string ToString()
    {
        var result = "";
        result += Process != null ? "Active: " : "Inactive";
        if (Process != null)
        {
            if (!Process.HasExited)
            {
                result += $"(Listening on {ListeningUri.OriginalString}) PID: {Process.Id}";
            }
            else
            {
                result += "(Already finished)";
            }
        }
 
        return result;
    }
}
 
public class Page(string url)
{
    public Page() : this(null) { }
 
    public string Url { get; set; } = url;
    public IEnumerable<string> Links { get; set; }
}