File: BlazorTemplateTest.cs
Web Access
Project: src\src\ProjectTemplates\test\Templates.Blazor.Tests\Templates.Blazor.Tests.csproj (Templates.Blazor.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.Runtime.InteropServices;
using Microsoft.AspNetCore.BrowserTesting;
using Microsoft.Playwright;
using Templates.Test.Helpers;
 
namespace BlazorTemplates.Tests;
 
public abstract class BlazorTemplateTest : BrowserTestBase
{
    public const int BUILDCREATEPUBLISH_PRIORITY = -1000;
 
    public BlazorTemplateTest(ProjectFactoryFixture projectFactory)
    {
        ProjectFactory = projectFactory;
        Microsoft.Playwright.Program.Main(["install"]);
    }
 
    public ProjectFactoryFixture ProjectFactory { get; set; }
 
    public abstract string ProjectType { get; }
 
    protected async Task<Project> CreateBuildPublishAsync(
        string auth = null,
        string[] args = null,
        string targetFramework = null,
        Func<Project, Project> getTargetProject = null,
        bool onlyCreate = false)
    {
        // Additional arguments are needed. See: https://github.com/dotnet/aspnetcore/issues/24278
        Environment.SetEnvironmentVariable("EnableDefaultScopedCssItems", "true");
        Environment.SetEnvironmentVariable("AllowMissingPrunePackageData", "true");
 
        var project = await ProjectFactory.CreateProject(Output);
        if (targetFramework != null)
        {
            project.TargetFramework = targetFramework;
        }
 
        await project.RunDotNetNewAsync(ProjectType, auth: auth, args: args);
 
        project = getTargetProject?.Invoke(project) ?? project;
 
        if (!onlyCreate)
        {
            await project.RunDotNetPublishAsync(noRestore: false);
 
            // Run dotnet build after publish. The reason is that one uses Config = Debug and the other uses Config = Release
            // The output from publish will go into bin/Release/netcoreappX.Y/publish and won't be affected by calling build
            // later, while the opposite is not true.
 
            await project.RunDotNetBuildAsync();
        }
 
        return project;
    }
 
    protected static Project GetSubProject(Project project, string projectDirectory, string projectName)
    {
        var subProjectDirectory = Path.Combine(project.TemplateOutputDir, projectDirectory);
        if (!Directory.Exists(subProjectDirectory))
        {
            throw new DirectoryNotFoundException($"Directory {subProjectDirectory} was not found.");
        }
 
        var subProject = new Project
        {
            Output = project.Output,
            DiagnosticsMessageSink = project.DiagnosticsMessageSink,
            ProjectName = projectName,
            TemplateOutputDir = subProjectDirectory,
        };
 
        return subProject;
    }
 
    public static bool TryValidateBrowserRequired(BrowserKind browserKind, bool isRequired, out string error)
    {
        error = !isRequired ? null : $"Browser '{browserKind}' is required but not configured on '{RuntimeInformation.OSDescription}'";
        return isRequired;
    }
 
    protected async Task TestBasicInteractionInNewPageAsync(
        BrowserKind browserKind,
        string listeningUri,
        string appName,
        BlazorTemplatePages pagesToExclude = BlazorTemplatePages.None,
        AuthenticationFeatures authenticationFeatures = AuthenticationFeatures.None)
    {
        if (!BrowserManager.IsAvailable(browserKind))
        {
            EnsureBrowserAvailable(browserKind);
            return;
        }
 
        await using var browser = await BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo);
        var page = await browser.NewPageAsync();
 
        Output.WriteLine($"Opening browser at {listeningUri}...");
        await page.GotoAsync(listeningUri, new() { WaitUntil = WaitUntilState.NetworkIdle });
 
        await TestBasicInteractionAsync(browser, page, appName, pagesToExclude, authenticationFeatures);
 
        await page.CloseAsync();
    }
 
    protected async Task TestBasicInteractionAsync(
        IBrowserContext browser,
        IPage page,
        string appName,
        BlazorTemplatePages pagesToExclude = BlazorTemplatePages.None,
        AuthenticationFeatures authenticationFeatures = AuthenticationFeatures.None)
    {
        await page.WaitForSelectorAsync("nav");
 
        if (!pagesToExclude.HasFlag(BlazorTemplatePages.Home))
        {
            // Initially displays the home page
            await page.WaitForSelectorAsync("h1 >> text=Hello, world!");
 
            Assert.Equal("Home", (await page.TitleAsync()).Trim());
        }
 
        if (!pagesToExclude.HasFlag(BlazorTemplatePages.Counter))
        {
            // Can navigate to the counter page
            await Task.WhenAll(
                page.WaitForURLAsync("**/counter"),
                page.WaitForSelectorAsync("h1 >> text=Counter"),
                page.WaitForSelectorAsync("p >> text=Current count: 0"),
                page.ClickAsync("a[href=counter]"));
 
            // Clicking the counter button works
            await IncrementCounterAsync(page);
        }
 
        if (authenticationFeatures.HasFlag(AuthenticationFeatures.RegisterAndLogIn))
        {
            // Start a new CDP session with WebAuthn enabled and add a virtual authenticator.
            // We do this regardless of whether we're testing passkeys, because passkey
            // gets attempted unconditionally on the login page, and this utilizes the WebAuthn API.
            await using var cdpSession = await browser.NewCDPSessionAsync(page);
            await cdpSession.SendAsync("WebAuthn.enable");
            var result = await cdpSession.SendAsync("WebAuthn.addVirtualAuthenticator", new Dictionary<string, object>
            {
                ["options"] = new
                {
                    protocol = "ctap2",
                    transport = "internal",
                    hasResidentKey = false,
                    hasUserVerification = true,
                    isUserVerified = true,
                    automaticPresenceSimulation = true,
                }
            });
 
            Assert.True(result.HasValue);
            var authenticatorId = result.Value.GetProperty("authenticatorId").GetString();
 
            await Task.WhenAll(
                page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
                page.ClickAsync("text=Login"));
 
            await Task.WhenAll(
                page.WaitForURLAsync("**/Account/Register**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
                page.ClickAsync("text=Register as a new user"));
 
            await page.WaitForSelectorAsync("text=Create a new account.");
 
            var userName = $"{Guid.NewGuid()}@example.com";
            var password = "[PLACEHOLDER]-1a";
 
            await page.FillAsync("[name=\"Input.Email\"]", userName);
            await page.FillAsync("[name=\"Input.Password\"]", password);
            await page.FillAsync("[name=\"Input.ConfirmPassword\"]", password);
 
            // We will be redirected to the RegisterConfirmation
            await Task.WhenAll(
                page.WaitForURLAsync("**/Account/RegisterConfirmation**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
                page.ClickAsync("button[type=\"submit\"]"));
 
            // We will be redirected to the ConfirmEmail
            await Task.WhenAll(
                page.WaitForURLAsync("**/Account/ConfirmEmail**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
                page.ClickAsync("text=Click here to confirm your account"));
 
            // Now we attempt to navigate to the "Auth Required" page,
            // which should redirect us to the login page since we are not logged in
            await Task.WhenAll(
                page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
                page.ClickAsync("text=Auth Required"));
 
            // Now we can login
            await page.WaitForSelectorAsync("[name=\"Input.Email\"]");
            await page.FillAsync("[name=\"Input.Email\"]", userName);
            await page.FillAsync("[name=\"Input.Password\"]", password);
            await page.ClickAsync("button[type=\"submit\"]");
 
            // Verify that we return to the "Auth Required" page
            await page.WaitForSelectorAsync("text=You are authenticated");
 
            if (authenticationFeatures.HasFlag(AuthenticationFeatures.Passkeys))
            {
                // Navigate to the passkey management page
                await Task.WhenAll(
                    page.WaitForURLAsync("**/Account/Manage**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
                    page.ClickAsync("a[href=\"Account/Manage\"]"));
 
                await page.WaitForSelectorAsync("text=Manage your account");
 
                // Check that an error is displayed if passkey creation fails
                await Task.WhenAll(
                    page.WaitForURLAsync("**/Account/Manage/Passkeys**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
                    page.ClickAsync("a[href=\"Account/Manage/Passkeys\"]"));
 
                await page.EvaluateAsync("""
                    () => {
                        navigator.credentials.create = () => {
                            const error = new Error("Simulated passkey creation failure");
                            error.name = "NotAllowedError";
                            return Promise.reject(error);
                        };
                    }
                    """);
 
                await page.ClickAsync("text=Add a new passkey");
                await page.WaitForSelectorAsync("text=Error: No passkey was provided by the authenticator.");
 
                // Now check that we can successfully register a passkey
                await page.ReloadAsync(new() { WaitUntil = WaitUntilState.NetworkIdle });
                await page.ClickAsync("text=Add a new passkey");
 
                await page.WaitForSelectorAsync("text=Enter a name for your passkey");
                await page.FillAsync("[name=\"Input.Name\"]", "My passkey");
                await page.ClickAsync("text=Continue");
 
                await page.WaitForSelectorAsync("text=Passkey updated successfully");
 
                // Logout so that we can test the passkey login flow
                await Task.WhenAll(
                    page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
                    page.ClickAsync("text=Logout"));
 
                // Navigate home to reset the return URL
                await page.ClickAsync("text=Home");
                await page.WaitForSelectorAsync("text=Hello, world!");
 
                // Now navigate to the login page
                await Task.WhenAll(
                    page.WaitForURLAsync("**/Account/Login**", new() { WaitUntil = WaitUntilState.NetworkIdle }),
                    page.ClickAsync("text=Login"));
 
                // Check that an error is displayed if passkey retrieval fails
                await page.EvaluateAsync("""
                    () => {
                        navigator.credentials.get = () => {
                            const error = new Error("Simulated passkey retrieval failure");
                            error.name = "NotAllowedError";
                            return Promise.reject(error);
                        };
                    }
                    """);
 
                await page.ClickAsync("text=Log in with a passkey");
                await page.WaitForSelectorAsync("text=Error: No passkey was provided by the authenticator.");
 
                // Now check that we can successfully login with the passkey
                await page.ReloadAsync(new() { WaitUntil = WaitUntilState.NetworkIdle });
                await page.WaitForSelectorAsync("[name=\"Input.Email\"]");
                await page.FillAsync("[name=\"Input.Email\"]", userName);
                await page.ClickAsync("text=Log in with a passkey");
 
                // Verify that we return to the home page
                await page.WaitForSelectorAsync("text=Hello, world!");
 
                // Verify that we can visit the "Auth Required" page again
                await page.ClickAsync("text=Auth Required");
                await page.WaitForSelectorAsync("text=You are authenticated");
            }
        }
 
        if (!pagesToExclude.HasFlag(BlazorTemplatePages.Weather))
        {
            await page.ClickAsync("a[href=weather]");
            await page.WaitForSelectorAsync("h1 >> text=Weather");
 
            // Asynchronously loads and displays the table of weather forecasts
            await page.WaitForSelectorAsync("table>tbody>tr");
            Assert.Equal(5, await page.Locator("p+table>tbody>tr").CountAsync());
        }
 
        static async Task IncrementCounterAsync(IPage page)
        {
            // Allow multiple click attempts because some interactive render modes
            // won't be immediately available
            const int MaxIncrementAttempts = 5;
            const float IncrementTimeoutMilliseconds = 3000f;
            for (var i = 0; i < MaxIncrementAttempts; i++)
            {
                await page.ClickAsync("p+button >> text=Click me");
                try
                {
                    await page.WaitForSelectorAsync("p >> text=Current count: 1", new()
                    {
                        Timeout = IncrementTimeoutMilliseconds,
                    });
 
                    // The counter successfully incremented, so we're done
                    return;
                }
                catch (TimeoutException)
                {
                    // The counter did not increment; try again
                }
            }
 
            Assert.Fail($"The counter did not increment after {MaxIncrementAttempts} attempts");
        }
    }
 
    protected void EnsureBrowserAvailable(BrowserKind browserKind)
    {
        Assert.False(
            TryValidateBrowserRequired(
                browserKind,
                isRequired: !BrowserManager.IsExplicitlyDisabled(browserKind),
                out var errorMessage),
            errorMessage);
    }
 
    [Flags]
    protected enum BlazorTemplatePages
    {
        None = 0,
        Home = 1,
        Counter = 2,
        Weather = 4,
        All = ~0,
    }
 
    [Flags]
    protected enum AuthenticationFeatures
    {
        None = 0,
        RegisterAndLogIn = 1,
        Passkeys = 2,
    }
}