// 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<Projects.WebFrontend>("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);
}