File: StarterTemplateRunTestsBase.cs
Web Access
Project: src\tests\Aspire.Workload.Tests\Aspire.Workload.Tests.csproj (Aspire.Workload.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.Playwright;
using static Microsoft.Playwright.Assertions;
using Xunit;
using Xunit.Abstractions;
using Aspire.Hosting.Redis;
using System.Net.Http.Json;
 
namespace Aspire.Workload.Tests;
 
public abstract class StarterTemplateRunTestsBase<T> : WorkloadTestsBase, IClassFixture<T> where T : TemplateAppFixture
{
    protected readonly T _testFixture;
    protected bool HasRedisCache;
    protected virtual int DashboardResourcesWaitTimeoutSecs => 120;
 
    public StarterTemplateRunTestsBase(T fixture, ITestOutputHelper testOutput)
        : base(testOutput)
    {
        _testFixture = fixture;
    }
 
    [Fact]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4623", typeof(PlaywrightProvider), nameof(PlaywrightProvider.DoesNotHavePlaywrightSupport))]
    public async Task ResourcesShowUpOnDashboard()
    {
        await using var context = await CreateNewBrowserContextAsync();
        await CheckDashboardHasResourcesAsync(
            await _testFixture.Project!.OpenDashboardPageAsync(context),
            GetExpectedResources(_testFixture.Project!, hasRedisCache: HasRedisCache),
            timeoutSecs: DashboardResourcesWaitTimeoutSecs);
    }
 
    [Theory]
    [InlineData("http://")]
    [InlineData("https://")]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4623", typeof(PlaywrightProvider), nameof(PlaywrightProvider.DoesNotHavePlaywrightSupport))]
    public async Task WebFrontendWorks(string urlPrefix)
    {
        await using var context = await CreateNewBrowserContextAsync();
        var resourceRows = await CheckDashboardHasResourcesAsync(
            await _testFixture.Project!.OpenDashboardPageAsync(context),
            GetExpectedResources(_testFixture.Project!, hasRedisCache: HasRedisCache),
            timeoutSecs: DashboardResourcesWaitTimeoutSecs);
 
        string url = resourceRows.First(r => r.Name == "webfrontend")
                        .Endpoints.First(e => e.StartsWith(urlPrefix));
        await CheckWebFrontendWorksAsync(context, url, _testOutput, _testFixture.Project.LogPath, hasRedisCache: HasRedisCache);
    }
 
    [Theory]
    [InlineData("http://")]
    [InlineData("https://")]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4623", typeof(PlaywrightProvider), nameof(PlaywrightProvider.DoesNotHavePlaywrightSupport))]
    public async Task ApiServiceWorks(string urlPrefix)
    {
        await using var context = await CreateNewBrowserContextAsync();
        var resourceRows = await CheckDashboardHasResourcesAsync(
            await _testFixture.Project!.OpenDashboardPageAsync(context),
            GetExpectedResources(_testFixture.Project!, hasRedisCache: HasRedisCache),
            timeoutSecs: DashboardResourcesWaitTimeoutSecs);
 
        string url = resourceRows.First(r => r.Name == "apiservice")
                        .Endpoints.First(e => e.StartsWith(urlPrefix));
        await CheckApiServiceWorksAsync(url, _testOutput, _testFixture.Project.LogPath);
    }
 
    public static async Task CheckApiServiceWorksAsync(string url, ITestOutputHelper testOutput, string logPath)
    {
        var uri = new UriBuilder(url) { Path = "weatherforecast" }.Uri;
 
        using var httpClient = new HttpClient();
        var response = await httpClient.GetFromJsonAsync<WeatherForecast[]>(uri);
 
        Assert.NotNull(response);
        Assert.Equal(5, response.Length);
    }
 
    public static async Task CheckWebFrontendWorksAsync(IBrowserContext context, string url, ITestOutputHelper testOutput, string logPath, bool hasRedisCache = false)
    {
        var page = await context.NewPageWithLoggingAsync(testOutput);
 
        try
        {
            // Enabling routing disables the http cache
            await page.RouteAsync("**", async route => await route.ContinueAsync());
            await page.GotoAsync(url);
 
            await page.GetByRole(AriaRole.Link, new PageGetByRoleOptions { Name = "Weather" }).ClickAsync();
 
            var tableLoc = page.Locator("//table[//thead/tr/th/text()='Date']");
            await Expect(tableLoc).ToBeVisibleAsync();
 
            if (hasRedisCache)
            {
                // Compare weather data after refreshes
                var firstLoadText = string.Join(',', (await GetAndValidateCellTexts(tableLoc)).SelectMany(r => r));
                await Task.Delay(10_000);
 
                await page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.Load });
 
                var secondLoadText = string.Join(',', (await GetAndValidateCellTexts(tableLoc)).SelectMany(r => r));
                Assert.NotEqual(firstLoadText, secondLoadText);
            }
        }
        catch (Exception ex)
        {
            testOutput.WriteLine($"Error: {ex}");
            string screenshotPath = Path.Combine(logPath, "webfrontend-fail.png");
            await page.ScreenshotAsync(new PageScreenshotOptions { Path = screenshotPath });
            throw;
        }
 
        static async Task<List<string[]>> GetAndValidateCellTexts(ILocator tableLoc)
        {
            List<string[]> cellTexts = [];
            var rowsLoc = tableLoc.Locator("//tbody/tr");
            foreach (var row in await rowsLoc.AllAsync())
            {
                var texts = (await row.Locator("//td").AllAsync())
                    .Select(cell => cell.InnerHTMLAsync())
                    .Select(t => t.Result)
                    .ToArray();
                cellTexts.Add(texts);
            }
 
            foreach (var row in cellTexts)
            {
                Assert.Collection(row,
                    r => Assert.True(DateTime.TryParse(r, out _)),
                    r => Assert.True(int.TryParse(r, out var actualTempC) && actualTempC >= -20 && actualTempC <= 55),
                    r => Assert.True(int.TryParse(r, out var actualTempF) && actualTempF >= -5 && actualTempF <= 133),
                    r => Assert.Contains(r, new HashSet<string> { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }));
            }
 
            return cellTexts;
        }
    }
 
    public static List<ResourceRow> GetExpectedResources(AspireProject project, bool hasRedisCache)
    {
        var expectedResources = new List<ResourceRow>
        {
            new(Type: "Project",
                Name: "apiservice",
                State: "Running",
                SourceContains: $"{project.Id}.ApiService.csproj",
                Endpoints: ["^http://localhost:\\d+$", "^https://localhost:\\d+$"]),
 
            new(Type: "Project",
                Name: "webfrontend",
                State: "Running",
                SourceContains: $"{project.Id}.Web.csproj",
                Endpoints: ["^http://localhost:\\d+$", "^https://localhost:\\d+$"])
        };
 
        if (hasRedisCache)
        {
            expectedResources.Add(
                new ResourceRow(Type: "Container",
                                Name: "cache",
                                State: "Running",
                                SourceContains: $"{RedisContainerImageTags.Registry}/{RedisContainerImageTags.Image}:{RedisContainerImageTags.Tag}",
                                Endpoints: ["tcp://localhost:\\d+"]));
        }
 
        return expectedResources;
    }
}
 
public sealed record ResourceRow(string Type, string Name, string State, string SourceContains, string[] Endpoints);
 
public sealed record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}