File: BrowserLogsBuilderExtensions.cs
Web Access
Project: src\src\Aspire.Hosting.Browsers\Aspire.Hosting.Browsers.csproj (Aspire.Hosting.Browsers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREBROWSERLOGS001 // Type is for evaluation purposes only
 
using System.Collections.Immutable;
using Aspire.Hosting.Browsers.Resources;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Extension methods for adding tracked browser log resources to browser-based application resources.
/// </summary>
[Experimental("ASPIREBROWSERLOGS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public static class BrowserLogsBuilderExtensions
{
    internal const string BrowserResourceType = "BrowserLogs";
    internal const string BrowserLogsConfigurationSectionName = "Aspire:Hosting:BrowserLogs";
    internal const string BrowserConfigurationKey = "Browser";
    internal const string BrowserPropertyName = "Browser";
    internal const string BrowserExecutablePropertyName = "Browser executable";
    internal const string BrowserHostOwnershipPropertyName = "Browser host ownership";
    internal const string ProfileConfigurationKey = "Profile";
    internal const string ProfilePropertyName = "Profile";
    internal const string UserDataModeConfigurationKey = "UserDataMode";
    internal const string UserDataModePropertyName = "User data mode";
    internal const BrowserUserDataMode DefaultUserDataMode = BrowserConfiguration.DefaultUserDataMode;
    internal const string TargetUrlPropertyName = "Target URL";
    internal const string ActiveSessionsPropertyName = "Active sessions";
    internal const string BrowserSessionsPropertyName = "Browser sessions";
    internal const string ActiveSessionCountPropertyName = "Active session count";
    internal const string TotalSessionsLaunchedPropertyName = "Total sessions launched";
    internal const string LastErrorPropertyName = "Last error";
    internal const string LastSessionPropertyName = "Last session";
    internal const string OpenTrackedBrowserCommandName = "open-tracked-browser";
    internal const string ConfigureTrackedBrowserCommandName = "configure-tracked-browser";
    internal const string CaptureScreenshotCommandName = "capture-screenshot";
    private static readonly JsonSerializerOptions s_commandResultJsonOptions = new(JsonSerializerDefaults.Web)
    {
        WriteIndented = true
    };
 
    /// <summary>
    /// Adds a child resource that can open the application's primary browser endpoint in a tracked browser session,
    /// surface browser diagnostics, and capture screenshots.
    /// </summary>
    /// <typeparam name="T">The type of resource being configured.</typeparam>
    /// <param name="builder">The resource builder.</param>
    /// <param name="browser">
    /// The browser to launch. When not specified, the tracked browser uses the configured value from
    /// <c>Aspire:Hosting:BrowserLogs</c> and otherwise prefers an installed <c>"msedge"</c> browser in shared user data
    /// mode, an installed <c>"chrome"</c> browser in isolated user data mode, and finally falls back to <c>"chrome"</c>.
    /// Supported values include logical
    /// browser names such as <c>"msedge"</c> and <c>"chrome"</c>, or an explicit browser executable path.
    /// </param>
    /// <param name="profile">
    /// Optional Chromium profile name or directory name to use. Only valid when the effective user data mode
    /// is <see cref="BrowserUserDataMode.Shared"/>. When not specified, the tracked browser uses the
    /// configured value from <c>Aspire:Hosting:BrowserLogs</c> if present.
    /// </param>
    /// <param name="userDataMode">
    /// Optional <see cref="BrowserUserDataMode"/> that selects whether the tracked browser launches against
    /// a persistent Aspire-managed user data directory shared across all AppHosts on the machine
    /// (<see cref="BrowserUserDataMode.Shared"/>, the default) or a per-AppHost persistent user data directory
    /// (<see cref="BrowserUserDataMode.Isolated"/>). Both modes use Aspire-managed paths under
    /// <c>%LocalAppData%\Aspire\BrowserData</c> on Windows (or platform equivalents); the user's normal browser
    /// profile is never used. When not specified, the tracked browser uses the configured value from
    /// <c>Aspire:Hosting:BrowserLogs</c> and otherwise defaults to <see cref="BrowserUserDataMode.Shared"/>.
    /// </param>
    /// <returns>A reference to the original <see cref="IResourceBuilder{T}"/> for further chaining.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    /// <remarks>
    /// <para>
    /// This method adds a child browser logs resource beneath the parent resource represented by <paramref name="builder"/>.
    /// The child resource exposes a dashboard command that launches a Chromium-based browser in a tracked mode, attaches to
    /// the browser's debugging protocol, forwards browser console, error, exception, and network output to the child
    /// resource's console log stream, and can capture screenshots as command artifacts.
    /// </para>
    /// <para>
    /// The tracked browser session uses the <a href="https://chromedevtools.github.io/devtools-protocol/">Chrome DevTools
    /// Protocol (CDP)</a> to subscribe to browser runtime, log, page, and network events.
    /// </para>
    /// <para>
    /// The parent resource must expose at least one HTTP or HTTPS endpoint. HTTPS endpoints are preferred over HTTP
    /// endpoints when selecting the browser target URL.
    /// </para>
    /// <para>
    /// Browser, profile, and user data mode settings can also be supplied from configuration
    /// using <c>Aspire:Hosting:BrowserLogs:Browser</c>, <c>Aspire:Hosting:BrowserLogs:Profile</c>,
    /// and <c>Aspire:Hosting:BrowserLogs:UserDataMode</c>, or scoped to a specific resource with
    /// <c>Aspire:Hosting:BrowserLogs:{ResourceName}:Browser</c>,
    /// <c>Aspire:Hosting:BrowserLogs:{ResourceName}:Profile</c>, and
    /// <c>Aspire:Hosting:BrowserLogs:{ResourceName}:UserDataMode</c>. Explicit method arguments override configuration.
    /// </para>
    /// </remarks>
    /// <example>
    /// Add tracked browser logs for a web front end:
    /// <code>
    /// var builder = DistributedApplication.CreateBuilder(args);
    ///
    /// builder.AddProject&lt;Projects.WebFrontend&gt;("web")
    ///     .WithExternalHttpEndpoints()
    ///     .WithBrowserLogs();
    /// </code>
    /// </example>
    [Experimental("ASPIREBROWSERLOGS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    [AspireExport]
    public static IResourceBuilder<T> WithBrowserLogs<T>(
        this IResourceBuilder<T> builder,
        string? browser = null,
        string? profile = null,
        BrowserUserDataMode? userDataMode = null)
        where T : IResourceWithEndpoints
    {
        ArgumentNullException.ThrowIfNull(builder);
        ThrowIfBlankWhenSpecified(browser, nameof(browser));
        ThrowIfBlankWhenSpecified(profile, nameof(profile));
 
        builder.ApplicationBuilder.Services.TryAddSingleton<IBrowserLogsSessionManager, BrowserLogsSessionManager>();
        builder.ApplicationBuilder.Services.TryAddSingleton<BrowserLogsConfigurationStore>();
        builder.ApplicationBuilder.Services.TryAddSingleton<BrowserLogsConfigurationManager>();
 
        var parentResource = builder.Resource;
        var explicitConfigurationValues = new BrowserConfigurationExplicitValues(browser, profile, userDataMode);
        var initialConfiguration = BrowserConfiguration.Resolve(builder.ApplicationBuilder.Configuration, parentResource.Name, explicitConfigurationValues);
        var browserLogsResource = new BrowserLogsResource(
            $"{parentResource.Name}-browser-logs",
            parentResource,
            initialConfiguration,
            explicitConfigurationValues);
        browserLogsResource.Annotations.Add(NameValidationPolicyAnnotation.None);
 
        builder.ApplicationBuilder.AddResource(browserLogsResource)
            .WithParentRelationship(parentResource)
            .ExcludeFromManifest()
            .WithIconName("GlobeDesktop")
            .WithInitialState(new CustomResourceSnapshot
            {
                ResourceType = BrowserResourceType,
                CreationTimeStamp = DateTime.UtcNow,
                State = KnownResourceStates.NotStarted,
                Properties = CreateInitialProperties(parentResource.Name, initialConfiguration)
            })
            .WithCommand(
                OpenTrackedBrowserCommandName,
                BrowserCommandStrings.OpenTrackedBrowserName,
                async context =>
                {
                    try
                    {
                        var configuration = context.Services.GetRequiredService<IConfiguration>();
                        var configurationStore = context.Services.GetRequiredService<BrowserLogsConfigurationStore>();
                        var currentConfiguration = browserLogsResource.ResolveCurrentConfiguration(configuration, configurationStore);
                        var url = ResolveBrowserUrl(parentResource);
                        var sessionManager = context.Services.GetRequiredService<IBrowserLogsSessionManager>();
                        await sessionManager.StartSessionAsync(browserLogsResource, currentConfiguration, context.ResourceName, url, context.CancellationToken).ConfigureAwait(false);
                        return CommandResults.Success();
                    }
                    catch (Exception ex)
                    {
                        return CommandResults.Failure(ex.Message);
                    }
                },
                new CommandOptions
                {
                    Description = BrowserCommandStrings.OpenTrackedBrowserDescription,
                    IconName = "Open",
                    IconVariant = IconVariant.Regular,
                    IsHighlighted = true,
                    UpdateState = context =>
                    {
                        var childState = context.ResourceSnapshot.State?.Text;
                        if (childState == KnownResourceStates.Starting)
                        {
                            return ResourceCommandState.Disabled;
                        }
 
                        var resourceNotifications = context.Services.GetRequiredService<ResourceNotificationService>();
                        if (resourceNotifications.TryGetCurrentState(parentResource.Name, out var resourceEvent))
                        {
                            var parentState = resourceEvent.Snapshot.State?.Text;
                            if (parentState == KnownResourceStates.Running || parentState == KnownResourceStates.RuntimeUnhealthy)
                            {
                                return ResourceCommandState.Enabled;
                            }
                        }
 
                        return ResourceCommandState.Disabled;
                    }
                })
            .WithCommand(
                ConfigureTrackedBrowserCommandName,
                BrowserCommandStrings.ConfigureTrackedBrowserName,
                async context =>
                {
                    try
                    {
                        var configurationManager = context.Services.GetRequiredService<BrowserLogsConfigurationManager>();
                        return await configurationManager.ConfigureAsync(browserLogsResource, context.Arguments, context.CancellationToken).ConfigureAwait(false);
                    }
                    catch (Exception ex)
                    {
                        return CommandResults.Failure(ex.Message);
                    }
                },
                new CommandOptions
                {
                    Description = BrowserCommandStrings.ConfigureTrackedBrowserDescription,
                    IconName = "Settings",
                    IconVariant = IconVariant.Regular,
                    Arguments = BrowserLogsConfigurationManager.CreateArgumentDefinitions(browserLogsResource, builder.ApplicationBuilder.UserSecretsManager.IsAvailable),
                    ValidateArguments = context =>
                    {
                        var configurationManager = context.Services.GetRequiredService<BrowserLogsConfigurationManager>();
                        return configurationManager.ValidateInputsAsync(browserLogsResource, context);
                    },
                    UpdateState = context =>
                    {
                        var interactionService = context.Services.GetRequiredService<IInteractionService>();
                        return interactionService.IsAvailable
                            ? ResourceCommandState.Enabled
                            : ResourceCommandState.Disabled;
                    }
                })
            .WithCommand(
                CaptureScreenshotCommandName,
                BrowserCommandStrings.CaptureScreenshotName,
                async context =>
                {
                    try
                    {
                        var sessionManager = context.Services.GetRequiredService<IBrowserLogsSessionManager>();
                        var result = await sessionManager.CaptureScreenshotAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false);
                        var resultJson = JsonSerializer.Serialize(
                            new BrowserLogsScreenshotCommandResult(
                                result.Artifact.ResourceName,
                                result.SessionId,
                                result.Browser,
                                result.BrowserExecutable,
                                result.BrowserHostOwnership,
                                result.ProcessId,
                                result.TargetId,
                                result.TargetUrl.ToString(),
                                result.Artifact.FilePath,
                                result.Artifact.MimeType,
                                result.Artifact.SizeBytes,
                                result.Artifact.CreatedAt),
                            s_commandResultJsonOptions);
 
                        return CommandResults.Success(
                            $"Captured screenshot to '{result.Artifact.FilePath}'.",
                            new CommandResultData
                            {
                                Value = resultJson,
                                Format = CommandResultFormat.Json,
                                DisplayImmediately = true
                            });
                    }
                    catch (Exception ex)
                    {
                        return CommandResults.Failure(ex.Message);
                    }
                },
                new CommandOptions
                {
                    Description = BrowserCommandStrings.CaptureScreenshotDescription,
                    IconName = "Camera",
                    IconVariant = IconVariant.Regular,
                    UpdateState = context =>
                    {
                        var childState = context.ResourceSnapshot.State?.Text;
                        return childState == KnownResourceStates.Running
                            ? ResourceCommandState.Enabled
                            : ResourceCommandState.Disabled;
                    }
                });
 
        builder.OnBeforeResourceStarted((_, @event, _) => RefreshBrowserLogsResourceAsync(@event.Services.GetRequiredService<ResourceNotificationService>()))
               .OnResourceReady((_, @event, _) => RefreshBrowserLogsResourceAsync(@event.Services.GetRequiredService<ResourceNotificationService>()))
               .OnResourceStopped((_, @event, _) => RefreshBrowserLogsResourceAsync(@event.Services.GetRequiredService<ResourceNotificationService>()));
 
        return builder;
 
        Task RefreshBrowserLogsResourceAsync(ResourceNotificationService notifications) =>
            notifications.PublishUpdateAsync(browserLogsResource, snapshot => snapshot);
 
        static ImmutableArray<ResourcePropertySnapshot> CreateInitialProperties(string resourceName, BrowserConfiguration configuration)
        {
            List<ResourcePropertySnapshot> properties =
            [
                new(CustomResourceKnownProperties.Source, resourceName),
                new(BrowserPropertyName, configuration.Browser),
                new(UserDataModePropertyName, configuration.UserDataMode.ToString())
            ];
 
            if (configuration.Profile is { } profile)
            {
                properties.Add(new ResourcePropertySnapshot(ProfilePropertyName, profile));
            }
 
            properties.AddRange(
            [
                new ResourcePropertySnapshot(ActiveSessionCountPropertyName, 0),
                new ResourcePropertySnapshot(ActiveSessionsPropertyName, "None"),
                new ResourcePropertySnapshot(BrowserSessionsPropertyName, "[]"),
                new ResourcePropertySnapshot(TotalSessionsLaunchedPropertyName, 0)
            ]);
 
            return [.. properties];
        }
 
        static Uri ResolveBrowserUrl(T resource)
        {
            EndpointAnnotation? endpointAnnotation = null;
            if (resource.TryGetAnnotationsOfType<EndpointAnnotation>(out var endpoints))
            {
                endpointAnnotation = endpoints.FirstOrDefault(e => e.UriScheme == "https")
                    ?? endpoints.FirstOrDefault(e => e.UriScheme == "http");
            }
 
            if (endpointAnnotation is null)
            {
                throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, BrowserMessageStrings.BrowserLogsResourceMissingHttpEndpoint, resource.Name));
            }
 
            var endpointReference = resource.GetEndpoint(endpointAnnotation.Name);
            if (!endpointReference.IsAllocated)
            {
                throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, BrowserMessageStrings.BrowserLogsEndpointNotAllocated, endpointAnnotation.Name, resource.Name));
            }
 
            return new Uri(endpointReference.Url, UriKind.Absolute);
        }
 
        static void ThrowIfBlankWhenSpecified(string? value, string paramName)
        {
            if (value is not null)
            {
                ArgumentException.ThrowIfNullOrWhiteSpace(value, paramName);
            }
        }
    }
 
    private sealed record BrowserLogsScreenshotCommandResult(
        string ResourceName,
        string SessionId,
        string Browser,
        string BrowserExecutable,
        string BrowserHostOwnership,
        int? ProcessId,
        string TargetId,
        string TargetUrl,
        string Path,
        string MimeType,
        long SizeBytes,
        DateTimeOffset CreatedAt);
}