From eab2c02c1917302af77201a5956ada0f8df739cc Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 19 Apr 2026 10:33:16 -0700 Subject: [PATCH 1/8] Add tracked browser logs and network capture Add a child browser logs resource that can launch tracked Chromium sessions, stream console and network request events, and expose the builder through polyglot export.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserTelemetry.AppHost/AppHost.cs | 3 +- .../BrowserTelemetry.Web/Pages/Index.cshtml | 39 +- .../BrowserTelemetry.Web/Scripts/index.js | 98 +- .../BrowserLogsBuilderExtensions.cs | 169 ++ src/Aspire.Hosting/BrowserLogsResource.cs | 14 + .../BrowserLogsSessionManager.cs | 1463 +++++++++++++++++ .../IBrowserLogsSessionManager.cs | 9 + .../Resources/CommandStrings.Designer.cs | 18 + .../Resources/CommandStrings.resx | 6 + .../Resources/xlf/CommandStrings.cs.xlf | 10 + .../Resources/xlf/CommandStrings.de.xlf | 10 + .../Resources/xlf/CommandStrings.es.xlf | 10 + .../Resources/xlf/CommandStrings.fr.xlf | 10 + .../Resources/xlf/CommandStrings.it.xlf | 10 + .../Resources/xlf/CommandStrings.ja.xlf | 10 + .../Resources/xlf/CommandStrings.ko.xlf | 10 + .../Resources/xlf/CommandStrings.pl.xlf | 10 + .../Resources/xlf/CommandStrings.pt-BR.xlf | 10 + .../Resources/xlf/CommandStrings.ru.xlf | 10 + .../Resources/xlf/CommandStrings.tr.xlf | 10 + .../Resources/xlf/CommandStrings.zh-Hans.xlf | 10 + .../Resources/xlf/CommandStrings.zh-Hant.xlf | 10 + ...TwoPassScanningGeneratedAspire.verified.go | 120 ++ ...oPassScanningGeneratedAspire.verified.java | 122 +- ...TwoPassScanningGeneratedAspire.verified.py | 73 + ...TwoPassScanningGeneratedAspire.verified.rs | 96 ++ .../AtsTypeScriptCodeGeneratorTests.cs | 13 + ...ContainerResourceCapabilities.verified.txt | 16 +- ...TwoPassScanningGeneratedAspire.verified.ts | 220 +++ .../BrowserLogsBuilderExtensionsTests.cs | 455 +++++ 30 files changed, 3056 insertions(+), 8 deletions(-) create mode 100644 src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs create mode 100644 src/Aspire.Hosting/BrowserLogsResource.cs create mode 100644 src/Aspire.Hosting/BrowserLogsSessionManager.cs create mode 100644 src/Aspire.Hosting/IBrowserLogsSessionManager.cs create mode 100644 tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs diff --git a/playground/BrowserTelemetry/BrowserTelemetry.AppHost/AppHost.cs b/playground/BrowserTelemetry/BrowserTelemetry.AppHost/AppHost.cs index b6623f591b4..9a17d171ca3 100644 --- a/playground/BrowserTelemetry/BrowserTelemetry.AppHost/AppHost.cs +++ b/playground/BrowserTelemetry/BrowserTelemetry.AppHost/AppHost.cs @@ -2,10 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. var builder = DistributedApplication.CreateBuilder(args); +var browser = OperatingSystem.IsWindows() ? "msedge" : "chrome"; builder.AddProject("web") .WithExternalHttpEndpoints() - .WithReplicas(2); + .WithBrowserLogs(browser); #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging diff --git a/playground/BrowserTelemetry/BrowserTelemetry.Web/Pages/Index.cshtml b/playground/BrowserTelemetry/BrowserTelemetry.Web/Pages/Index.cshtml index c19b38067d8..0d3222fe293 100644 --- a/playground/BrowserTelemetry/BrowserTelemetry.Web/Pages/Index.cshtml +++ b/playground/BrowserTelemetry/BrowserTelemetry.Web/Pages/Index.cshtml @@ -1,4 +1,41 @@ @page
-

Hello world

+
+
+

Browser logs demo

+

+ Use the web-browser-logs resource in the dashboard to open a tracked browser session, then + use the buttons below to emit browser-side console logs, network requests, and unhandled failures. +

+
+
+
+ Waiting for browser interaction. +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
diff --git a/playground/BrowserTelemetry/BrowserTelemetry.Web/Scripts/index.js b/playground/BrowserTelemetry/BrowserTelemetry.Web/Scripts/index.js index 4bae710b130..374630057b5 100644 --- a/playground/BrowserTelemetry/BrowserTelemetry.Web/Scripts/index.js +++ b/playground/BrowserTelemetry/BrowserTelemetry.Web/Scripts/index.js @@ -37,14 +37,104 @@ export function initializeTelemetry(otlpUrl, headers, resourceAttributes) { }); } +document.addEventListener('DOMContentLoaded', () => { + wireButton('emit-console-log', () => { + setStatus('Emitted console.log.'); + console.log('BrowserTelemetry demo: console.log fired from the tracked browser.'); + }); + + wireButton('emit-console-warn', () => { + setStatus('Emitted console.warn.'); + console.warn('BrowserTelemetry demo: console.warn fired from the tracked browser.'); + }); + + wireButton('emit-console-error', () => { + setStatus('Emitted console.error.'); + console.error('BrowserTelemetry demo: console.error fired from the tracked browser.'); + }); + + wireButton('emit-unhandled-exception', () => { + setStatus('Scheduling an unhandled exception.'); + window.setTimeout(() => { + throw new Error('BrowserTelemetry demo: unhandled exception from tracked browser.'); + }, 50); + }); + + wireButton('emit-unhandled-rejection', () => { + setStatus('Scheduling an unhandled promise rejection.'); + Promise.reject(new Error('BrowserTelemetry demo: unhandled promise rejection from tracked browser.')); + }); + + wireButton('emit-successful-fetch', async () => { + setStatus('Issuing a successful fetch request.'); + const response = await fetch(`${window.location.pathname}?browserNetworkSuccess=${Date.now()}`, { + cache: 'no-store', + headers: { + 'x-browser-telemetry-demo': 'success' + } + }); + + setStatus(`Successful fetch completed with status ${response.status}.`); + console.info(`BrowserTelemetry demo: successful fetch completed with status ${response.status}.`); + }); + + wireButton('emit-failing-fetch', async () => { + setStatus('Issuing a failing fetch request.'); + + try { + await fetch(`https://127.0.0.1:1/browser-network-failure?ts=${Date.now()}`, { + cache: 'no-store' + }); + } catch (error) { + setStatus('Failing fetch triggered.'); + console.warn('BrowserTelemetry demo: failing fetch triggered for tracked browser network capture.', error); + } + }); + + window.setTimeout(() => { + setStatus('Tracked browser demo ready.'); + console.info('BrowserTelemetry demo: page loaded and ready for tracked browser logging.'); + }, 250); +}); + function parseDelimitedValues(s) { - const headers = s.split(','); // Split by comma const o = {}; + if (!s || !s.trim()) { + return o; + } + + s.split(',').forEach(header => { + const separatorIndex = header.indexOf('='); + if (separatorIndex === -1) { + return; + } + + const key = header.slice(0, separatorIndex).trim(); + if (!key) { + return; + } - headers.forEach(header => { - const [key, value] = header.split('='); // Split by equal sign - o[key.trim()] = value.trim(); // Add to the object, trimming spaces + const value = header.slice(separatorIndex + 1).trim(); + o[key] = value; }); return o; } + +function wireButton(id, callback) { + const button = document.getElementById(id); + if (!button) { + return; + } + + button.addEventListener('click', callback); +} + +function setStatus(message) { + const status = document.getElementById('browser-log-status'); + if (!status) { + return; + } + + status.textContent = message; +} diff --git a/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs new file mode 100644 index 00000000000..28ff0b412aa --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs @@ -0,0 +1,169 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Resources; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aspire.Hosting; + +/// +/// Extension methods for adding tracked browser log resources to browser-based application resources. +/// +public static class BrowserLogsBuilderExtensions +{ + internal const string BrowserResourceType = "BrowserLogs"; + internal const string BrowserPropertyName = "Browser"; + internal const string BrowserExecutablePropertyName = "Browser executable"; + internal const string TargetUrlPropertyName = "Target URL"; + internal const string ActiveSessionsPropertyName = "Active sessions"; + internal const string ActiveSessionCountPropertyName = "Active session count"; + internal const string TotalSessionsLaunchedPropertyName = "Total sessions launched"; + internal const string LastSessionPropertyName = "Last session"; + internal const string OpenTrackedBrowserCommandName = "open-tracked-browser"; + + /// + /// Adds a child resource that can open the application's primary browser endpoint in a tracked browser session and + /// surface browser console output in the dashboard console logs. + /// + /// The type of resource being configured. + /// The resource builder. + /// + /// The browser to launch. Defaults to "msedge". Supported values include logical browser names such as + /// "msedge" and "chrome", or an explicit browser executable path. + /// + /// A reference to the original for further chaining. + /// + /// + /// This method adds a child browser logs resource beneath the parent resource represented by . + /// The child resource exposes a dashboard command that launches a Chromium-based browser in a tracked mode, attaches to + /// the browser's debugging protocol, and forwards browser console, error, and exception output to the child resource's + /// console log stream. + /// + /// + /// 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. + /// + /// + /// + /// Add tracked browser logs for a web front end: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// builder.AddProject<Projects.WebFrontend>("web") + /// .WithExternalHttpEndpoints() + /// .WithBrowserLogs(); + /// + /// + [AspireExport(Description = "Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.")] + public static IResourceBuilder WithBrowserLogs(this IResourceBuilder builder, string browser = "msedge") + where T : IResourceWithEndpoints + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(browser); + + builder.ApplicationBuilder.Services.TryAddSingleton(); + + var parentResource = builder.Resource; + var browserLogsResource = new BrowserLogsResource($"{parentResource.Name}-browser-logs", parentResource, browser); + + builder.ApplicationBuilder.AddResource(browserLogsResource) + .WithParentRelationship(parentResource) + .ExcludeFromManifest() + .WithIconName("GlobeDesktop") + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = BrowserResourceType, + CreationTimeStamp = DateTime.UtcNow, + State = KnownResourceStates.NotStarted, + Properties = + [ + new ResourcePropertySnapshot(CustomResourceKnownProperties.Source, parentResource.Name), + new ResourcePropertySnapshot(BrowserPropertyName, browser), + new ResourcePropertySnapshot(ActiveSessionCountPropertyName, 0), + new ResourcePropertySnapshot(ActiveSessionsPropertyName, "None"), + new ResourcePropertySnapshot(TotalSessionsLaunchedPropertyName, 0) + ] + }) + .WithCommand( + OpenTrackedBrowserCommandName, + CommandStrings.OpenTrackedBrowserName, + async context => + { + try + { + var url = ResolveBrowserUrl(parentResource); + var sessionManager = context.ServiceProvider.GetRequiredService(); + await sessionManager.StartSessionAsync(browserLogsResource, context.ResourceName, url, context.CancellationToken).ConfigureAwait(false); + return CommandResults.Success(); + } + catch (Exception ex) + { + return CommandResults.Failure(ex.Message); + } + }, + new CommandOptions + { + Description = CommandStrings.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.ServiceProvider.GetRequiredService(); + foreach (var resourceName in parentResource.GetResolvedResourceNames()) + { + if (resourceNotifications.TryGetCurrentState(resourceName, out var resourceEvent)) + { + var parentState = resourceEvent.Snapshot.State?.Text; + if (parentState == KnownResourceStates.Running || parentState == KnownResourceStates.RuntimeUnhealthy) + { + return ResourceCommandState.Enabled; + } + } + } + + return ResourceCommandState.Disabled; + } + }); + + builder.OnBeforeResourceStarted((_, @event, _) => RefreshBrowserLogsResourceAsync(@event.Services.GetRequiredService())) + .OnResourceReady((_, @event, _) => RefreshBrowserLogsResourceAsync(@event.Services.GetRequiredService())) + .OnResourceStopped((_, @event, _) => RefreshBrowserLogsResourceAsync(@event.Services.GetRequiredService())); + + return builder; + + Task RefreshBrowserLogsResourceAsync(ResourceNotificationService notifications) => + notifications.PublishUpdateAsync(browserLogsResource, snapshot => snapshot); + + static Uri ResolveBrowserUrl(T resource) + { + EndpointAnnotation? endpointAnnotation = null; + if (resource.TryGetAnnotationsOfType(out var endpoints)) + { + endpointAnnotation = endpoints.FirstOrDefault(e => e.UriScheme == "https") + ?? endpoints.FirstOrDefault(e => e.UriScheme == "http"); + } + + if (endpointAnnotation is null) + { + throw new InvalidOperationException($"Resource '{resource.Name}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to."); + } + + var endpointReference = resource.GetEndpoint(endpointAnnotation.Name); + if (!endpointReference.IsAllocated) + { + throw new InvalidOperationException($"Endpoint '{endpointAnnotation.Name}' for resource '{resource.Name}' has not been allocated yet."); + } + + return new Uri(endpointReference.Url, UriKind.Absolute); + } + } +} diff --git a/src/Aspire.Hosting/BrowserLogsResource.cs b/src/Aspire.Hosting/BrowserLogsResource.cs new file mode 100644 index 00000000000..3235f6e362e --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogsResource.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +internal sealed class BrowserLogsResource(string name, IResourceWithEndpoints parentResource, string browser) + : Resource(name) +{ + public IResourceWithEndpoints ParentResource { get; } = parentResource; + + public string Browser { get; } = browser; +} diff --git a/src/Aspire.Hosting/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogsSessionManager.cs new file mode 100644 index 00000000000..63a6660da2c --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogsSessionManager.cs @@ -0,0 +1,1463 @@ +// 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 ASPIREFILESYSTEM001 // Type is for evaluation purposes only + +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Http.Json; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Text.Json; +using System.Text.Json.Serialization; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Logging; +using HealthStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus; + +namespace Aspire.Hosting; + +internal interface IBrowserLogsRunningSession +{ + string SessionId { get; } + + string BrowserExecutable { get; } + + int ProcessId { get; } + + DateTime StartedAt { get; } + + void StartCompletionObserver(Func onCompleted); + + Task StopAsync(CancellationToken cancellationToken); +} + +internal interface IBrowserLogsRunningSessionFactory +{ + Task StartSessionAsync( + BrowserLogsResource resource, + string resourceName, + Uri url, + string sessionId, + ILogger resourceLogger, + CancellationToken cancellationToken); +} + +internal sealed class BrowserLogsSessionManager : IBrowserLogsSessionManager, IAsyncDisposable +{ + private readonly ResourceLoggerService _resourceLoggerService; + private readonly ResourceNotificationService _resourceNotificationService; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly IBrowserLogsRunningSessionFactory _sessionFactory; + private readonly ConcurrentDictionary _resourceStates = new(StringComparer.Ordinal); + + public BrowserLogsSessionManager( + IFileSystemService fileSystemService, + ResourceLoggerService resourceLoggerService, + ResourceNotificationService resourceNotificationService, + TimeProvider timeProvider, + ILogger logger) + : this( + resourceLoggerService, + resourceNotificationService, + timeProvider, + logger, + new BrowserLogsRunningSessionFactory(fileSystemService, logger)) + { + } + + internal BrowserLogsSessionManager( + ResourceLoggerService resourceLoggerService, + ResourceNotificationService resourceNotificationService, + TimeProvider timeProvider, + ILogger logger, + IBrowserLogsRunningSessionFactory sessionFactory) + { + _resourceLoggerService = resourceLoggerService; + _resourceNotificationService = resourceNotificationService; + _timeProvider = timeProvider; + _logger = logger; + _sessionFactory = sessionFactory; + } + + public async Task StartSessionAsync(BrowserLogsResource resource, string resourceName, Uri url, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + ArgumentNullException.ThrowIfNull(url); + + var resourceState = _resourceStates.GetOrAdd(resourceName, static _ => new ResourceSessionState()); + await resourceState.Lock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + var sessionSequence = ++resourceState.TotalSessionsLaunched; + var sessionId = $"session-{sessionSequence:0000}"; + resourceState.LastSessionId = sessionId; + resourceState.LastTargetUrl = url.ToString(); + + var resourceLogger = _resourceLoggerService.GetLogger(resourceName); + resourceLogger.LogInformation("[{SessionId}] Opening tracked browser for '{Url}' using '{Browser}'.", sessionId, url, resource.Browser); + + var launchStartedAt = _timeProvider.GetUtcNow().UtcDateTime; + var pendingSession = new PendingBrowserSession(sessionId, launchStartedAt, url); + + await PublishResourceSnapshotAsync( + resource, + resourceName, + resourceState, + stateText: KnownResourceStates.Starting, + stateStyle: KnownResourceStateStyles.Info, + pendingSession, + stopTimeStamp: null, + exitCode: null).ConfigureAwait(false); + + IBrowserLogsRunningSession session; + try + { + session = await _sessionFactory.StartSessionAsync( + resource, + resourceName, + url, + sessionId, + resourceLogger, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + resourceLogger.LogError(ex, "[{SessionId}] Failed to open tracked browser for '{Url}'.", sessionId, url); + + await PublishResourceSnapshotAsync( + resource, + resourceName, + resourceState, + stateText: resourceState.ActiveSessions.Count > 0 ? KnownResourceStates.Running : KnownResourceStates.FailedToStart, + stateStyle: resourceState.ActiveSessions.Count > 0 ? KnownResourceStateStyles.Success : KnownResourceStateStyles.Error, + pendingSession: null, + stopTimeStamp: resourceState.ActiveSessions.Count == 0 ? _timeProvider.GetUtcNow().UtcDateTime : null, + exitCode: null, + fallbackStartTimeStamp: launchStartedAt).ConfigureAwait(false); + + throw; + } + + resourceState.LastBrowserExecutable = session.BrowserExecutable; + resourceState.ActiveSessions[session.SessionId] = new ActiveBrowserSession( + session.SessionId, + session.BrowserExecutable, + session.ProcessId, + session.StartedAt, + url, + session); + + session.StartCompletionObserver(async (exitCode, error) => + { + await HandleSessionCompletedAsync(resource, resourceName, resourceState, session.SessionId, exitCode, error).ConfigureAwait(false); + }); + + await PublishResourceSnapshotAsync( + resource, + resourceName, + resourceState, + stateText: KnownResourceStates.Running, + stateStyle: KnownResourceStateStyles.Success, + pendingSession: null, + stopTimeStamp: null, + exitCode: null).ConfigureAwait(false); + } + finally + { + resourceState.Lock.Release(); + } + } + + public async ValueTask DisposeAsync() + { + var sessionsToStop = new List(); + + foreach (var resourceState in _resourceStates.Values) + { + await resourceState.Lock.WaitAsync(CancellationToken.None).ConfigureAwait(false); + + try + { + sessionsToStop.AddRange(resourceState.ActiveSessions.Values.Select(static activeSession => activeSession.Session)); + } + finally + { + resourceState.Lock.Release(); + } + } + + foreach (var session in sessionsToStop) + { + await session.StopAsync(CancellationToken.None).ConfigureAwait(false); + } + + foreach (var (_, resourceState) in _resourceStates) + { + resourceState.Lock.Dispose(); + } + } + + private async Task HandleSessionCompletedAsync( + BrowserLogsResource resource, + string resourceName, + ResourceSessionState resourceState, + string sessionId, + int exitCode, + Exception? error) + { + await resourceState.Lock.WaitAsync(CancellationToken.None).ConfigureAwait(false); + + try + { + if (!resourceState.ActiveSessions.Remove(sessionId)) + { + return; + } + + var completedAt = _timeProvider.GetUtcNow().UtcDateTime; + var hasActiveSessions = resourceState.ActiveSessions.Count > 0; + var (stateText, stateStyle) = hasActiveSessions + ? (KnownResourceStates.Running, KnownResourceStateStyles.Success) + : error switch + { + not null => (KnownResourceStates.Exited, KnownResourceStateStyles.Error), + null when exitCode == 0 => (KnownResourceStates.Finished, KnownResourceStateStyles.Success), + _ => (KnownResourceStates.Exited, KnownResourceStateStyles.Error) + }; + + await PublishResourceSnapshotAsync( + resource, + resourceName, + resourceState, + stateText, + stateStyle, + pendingSession: null, + stopTimeStamp: hasActiveSessions ? null : completedAt, + exitCode: hasActiveSessions ? null : exitCode).ConfigureAwait(false); + } + finally + { + resourceState.Lock.Release(); + } + } + + private Task PublishResourceSnapshotAsync( + BrowserLogsResource resource, + string resourceName, + ResourceSessionState resourceState, + string stateText, + string stateStyle, + PendingBrowserSession? pendingSession, + DateTime? stopTimeStamp, + int? exitCode, + DateTime? fallbackStartTimeStamp = null) + { + var startTimeStamp = GetStartTimeStamp(resourceState, pendingSession?.StartedAt ?? fallbackStartTimeStamp); + var healthReports = GetHealthReports(resourceState, pendingSession); + var propertyUpdates = GetPropertyUpdates(resourceState); + + return _resourceNotificationService.PublishUpdateAsync(resource, resourceName, snapshot => snapshot with + { + StartTimeStamp = startTimeStamp ?? snapshot.StartTimeStamp, + StopTimeStamp = resourceState.ActiveSessions.Count > 0 || pendingSession is not null ? null : stopTimeStamp, + ExitCode = resourceState.ActiveSessions.Count > 0 || pendingSession is not null ? null : exitCode, + State = new ResourceStateSnapshot(stateText, stateStyle), + Properties = snapshot.Properties.SetResourcePropertyRange(propertyUpdates), + HealthReports = healthReports + }); + } + + private ImmutableArray GetHealthReports(ResourceSessionState resourceState, PendingBrowserSession? pendingSession) + { + var runAt = _timeProvider.GetUtcNow().UtcDateTime; + var reports = new List(resourceState.ActiveSessions.Count + (pendingSession is null ? 0 : 1)); + + foreach (var session in resourceState.ActiveSessions.Values.OrderBy(static session => session.SessionId, StringComparer.Ordinal)) + { + reports.Add(new HealthReportSnapshot( + session.SessionId, + HealthStatus.Healthy, + $"PID {session.ProcessId} targeting {session.TargetUrl}", + null) + { + LastRunAt = runAt + }); + } + + if (pendingSession is not null) + { + reports.Add(new HealthReportSnapshot( + pendingSession.SessionId, + Status: null, + Description: $"Launching tracked browser for {pendingSession.TargetUrl}.", + ExceptionText: null) + { + LastRunAt = runAt + }); + } + + return [.. reports]; + } + + private static IEnumerable GetPropertyUpdates(ResourceSessionState resourceState) + { + yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, resourceState.ActiveSessions.Count); + yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.ActiveSessionsPropertyName, FormatActiveSessions(resourceState.ActiveSessions.Values)); + yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.TotalSessionsLaunchedPropertyName, resourceState.TotalSessionsLaunched); + + if (resourceState.LastSessionId is not null) + { + yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.LastSessionPropertyName, resourceState.LastSessionId); + } + + if (resourceState.LastTargetUrl is not null) + { + yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.TargetUrlPropertyName, resourceState.LastTargetUrl); + } + + if (resourceState.LastBrowserExecutable is not null) + { + yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.BrowserExecutablePropertyName, resourceState.LastBrowserExecutable); + } + } + + private static DateTime? GetStartTimeStamp(ResourceSessionState resourceState, DateTime? fallbackStartTimeStamp) + { + if (resourceState.ActiveSessions.Count > 0) + { + return resourceState.ActiveSessions.Values.MinBy(static session => session.StartedAt)?.StartedAt; + } + + return fallbackStartTimeStamp; + } + + private static string FormatActiveSessions(IEnumerable sessions) + { + var activeSessions = sessions + .OrderBy(static session => session.SessionId, StringComparer.Ordinal) + .Select(static session => $"{session.SessionId} (PID {session.ProcessId})") + .ToArray(); + + return activeSessions.Length > 0 + ? string.Join(", ", activeSessions) + : "None"; + } + + internal sealed class BrowserEventLogger(string sessionId, ILogger resourceLogger) + { + private readonly string _sessionId = sessionId; + private readonly ILogger _resourceLogger = resourceLogger; + private readonly Dictionary _networkRequests = new(StringComparer.Ordinal); + + public void HandleEvent(string method, JsonElement parameters) + { + switch (method) + { + case "Runtime.consoleAPICalled": + LogConsoleMessage(parameters); + break; + case "Runtime.exceptionThrown": + LogUnhandledException(parameters); + break; + case "Log.entryAdded": + LogEntryAdded(parameters); + break; + case "Network.requestWillBeSent": + TrackRequestStarted(parameters); + break; + case "Network.responseReceived": + TrackResponseReceived(parameters); + break; + case "Network.loadingFinished": + TrackRequestCompleted(parameters); + break; + case "Network.loadingFailed": + TrackRequestFailed(parameters); + break; + } + } + + private void LogConsoleMessage(JsonElement parameters) + { + var level = TryGetString(parameters, "type") ?? "log"; + var message = parameters.TryGetProperty("args", out var argsElement) && argsElement.ValueKind == JsonValueKind.Array + ? string.Join(" ", argsElement.EnumerateArray().Select(FormatRemoteObject).Where(static value => !string.IsNullOrEmpty(value))) + : string.Empty; + + WriteLog(MapConsoleLevel(level), $"[console.{level}] {message}".TrimEnd()); + } + + private void LogUnhandledException(JsonElement parameters) + { + if (!parameters.TryGetProperty("exceptionDetails", out var exceptionDetails)) + { + return; + } + + var message = exceptionDetails.TryGetProperty("exception", out var exception) && exception.TryGetProperty("description", out var description) + ? description.GetString() + : exceptionDetails.TryGetProperty("text", out var text) + ? text.GetString() + : "Unhandled browser exception"; + + var location = GetLocationSuffix(exceptionDetails); + WriteLog(LogLevel.Error, $"[exception] {message}{location}"); + } + + private void LogEntryAdded(JsonElement parameters) + { + if (!parameters.TryGetProperty("entry", out var entry)) + { + return; + } + + var level = TryGetString(entry, "level") ?? "info"; + var text = TryGetString(entry, "text") ?? string.Empty; + var location = GetLocationSuffix(entry); + + WriteLog(MapLogEntryLevel(level), $"[log.{level}] {text}{location}".TrimEnd()); + } + + private void TrackRequestStarted(JsonElement parameters) + { + if (TryGetString(parameters, "requestId") is not { Length: > 0 } requestId) + { + return; + } + + if (!parameters.TryGetProperty("request", out var request)) + { + return; + } + + var url = TryGetString(request, "url"); + var method = TryGetString(request, "method"); + if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(method)) + { + return; + } + + var startTimestamp = TryGetDouble(parameters, "timestamp"); + + if (parameters.TryGetProperty("redirectResponse", out var redirectResponse) && + _networkRequests.Remove(requestId, out var redirectedRequest)) + { + UpdateResponse(redirectedRequest, redirectResponse); + LogCompletedRequest(redirectedRequest, startTimestamp, encodedDataLength: null, redirectUrl: url); + } + + var resourceType = NormalizeResourceType(TryGetString(parameters, "type")); + _networkRequests[requestId] = new BrowserNetworkRequestState + { + Method = method, + Url = url, + ResourceType = resourceType, + StartTimestamp = startTimestamp + }; + } + + private void TrackResponseReceived(JsonElement parameters) + { + if (TryGetString(parameters, "requestId") is not { Length: > 0 } requestId || + !_networkRequests.TryGetValue(requestId, out var request)) + { + return; + } + + if (parameters.TryGetProperty("response", out var response)) + { + UpdateResponse(request, response); + } + + if (TryGetString(parameters, "type") is { Length: > 0 } resourceType) + { + request.ResourceType = NormalizeResourceType(resourceType); + } + } + + private void TrackRequestCompleted(JsonElement parameters) + { + if (TryGetString(parameters, "requestId") is not { Length: > 0 } requestId || + !_networkRequests.Remove(requestId, out var request)) + { + return; + } + + LogCompletedRequest(request, TryGetDouble(parameters, "timestamp"), TryGetDouble(parameters, "encodedDataLength"), redirectUrl: null); + } + + private void TrackRequestFailed(JsonElement parameters) + { + if (TryGetString(parameters, "requestId") is not { Length: > 0 } requestId || + !_networkRequests.Remove(requestId, out var request)) + { + return; + } + + var errorText = TryGetString(parameters, "errorText") ?? "Request failed"; + var canceled = TryGetBoolean(parameters, "canceled"); + var blockedReason = TryGetString(parameters, "blockedReason"); + var details = new List(); + + if (FormatDuration(request.StartTimestamp, TryGetDouble(parameters, "timestamp")) is { Length: > 0 } duration) + { + details.Add(duration); + } + + if (canceled == true) + { + details.Add("canceled"); + } + + if (!string.IsNullOrEmpty(blockedReason)) + { + details.Add($"blocked={blockedReason}"); + } + + WriteLog(LogLevel.Warning, $"[network.{request.ResourceType}] {request.Method} {request.Url} failed: {errorText}{FormatDetails(details)}"); + } + + private void LogCompletedRequest(BrowserNetworkRequestState request, double? completedTimestamp, double? encodedDataLength, string? redirectUrl) + { + var details = new List(); + + if (FormatDuration(request.StartTimestamp, completedTimestamp) is { Length: > 0 } duration) + { + details.Add(duration); + } + + if (encodedDataLength is > 0) + { + details.Add($"{Math.Round(encodedDataLength.Value, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture)} B"); + } + + if (request.FromDiskCache == true) + { + details.Add("disk-cache"); + } + + if (request.FromServiceWorker == true) + { + details.Add("service-worker"); + } + + if (!string.IsNullOrEmpty(redirectUrl)) + { + details.Add($"redirect to {redirectUrl}"); + } + + var statusText = request.StatusCode is int statusCode + ? string.IsNullOrEmpty(request.StatusText) + ? $" -> {statusCode}" + : $" -> {statusCode} {request.StatusText}" + : redirectUrl is null + ? " completed" + : " -> redirect"; + + WriteLog(LogLevel.Information, $"[network.{request.ResourceType}] {request.Method} {request.Url}{statusText}{FormatDetails(details)}"); + } + + private static void UpdateResponse(BrowserNetworkRequestState request, JsonElement response) + { + request.Url = TryGetString(response, "url") ?? request.Url; + request.StatusCode = TryGetInt32(response, "status"); + request.StatusText = TryGetString(response, "statusText"); + request.FromDiskCache = TryGetBoolean(response, "fromDiskCache"); + request.FromServiceWorker = TryGetBoolean(response, "fromServiceWorker"); + } + + private void WriteLog(LogLevel logLevel, string message) + { + var sessionMessage = $"[{_sessionId}] {message}"; + + switch (logLevel) + { + case LogLevel.Error: + case LogLevel.Critical: + _resourceLogger.LogError("{Message}", sessionMessage); + break; + case LogLevel.Warning: + _resourceLogger.LogWarning("{Message}", sessionMessage); + break; + case LogLevel.Debug: + case LogLevel.Trace: + _resourceLogger.LogDebug("{Message}", sessionMessage); + break; + default: + _resourceLogger.LogInformation("{Message}", sessionMessage); + break; + } + } + + private static string NormalizeResourceType(string? resourceType) => + string.IsNullOrEmpty(resourceType) + ? "request" + : resourceType.ToLowerInvariant(); + + private static string? TryGetString(JsonElement element, string propertyName) => + element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static double? TryGetDouble(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Number) + { + return null; + } + + return property.TryGetDouble(out var value) ? value : null; + } + + private static int? TryGetInt32(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Number) + { + return null; + } + + if (property.TryGetInt32(out var value)) + { + return value; + } + + return property.TryGetDouble(out var doubleValue) + ? (int)Math.Round(doubleValue, MidpointRounding.AwayFromZero) + : null; + } + + private static bool? TryGetBoolean(JsonElement element, string propertyName) => + element.TryGetProperty(propertyName, out var property) && (property.ValueKind == JsonValueKind.True || property.ValueKind == JsonValueKind.False) + ? property.GetBoolean() + : null; + + private static string? FormatDuration(double? startTimestamp, double? endTimestamp) + { + if (startTimestamp is null || endTimestamp is null || endTimestamp < startTimestamp) + { + return null; + } + + var durationMs = Math.Round((endTimestamp.Value - startTimestamp.Value) * 1000, MidpointRounding.AwayFromZero); + return $"{durationMs.ToString(CultureInfo.InvariantCulture)} ms"; + } + + private static string FormatDetails(IReadOnlyList details) => + details.Count > 0 + ? $" ({string.Join(", ", details)})" + : string.Empty; + + private static LogLevel MapConsoleLevel(string level) => level switch + { + "error" or "assert" => LogLevel.Error, + "warning" or "warn" => LogLevel.Warning, + "debug" => LogLevel.Debug, + _ => LogLevel.Information + }; + + private static LogLevel MapLogEntryLevel(string level) => level switch + { + "error" => LogLevel.Error, + "warning" => LogLevel.Warning, + "verbose" => LogLevel.Debug, + _ => LogLevel.Information + }; + + private static string FormatRemoteObject(JsonElement remoteObject) + { + if (remoteObject.TryGetProperty("value", out var value)) + { + return value.ValueKind switch + { + JsonValueKind.String => value.GetString() ?? string.Empty, + JsonValueKind.Null => "null", + JsonValueKind.True => bool.TrueString, + JsonValueKind.False => bool.FalseString, + _ => value.GetRawText() + }; + } + + if (remoteObject.TryGetProperty("unserializableValue", out var unserializableValue)) + { + return unserializableValue.GetString() ?? string.Empty; + } + + if (remoteObject.TryGetProperty("description", out var description)) + { + return description.GetString() ?? string.Empty; + } + + return remoteObject.GetRawText(); + } + + private static string GetLocationSuffix(JsonElement details) + { + if (!details.TryGetProperty("url", out var urlElement)) + { + return string.Empty; + } + + var url = urlElement.GetString(); + if (string.IsNullOrEmpty(url)) + { + return string.Empty; + } + + var lineNumber = details.TryGetProperty("lineNumber", out var lineElement) ? lineElement.GetInt32() + 1 : 0; + var columnNumber = details.TryGetProperty("columnNumber", out var columnElement) ? columnElement.GetInt32() + 1 : 0; + + if (lineNumber > 0 && columnNumber > 0) + { + return $" ({url}:{lineNumber}:{columnNumber})"; + } + + return $" ({url})"; + } + + private sealed class BrowserNetworkRequestState + { + public required string Method { get; set; } + + public required string Url { get; set; } + + public required string ResourceType { get; set; } + + public double? StartTimestamp { get; set; } + + public int? StatusCode { get; set; } + + public string? StatusText { get; set; } + + public bool? FromDiskCache { get; set; } + + public bool? FromServiceWorker { get; set; } + } + } + + private sealed class RunningSession + : IBrowserLogsRunningSession + { + private static readonly HttpClient s_httpClient = new(new SocketsHttpHandler + { + UseProxy = false + }); + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly BrowserLogsResource _resource; + private readonly string _resourceName; + private readonly string _sessionId; + private readonly Uri _url; + private readonly TempDirectory _userDataDirectory; + private readonly ILogger _resourceLogger; + private readonly ILogger _logger; + private readonly BrowserEventLogger _eventLogger; + private readonly CancellationTokenSource _stopCts = new(); + + private Process? _process; + private Task? _stdoutTask; + private Task? _stderrTask; + private ChromeDevToolsConnection? _connection; + private string? _targetSessionId; + private Task? _completion; + private int _cleanupState; + private string? _browserExecutable; + + private RunningSession( + BrowserLogsResource resource, + string resourceName, + string sessionId, + Uri url, + TempDirectory userDataDirectory, + ILogger resourceLogger, + ILogger logger) + { + _resource = resource; + _resourceName = resourceName; + _sessionId = sessionId; + _url = url; + _userDataDirectory = userDataDirectory; + _resourceLogger = resourceLogger; + _logger = logger; + _eventLogger = new BrowserEventLogger(sessionId, resourceLogger); + } + + public string SessionId => _sessionId; + + public string BrowserExecutable => _browserExecutable ?? throw new InvalidOperationException("Browser executable is not available before the session starts."); + + public int ProcessId => _process?.Id ?? throw new InvalidOperationException("Browser process has not started."); + + public DateTime StartedAt { get; private set; } + + private Task Completion => _completion ?? throw new InvalidOperationException("Session has not been started."); + + public static async Task StartAsync( + BrowserLogsResource resource, + string resourceName, + string sessionId, + Uri url, + IFileSystemService fileSystemService, + ILogger resourceLogger, + ILogger logger, + CancellationToken cancellationToken) + { + var userDataDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-browser-logs"); + var session = new RunningSession(resource, resourceName, sessionId, url, userDataDirectory, resourceLogger, logger); + + try + { + await session.InitializeAsync(cancellationToken).ConfigureAwait(false); + session._completion = session.MonitorAsync(); + return session; + } + catch + { + await session.CleanupAsync(forceKillProcess: true).ConfigureAwait(false); + throw; + } + } + + public void StartCompletionObserver(Func onCompleted) + { + _ = ObserveCompletionAsync(onCompleted); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _stopCts.Cancel(); + + if (_connection is not null) + { + try + { + await _connection.CloseBrowserAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to close tracked browser for resource '{ResourceName}' via CDP.", _resourceName); + } + } + + if (_process is { HasExited: false }) + { + using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + waitCts.CancelAfter(TimeSpan.FromSeconds(5)); + + try + { + await _process.WaitForExitAsync(waitCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + } + + if (!_process.HasExited) + { + try + { + _process.Kill(entireProcessTree: true); + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) + { + _logger.LogDebug(ex, "Failed to kill tracked browser process for resource '{ResourceName}'.", _resourceName); + } + } + } + + try + { + await Completion.ConfigureAwait(false); + } + catch + { + } + } + + private async Task InitializeAsync(CancellationToken cancellationToken) + { + _browserExecutable = ResolveBrowserExecutable(_resource.Browser); + if (_browserExecutable is null) + { + throw new InvalidOperationException($"Unable to locate browser '{_resource.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); + } + + var startInfo = new ProcessStartInfo(_browserExecutable) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + var browserDebuggingPort = AllocateBrowserDebuggingPort(); + startInfo.ArgumentList.Add($"--user-data-dir={_userDataDirectory.Path}"); + startInfo.ArgumentList.Add($"--remote-debugging-port={browserDebuggingPort}"); + startInfo.ArgumentList.Add("--no-first-run"); + startInfo.ArgumentList.Add("--no-default-browser-check"); + startInfo.ArgumentList.Add("--new-window"); + startInfo.ArgumentList.Add("--allow-insecure-localhost"); + startInfo.ArgumentList.Add("--ignore-certificate-errors"); + startInfo.ArgumentList.Add("about:blank"); + + _process = Process.Start(startInfo) ?? throw new InvalidOperationException($"Failed to start tracked browser process '{_browserExecutable}'."); + StartedAt = DateTime.UtcNow; + _stdoutTask = DrainStreamAsync(_process.StandardOutput, _stopCts.Token); + _stderrTask = DrainStreamAsync(_process.StandardError, _stopCts.Token); + _resourceLogger.LogInformation("[{SessionId}] Started tracked browser process '{BrowserExecutable}'.", _sessionId, _browserExecutable); + _resourceLogger.LogInformation("[{SessionId}] Waiting for tracked browser debug endpoint on port {Port}.", _sessionId, browserDebuggingPort); + + var browserEndpoint = await WaitForBrowserEndpointAsync(browserDebuggingPort, cancellationToken).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Discovered tracked browser debug endpoint '{Endpoint}'.", _sessionId, browserEndpoint); + _connection = await ChromeDevToolsConnection.ConnectAsync(browserEndpoint, HandleEventAsync, cancellationToken).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Connected to the tracked browser debug endpoint.", _sessionId); + + var targetId = await _connection.CreateTargetAsync(cancellationToken).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Created tracked browser target '{TargetId}'.", _sessionId, targetId); + _targetSessionId = await _connection.AttachToTargetAsync(targetId, cancellationToken).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Attached to the tracked browser target.", _sessionId); + await _connection.EnablePageInstrumentationAsync(_targetSessionId, cancellationToken).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Enabled tracked browser logging.", _sessionId); + await _connection.NavigateAsync(_targetSessionId, _url, cancellationToken).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Navigated tracked browser to '{Url}'.", _sessionId, _url); + + _resourceLogger.LogInformation("[{SessionId}] Tracking browser console logs for '{Url}'.", _sessionId, _url); + } + + private async Task MonitorAsync() + { + try + { + Debug.Assert(_process is not null); + Debug.Assert(_connection is not null); + + var processExitTask = _process.WaitForExitAsync(CancellationToken.None); + var completedTask = await Task.WhenAny(processExitTask, _connection.Completion).ConfigureAwait(false); + + Exception? error = null; + if (completedTask == _connection.Completion) + { + try + { + await _connection.Completion.ConfigureAwait(false); + } + catch (Exception ex) + { + error = ex; + } + + if (!_stopCts.IsCancellationRequested && !_process.HasExited) + { + _resourceLogger.LogWarning("[{SessionId}] Tracked browser debug connection closed before the browser process exited.", _sessionId); + + try + { + _process.Kill(entireProcessTree: true); + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) + { + _logger.LogDebug(ex, "Failed to kill tracked browser process after the debug connection closed for resource '{ResourceName}'.", _resourceName); + } + } + } + + await processExitTask.ConfigureAwait(false); + + if (!_stopCts.IsCancellationRequested) + { + _resourceLogger.LogInformation("[{SessionId}] Tracked browser exited with code {ExitCode}.", _sessionId, _process.ExitCode); + } + + return new BrowserSessionResult(_process.ExitCode, error); + } + finally + { + await CleanupAsync(forceKillProcess: false).ConfigureAwait(false); + } + } + + private ValueTask HandleEventAsync(CdpEvent cdpEvent) + { + if (!string.Equals(cdpEvent.SessionId, _targetSessionId, StringComparison.Ordinal)) + { + return ValueTask.CompletedTask; + } + + _eventLogger.HandleEvent(cdpEvent.Method, cdpEvent.Params); + return ValueTask.CompletedTask; + } + + private async Task ObserveCompletionAsync(Func onCompleted) + { + try + { + var result = await Completion.ConfigureAwait(false); + await onCompleted(result.ExitCode, result.Error).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Tracked browser completion observer failed for resource '{ResourceName}' and session '{SessionId}'.", _resourceName, _sessionId); + } + } + + private async Task CleanupAsync(bool forceKillProcess) + { + if (Interlocked.Exchange(ref _cleanupState, 1) != 0) + { + return; + } + + if (forceKillProcess && _process is { HasExited: false }) + { + try + { + _process.Kill(entireProcessTree: true); + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) + { + _logger.LogDebug(ex, "Failed to kill tracked browser process during cleanup for resource '{ResourceName}'.", _resourceName); + } + } + + if (_connection is not null) + { + await _connection.DisposeAsync().ConfigureAwait(false); + } + + if (_stdoutTask is not null) + { + await AwaitQuietlyAsync(_stdoutTask).ConfigureAwait(false); + } + + if (_stderrTask is not null) + { + await AwaitQuietlyAsync(_stderrTask).ConfigureAwait(false); + } + + _process?.Dispose(); + _stopCts.Dispose(); + _userDataDirectory.Dispose(); + } + + private static async Task AwaitQuietlyAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + } + } + + private static async Task DrainStreamAsync(StreamReader reader, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (line is null) + { + break; + } + } + } + + private static async Task WaitForBrowserEndpointAsync(int browserDebuggingPort, CancellationToken cancellationToken) + { + var browserVersionUri = new Uri($"http://127.0.0.1:{browserDebuggingPort}/json/version", UriKind.Absolute); + var timeoutAt = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(30); + + while (TimeProvider.System.GetUtcNow() < timeoutAt) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + probeCts.CancelAfter(TimeSpan.FromSeconds(1)); + + using var response = await s_httpClient.GetAsync(browserVersionUri, probeCts.Token).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var version = await response.Content.ReadFromJsonAsync(probeCts.Token).ConfigureAwait(false); + if (version?.WebSocketDebuggerUrl is { } browserEndpoint) + { + return browserEndpoint; + } + } + catch (HttpRequestException) + { + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + } + catch (JsonException) + { + } + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false); + } + + throw new TimeoutException("Timed out waiting for the tracked browser to expose its debugging endpoint."); + } + + private static int AllocateBrowserDebuggingPort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static string? ResolveBrowserExecutable(string browser) + { + if (Path.IsPathRooted(browser) && File.Exists(browser)) + { + return browser; + } + + foreach (var candidate in GetBrowserCandidates(browser)) + { + if (Path.IsPathRooted(candidate)) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + else if (PathLookupHelper.FindFullPathFromPath(candidate) is { } resolvedPath) + { + return resolvedPath; + } + } + + return PathLookupHelper.FindFullPathFromPath(browser); + } + + private static IEnumerable GetBrowserCandidates(string browser) + { + if (OperatingSystem.IsMacOS()) + { + return browser.ToLowerInvariant() switch + { + "msedge" or "edge" => + [ + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "msedge" + ], + "chrome" or "google-chrome" => + [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "google-chrome", + "chrome" + ], + _ => [browser] + }; + } + + if (OperatingSystem.IsWindows()) + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + + return browser.ToLowerInvariant() switch + { + "msedge" or "edge" => + [ + Path.Combine(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"), + Path.Combine(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"), + "msedge.exe" + ], + "chrome" or "google-chrome" => + [ + Path.Combine(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"), + Path.Combine(programFiles, "Google", "Chrome", "Application", "chrome.exe"), + "chrome.exe" + ], + _ => [browser] + }; + } + + return browser.ToLowerInvariant() switch + { + "msedge" or "edge" => ["microsoft-edge", "microsoft-edge-stable", "msedge"], + "chrome" or "google-chrome" => ["google-chrome", "google-chrome-stable", "chrome", "chromium-browser", "chromium"], + _ => [browser] + }; + } + + private sealed record BrowserSessionResult(int ExitCode, Exception? Error); + + private sealed record CdpEvent(string Method, string? SessionId, JsonElement Params); + + private sealed class BrowserVersionResponse + { + [JsonPropertyName("webSocketDebuggerUrl")] + public required Uri WebSocketDebuggerUrl { get; init; } + } + + private sealed class ChromeDevToolsConnection : IAsyncDisposable + { + private readonly ClientWebSocket _webSocket; + private readonly Func _eventHandler; + private readonly ConcurrentDictionary> _pendingCommands = new(); + private readonly Task _receiveLoop; + private long _nextCommandId; + + public ChromeDevToolsConnection(ClientWebSocket webSocket, Func eventHandler) + { + _webSocket = webSocket; + _eventHandler = eventHandler; + _receiveLoop = Task.Run(ReceiveLoopAsync); + } + + public Task Completion => _receiveLoop; + + public static async Task ConnectAsync(Uri webSocketUri, Func eventHandler, CancellationToken cancellationToken) + { + var webSocket = new ClientWebSocket(); + webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30); + await webSocket.ConnectAsync(webSocketUri, cancellationToken).ConfigureAwait(false); + return new ChromeDevToolsConnection(webSocket, eventHandler); + } + + public async Task CreateTargetAsync(CancellationToken cancellationToken) + { + var result = await SendCommandAsync("Target.createTarget", new { url = "about:blank" }, sessionId: null, cancellationToken).ConfigureAwait(false); + return result.GetProperty("targetId").GetString() + ?? throw new InvalidOperationException("Browser target creation did not return a target id."); + } + + public async Task AttachToTargetAsync(string targetId, CancellationToken cancellationToken) + { + var result = await SendCommandAsync("Target.attachToTarget", new { targetId, flatten = true }, sessionId: null, cancellationToken).ConfigureAwait(false); + return result.GetProperty("sessionId").GetString() + ?? throw new InvalidOperationException("Browser target attachment did not return a session id."); + } + + public async Task EnablePageInstrumentationAsync(string sessionId, CancellationToken cancellationToken) + { + await SendCommandAsync("Runtime.enable", parameters: null, sessionId, cancellationToken).ConfigureAwait(false); + await SendCommandAsync("Log.enable", parameters: null, sessionId, cancellationToken).ConfigureAwait(false); + await SendCommandAsync("Page.enable", parameters: null, sessionId, cancellationToken).ConfigureAwait(false); + await SendCommandAsync("Network.enable", parameters: null, sessionId, cancellationToken).ConfigureAwait(false); + } + + public Task NavigateAsync(string sessionId, Uri url, CancellationToken cancellationToken) => + SendCommandAsync("Page.navigate", new { url = url.ToString() }, sessionId, cancellationToken); + + public Task CloseBrowserAsync(CancellationToken cancellationToken) => + SendCommandAsync("Browser.close", parameters: null, sessionId: null, cancellationToken); + + public async ValueTask DisposeAsync() + { + try + { + if (_webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposed", CancellationToken.None).ConfigureAwait(false); + } + } + catch + { + _webSocket.Abort(); + } + finally + { + _webSocket.Dispose(); + } + + try + { + await _receiveLoop.ConfigureAwait(false); + } + catch + { + } + } + + private async Task SendCommandAsync(string method, object? parameters, string? sessionId, CancellationToken cancellationToken) + { + var commandId = Interlocked.Increment(ref _nextCommandId); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingCommands[commandId] = tcs; + + using var registration = cancellationToken.Register(static state => + { + var source = (TaskCompletionSource)state!; + source.TrySetCanceled(); + }, tcs); + + var payload = JsonSerializer.SerializeToUtf8Bytes(new CdpCommand + { + Id = commandId, + Method = method, + Params = parameters, + SessionId = sessionId + }, s_jsonOptions); + + await _webSocket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, cancellationToken).ConfigureAwait(false); + + try + { + return await tcs.Task.ConfigureAwait(false); + } + finally + { + _pendingCommands.TryRemove(commandId, out _); + } + } + + private async Task ReceiveLoopAsync() + { + var buffer = new byte[16 * 1024]; + using var messageBuffer = new MemoryStream(); + + try + { + while (_webSocket.State is WebSocketState.Open or WebSocketState.CloseSent) + { + var result = await _webSocket.ReceiveAsync(buffer, CancellationToken.None).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + break; + } + + messageBuffer.Write(buffer, 0, result.Count); + if (!result.EndOfMessage) + { + continue; + } + + var message = messageBuffer.ToArray(); + messageBuffer.SetLength(0); + + using var document = JsonDocument.Parse(message); + var root = document.RootElement; + + if (root.TryGetProperty("id", out var idElement)) + { + var commandId = idElement.GetInt64(); + if (_pendingCommands.TryGetValue(commandId, out var pendingCommand)) + { + if (root.TryGetProperty("error", out var error)) + { + var errorMessage = error.TryGetProperty("message", out var errorMessageElement) + ? errorMessageElement.GetString() + : "Unknown browser protocol error."; + pendingCommand.TrySetException(new InvalidOperationException(errorMessage)); + } + else if (root.TryGetProperty("result", out var responseResult)) + { + pendingCommand.TrySetResult(responseResult.Clone()); + } + else + { + pendingCommand.TrySetResult(default); + } + } + } + else if (root.TryGetProperty("method", out var methodElement)) + { + var sessionId = root.TryGetProperty("sessionId", out var sessionIdElement) + ? sessionIdElement.GetString() + : null; + var parameters = root.TryGetProperty("params", out var paramsElement) + ? paramsElement.Clone() + : default; + + await _eventHandler(new CdpEvent(methodElement.GetString() ?? string.Empty, sessionId, parameters)).ConfigureAwait(false); + } + } + } + finally + { + foreach (var (_, pendingCommand) in _pendingCommands) + { + pendingCommand.TrySetException(new InvalidOperationException("Browser debug connection closed.")); + } + } + } + + private sealed class CdpCommand + { + [JsonPropertyName("id")] + public required long Id { get; init; } + + [JsonPropertyName("method")] + public required string Method { get; init; } + + [JsonPropertyName("params")] + public object? Params { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } + } + } + } + + private sealed class BrowserLogsRunningSessionFactory : IBrowserLogsRunningSessionFactory + { + private readonly IFileSystemService _fileSystemService; + private readonly ILogger _logger; + + public BrowserLogsRunningSessionFactory(IFileSystemService fileSystemService, ILogger logger) + { + _fileSystemService = fileSystemService; + _logger = logger; + } + + public async Task StartSessionAsync( + BrowserLogsResource resource, + string resourceName, + Uri url, + string sessionId, + ILogger resourceLogger, + CancellationToken cancellationToken) + { + return await RunningSession.StartAsync( + resource, + resourceName, + sessionId, + url, + _fileSystemService, + resourceLogger, + _logger, + cancellationToken).ConfigureAwait(false); + } + } + + private sealed class ResourceSessionState + { + public SemaphoreSlim Lock { get; } = new(1, 1); + + public Dictionary ActiveSessions { get; } = new(StringComparer.Ordinal); + + public int TotalSessionsLaunched { get; set; } + + public string? LastSessionId { get; set; } + + public string? LastTargetUrl { get; set; } + + public string? LastBrowserExecutable { get; set; } + } + + private sealed record ActiveBrowserSession( + string SessionId, + string BrowserExecutable, + int ProcessId, + DateTime StartedAt, + Uri TargetUrl, + IBrowserLogsRunningSession Session); + + private sealed record PendingBrowserSession( + string SessionId, + DateTime StartedAt, + Uri TargetUrl); +} diff --git a/src/Aspire.Hosting/IBrowserLogsSessionManager.cs b/src/Aspire.Hosting/IBrowserLogsSessionManager.cs new file mode 100644 index 00000000000..95469aa7c3b --- /dev/null +++ b/src/Aspire.Hosting/IBrowserLogsSessionManager.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting; + +internal interface IBrowserLogsSessionManager +{ + Task StartSessionAsync(BrowserLogsResource resource, string resourceName, Uri url, CancellationToken cancellationToken); +} diff --git a/src/Aspire.Hosting/Resources/CommandStrings.Designer.cs b/src/Aspire.Hosting/Resources/CommandStrings.Designer.cs index 6135bb20f2a..15361470058 100644 --- a/src/Aspire.Hosting/Resources/CommandStrings.Designer.cs +++ b/src/Aspire.Hosting/Resources/CommandStrings.Designer.cs @@ -105,6 +105,24 @@ internal static string DeleteParameterName { } } + /// + /// Looks up a localized string similar to Open the app in a tracked browser session and stream browser logs to this resource.. + /// + internal static string OpenTrackedBrowserDescription { + get { + return ResourceManager.GetString("OpenTrackedBrowserDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open tracked browser. + /// + internal static string OpenTrackedBrowserName { + get { + return ResourceManager.GetString("OpenTrackedBrowserName", resourceCulture); + } + } + /// /// Looks up a localized string similar to Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes.. /// diff --git a/src/Aspire.Hosting/Resources/CommandStrings.resx b/src/Aspire.Hosting/Resources/CommandStrings.resx index c955543bf60..75f06401a6f 100644 --- a/src/Aspire.Hosting/Resources/CommandStrings.resx +++ b/src/Aspire.Hosting/Resources/CommandStrings.resx @@ -150,6 +150,12 @@ Delete parameter + + Open the app in a tracked browser session and stream browser logs to this resource. + + + Open tracked browser + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.cs.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.cs.xlf index ebde0302553..6fe521b448d 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.cs.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.cs.xlf @@ -12,6 +12,16 @@ Odstranit parametr + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.de.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.de.xlf index adbf6bbe364..e21dd4172f5 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.de.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.de.xlf @@ -12,6 +12,16 @@ Parameter löschen + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.es.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.es.xlf index d2f67ba89e0..82a445d614a 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.es.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.es.xlf @@ -12,6 +12,16 @@ Eliminar parámetro + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.fr.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.fr.xlf index f69fe25f71a..8db4150758b 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.fr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.fr.xlf @@ -12,6 +12,16 @@ Supprimer le paramètre + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.it.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.it.xlf index 783c0e16512..fc3f5859c7b 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.it.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.it.xlf @@ -12,6 +12,16 @@ Elimina parametro + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ja.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ja.xlf index 4a3f1fa42d8..5cfda9bf22d 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ja.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ja.xlf @@ -12,6 +12,16 @@ パラメーターの削除 + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ko.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ko.xlf index 16ac639329f..459369f13f3 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ko.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ko.xlf @@ -12,6 +12,16 @@ 매개 변수 삭제 + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.pl.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.pl.xlf index f959a404a13..db7d5977d48 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.pl.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.pl.xlf @@ -12,6 +12,16 @@ Usuń parametr + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.pt-BR.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.pt-BR.xlf index c89ebb00271..f8409d8ad8a 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.pt-BR.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.pt-BR.xlf @@ -12,6 +12,16 @@ Excluir parâmetro + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ru.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ru.xlf index e590c123ad5..fcda2f1a4b3 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ru.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ru.xlf @@ -12,6 +12,16 @@ Удалить параметр + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.tr.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.tr.xlf index fb14686e425..96ba9f76baf 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.tr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.tr.xlf @@ -12,6 +12,16 @@ Parametreyi sil + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hans.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hans.xlf index 430e2a03c4d..5874b309bb7 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hans.xlf @@ -12,6 +12,16 @@ 删除参数 + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hant.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hant.xlf index 5b5ef0297a8..53329748494 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hant.xlf @@ -12,6 +12,16 @@ 刪除參數 + + Open the app in a tracked browser session and stream browser logs to this resource. + Open the app in a tracked browser session and stream browser logs to this resource. + + + + Open tracked browser + Open tracked browser + + Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. Stop the resource, rebuild the project from source, and restart. Use this to apply source code changes. diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index b190aa1558f..bb4d4f33bc6 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -655,6 +655,21 @@ func NewCSharpAppResource(handle *Handle, client *AspireClient) *CSharpAppResour } } +// WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. +func (s *CSharpAppResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if browser != nil { + reqArgs["browser"] = SerializeValue(browser) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithContainerRegistry configures a resource to use a container registry func (s *CSharpAppResource) WithContainerRegistry(registry *IResource) (*IResource, error) { reqArgs := map[string]any{ @@ -3978,6 +3993,21 @@ func NewContainerResource(handle *Handle, client *AspireClient) *ContainerResour } } +// WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. +func (s *ContainerResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if browser != nil { + reqArgs["browser"] = SerializeValue(browser) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithContainerRegistry configures a resource to use a container registry func (s *ContainerResource) WithContainerRegistry(registry *IResource) (*IResource, error) { reqArgs := map[string]any{ @@ -6193,6 +6223,21 @@ func NewDotnetToolResource(handle *Handle, client *AspireClient) *DotnetToolReso } } +// WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. +func (s *DotnetToolResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if browser != nil { + reqArgs["browser"] = SerializeValue(browser) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithContainerRegistry configures a resource to use a container registry func (s *DotnetToolResource) WithContainerRegistry(registry *IResource) (*IResource, error) { reqArgs := map[string]any{ @@ -8434,6 +8479,21 @@ func NewExecutableResource(handle *Handle, client *AspireClient) *ExecutableReso } } +// WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. +func (s *ExecutableResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if browser != nil { + reqArgs["browser"] = SerializeValue(browser) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithContainerRegistry configures a resource to use a container registry func (s *ExecutableResource) WithContainerRegistry(registry *IResource) (*IResource, error) { reqArgs := map[string]any{ @@ -13638,6 +13698,21 @@ func NewProjectResource(handle *Handle, client *AspireClient) *ProjectResource { } } +// WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. +func (s *ProjectResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if browser != nil { + reqArgs["browser"] = SerializeValue(browser) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithContainerRegistry configures a resource to use a container registry func (s *ProjectResource) WithContainerRegistry(registry *IResource) (*IResource, error) { reqArgs := map[string]any{ @@ -15692,6 +15767,21 @@ func NewTestDatabaseResource(handle *Handle, client *AspireClient) *TestDatabase } } +// WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. +func (s *TestDatabaseResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if browser != nil { + reqArgs["browser"] = SerializeValue(browser) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithContainerRegistry configures a resource to use a container registry func (s *TestDatabaseResource) WithContainerRegistry(registry *IResource) (*IResource, error) { reqArgs := map[string]any{ @@ -17483,6 +17573,21 @@ func NewTestRedisResource(handle *Handle, client *AspireClient) *TestRedisResour } } +// WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. +func (s *TestRedisResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if browser != nil { + reqArgs["browser"] = SerializeValue(browser) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithContainerRegistry configures a resource to use a container registry func (s *TestRedisResource) WithContainerRegistry(registry *IResource) (*IResource, error) { reqArgs := map[string]any{ @@ -19499,6 +19604,21 @@ func NewTestVaultResource(handle *Handle, client *AspireClient) *TestVaultResour } } +// WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. +func (s *TestVaultResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if browser != nil { + reqArgs["browser"] = SerializeValue(browser) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithContainerRegistry configures a resource to use a container registry func (s *TestVaultResource) WithContainerRegistry(registry *IResource) (*IResource, error) { reqArgs := map[string]any{ diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index 62ec7cc3283..dc20e11a9f0 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -1,4 +1,4 @@ -// ===== AddContainerOptions.java ===== +// ===== AddContainerOptions.java ===== // AddContainerOptions.java - GENERATED CODE - DO NOT EDIT package aspire; @@ -1426,6 +1426,21 @@ public class CSharpAppResource extends ResourceBuilderBase { super(handle, client); } + public CSharpAppResource withBrowserLogs() { + return withBrowserLogs(null); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public CSharpAppResource withBrowserLogs(String browser) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (browser != null) { + reqArgs.put("browser", AspireClient.serializeValue(browser)); + } + getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); + return this; + } + /** Configures a resource to use a container registry */ public CSharpAppResource withContainerRegistry(IResource registry) { Map reqArgs = new HashMap<>(); @@ -5148,6 +5163,21 @@ public class ContainerResource extends ResourceBuilderBase { super(handle, client); } + public ContainerResource withBrowserLogs() { + return withBrowserLogs(null); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public ContainerResource withBrowserLogs(String browser) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (browser != null) { + reqArgs.put("browser", AspireClient.serializeValue(browser)); + } + getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); + return this; + } + /** Configures a resource to use a container registry */ public ContainerResource withContainerRegistry(IResource registry) { Map reqArgs = new HashMap<>(); @@ -7455,6 +7485,21 @@ public class DotnetToolResource extends ResourceBuilderBase { super(handle, client); } + public DotnetToolResource withBrowserLogs() { + return withBrowserLogs(null); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public DotnetToolResource withBrowserLogs(String browser) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (browser != null) { + reqArgs.put("browser", AspireClient.serializeValue(browser)); + } + getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); + return this; + } + /** Configures a resource to use a container registry */ public DotnetToolResource withContainerRegistry(IResource registry) { Map reqArgs = new HashMap<>(); @@ -9541,6 +9586,21 @@ public class ExecutableResource extends ResourceBuilderBase { super(handle, client); } + public ExecutableResource withBrowserLogs() { + return withBrowserLogs(null); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public ExecutableResource withBrowserLogs(String browser) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (browser != null) { + reqArgs.put("browser", AspireClient.serializeValue(browser)); + } + getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); + return this; + } + /** Configures a resource to use a container registry */ public ExecutableResource withContainerRegistry(IResource registry) { Map reqArgs = new HashMap<>(); @@ -15025,6 +15085,21 @@ public class ProjectResource extends ResourceBuilderBase { super(handle, client); } + public ProjectResource withBrowserLogs() { + return withBrowserLogs(null); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public ProjectResource withBrowserLogs(String browser) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (browser != null) { + reqArgs.put("browser", AspireClient.serializeValue(browser)); + } + getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); + return this; + } + /** Configures a resource to use a container registry */ public ProjectResource withContainerRegistry(IResource registry) { Map reqArgs = new HashMap<>(); @@ -17432,6 +17507,21 @@ public class TestDatabaseResource extends ResourceBuilderBase { super(handle, client); } + public TestDatabaseResource withBrowserLogs() { + return withBrowserLogs(null); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public TestDatabaseResource withBrowserLogs(String browser) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (browser != null) { + reqArgs.put("browser", AspireClient.serializeValue(browser)); + } + getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); + return this; + } + /** Configures a resource to use a container registry */ public TestDatabaseResource withContainerRegistry(IResource registry) { Map reqArgs = new HashMap<>(); @@ -19326,6 +19416,21 @@ public class TestRedisResource extends ResourceBuilderBase { super(handle, client); } + public TestRedisResource withBrowserLogs() { + return withBrowserLogs(null); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public TestRedisResource withBrowserLogs(String browser) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (browser != null) { + reqArgs.put("browser", AspireClient.serializeValue(browser)); + } + getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); + return this; + } + /** Configures a resource to use a container registry */ public TestRedisResource withContainerRegistry(IResource registry) { Map reqArgs = new HashMap<>(); @@ -21373,6 +21478,21 @@ public class TestVaultResource extends ResourceBuilderBase { super(handle, client); } + public TestVaultResource withBrowserLogs() { + return withBrowserLogs(null); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public TestVaultResource withBrowserLogs(String browser) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (browser != null) { + reqArgs.put("browser", AspireClient.serializeValue(browser)); + } + getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); + return this; + } + /** Configures a resource to use a container registry */ public TestVaultResource withContainerRegistry(IResource registry) { Map reqArgs = new HashMap<>(); diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index 0973d6df99d..fef3bb63f30 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -5829,6 +5829,10 @@ def clear_container_files_sources(self) -> typing.Self: class AbstractResourceWithEndpoints(AbstractResource): """Abstract base class for AbstractResourceWithEndpoints interface.""" + @abc.abstractmethod + def with_browser_logs(self, *, browser: str = "msedge") -> typing.Self: + """Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.""" + @abc.abstractmethod def with_mcp_server(self, *, path: str = "/mcp", endpoint_name: str | None = None) -> typing.Self: """Configures an MCP server endpoint on the resource""" @@ -7146,6 +7150,7 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack class ContainerResourceKwargs(_BaseResourceKwargs, total=False): """ContainerResource options.""" + browser_logs: str | typing.Literal[True] bind_mount: tuple[str, str] | BindMountParameters entrypoint: str image_tag: str @@ -7213,6 +7218,18 @@ class ContainerResource(_BaseResource, AbstractResourceWithEnvironment, Abstract def __repr__(self) -> str: return "ContainerResource(handle={self._handle.handle_id})" + def with_browser_logs(self, *, browser: str = "msedge") -> typing.Self: + """Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.""" + rpc_args: dict[str, typing.Any] = {'builder': self._handle} + if browser is not None: + rpc_args['browser'] = browser + result = self._client.invoke_capability( + 'Aspire.Hosting/withBrowserLogs', + rpc_args, + ) + self._handle = self._wrap_builder(result) + return self + def with_bind_mount(self, source: str, target: str, *, is_read_only: bool = False) -> typing.Self: """Adds a bind mount""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} @@ -7995,6 +8012,16 @@ def with_env_vars(self, vars: typing.Mapping[str, str]) -> typing.Self: return self def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack[ContainerResourceKwargs]) -> None: + if _browser_logs := kwargs.pop("browser_logs", None): + if _validate_type(_browser_logs, str): + rpc_args: dict[str, typing.Any] = {"builder": handle} + rpc_args["browser"] = typing.cast(str, _browser_logs) + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) + elif _browser_logs is True: + rpc_args: dict[str, typing.Any] = {"builder": handle} + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) + else: + raise TypeError("Invalid type for option 'browser_logs'. Expected: str or Literal[True]") if _bind_mount := kwargs.pop("bind_mount", None): if _validate_tuple_types(_bind_mount, (str, str)): rpc_args: dict[str, typing.Any] = {"builder": handle} @@ -8559,6 +8586,7 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack class ProjectResourceKwargs(_BaseResourceKwargs, total=False): """ProjectResource options.""" + browser_logs: str | typing.Literal[True] mcp_server: McpServerParameters | typing.Literal[True] otlp_exporter: OtlpProtocol | typing.Literal[True] replicas: int @@ -8610,6 +8638,18 @@ class ProjectResource(_BaseResource, AbstractResourceWithEnvironment, AbstractRe def __repr__(self) -> str: return "ProjectResource(handle={self._handle.handle_id})" + def with_browser_logs(self, *, browser: str = "msedge") -> typing.Self: + """Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.""" + rpc_args: dict[str, typing.Any] = {'builder': self._handle} + if browser is not None: + rpc_args['browser'] = browser + result = self._client.invoke_capability( + 'Aspire.Hosting/withBrowserLogs', + rpc_args, + ) + self._handle = self._wrap_builder(result) + return self + def with_mcp_server(self, *, path: str = "/mcp", endpoint_name: str | None = None) -> typing.Self: """Configures an MCP server endpoint on the resource""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} @@ -9196,6 +9236,16 @@ def with_env_vars(self, vars: typing.Mapping[str, str]) -> typing.Self: return self def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack[ProjectResourceKwargs]) -> None: + if _browser_logs := kwargs.pop("browser_logs", None): + if _validate_type(_browser_logs, str): + rpc_args: dict[str, typing.Any] = {"builder": handle} + rpc_args["browser"] = typing.cast(str, _browser_logs) + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) + elif _browser_logs is True: + rpc_args: dict[str, typing.Any] = {"builder": handle} + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) + else: + raise TypeError("Invalid type for option 'browser_logs'. Expected: str or Literal[True]") if _mcp_server := kwargs.pop("mcp_server", None): if _validate_dict_types(_mcp_server, McpServerParameters): rpc_args: dict[str, typing.Any] = {"builder": handle} @@ -9629,6 +9679,7 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack class ExecutableResourceKwargs(_BaseResourceKwargs, total=False): """ExecutableResource options.""" + browser_logs: str | typing.Literal[True] publish_as_docker_file: typing.Callable[[ContainerResource], None] | typing.Literal[True] executable_command: str working_dir: str @@ -9679,6 +9730,18 @@ class ExecutableResource(_BaseResource, AbstractResourceWithEnvironment, Abstrac def __repr__(self) -> str: return "ExecutableResource(handle={self._handle.handle_id})" + def with_browser_logs(self, *, browser: str = "msedge") -> typing.Self: + """Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.""" + rpc_args: dict[str, typing.Any] = {'builder': self._handle} + if browser is not None: + rpc_args['browser'] = browser + result = self._client.invoke_capability( + 'Aspire.Hosting/withBrowserLogs', + rpc_args, + ) + self._handle = self._wrap_builder(result) + return self + def publish_as_docker_file(self, *, configure: typing.Callable[[ContainerResource], None] | None = None) -> typing.Self: """Publishes the executable as a Docker container""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} @@ -10255,6 +10318,16 @@ def with_env_vars(self, vars: typing.Mapping[str, str]) -> typing.Self: return self def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack[ExecutableResourceKwargs]) -> None: + if _browser_logs := kwargs.pop("browser_logs", None): + if _validate_type(_browser_logs, str): + rpc_args: dict[str, typing.Any] = {"builder": handle} + rpc_args["browser"] = typing.cast(str, _browser_logs) + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) + elif _browser_logs is True: + rpc_args: dict[str, typing.Any] = {"builder": handle} + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) + else: + raise TypeError("Invalid type for option 'browser_logs'. Expected: str or Literal[True]") if _publish_as_docker_file := kwargs.pop("publish_as_docker_file", None): if _validate_type(_publish_as_docker_file, typing.Callable[[ContainerResource], None]): rpc_args: dict[str, typing.Any] = {"builder": handle} diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index 3615b1cc34f..ee88970f5e3 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -1070,6 +1070,18 @@ impl CSharpAppResource { &self.client } + /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. + pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = browser { + args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures a resource to use a container registry pub fn with_container_registry(&self, registry: &IResource) -> Result> { let mut args: HashMap = HashMap::new(); @@ -3832,6 +3844,18 @@ impl ContainerResource { &self.client } + /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. + pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = browser { + args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures a resource to use a container registry pub fn with_container_registry(&self, registry: &IResource) -> Result> { let mut args: HashMap = HashMap::new(); @@ -5743,6 +5767,18 @@ impl DotnetToolResource { &self.client } + /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. + pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = browser { + args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures a resource to use a container registry pub fn with_container_registry(&self, registry: &IResource) -> Result> { let mut args: HashMap = HashMap::new(); @@ -7558,6 +7594,18 @@ impl ExecutableResource { &self.client } + /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. + pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = browser { + args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures a resource to use a container registry pub fn with_container_registry(&self, registry: &IResource) -> Result> { let mut args: HashMap = HashMap::new(); @@ -12309,6 +12357,18 @@ impl ProjectResource { &self.client } + /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. + pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = browser { + args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures a resource to use a container registry pub fn with_container_registry(&self, registry: &IResource) -> Result> { let mut args: HashMap = HashMap::new(); @@ -14099,6 +14159,18 @@ impl TestDatabaseResource { &self.client } + /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. + pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = browser { + args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures a resource to use a container registry pub fn with_container_registry(&self, registry: &IResource) -> Result> { let mut args: HashMap = HashMap::new(); @@ -15542,6 +15614,18 @@ impl TestRedisResource { &self.client } + /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. + pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = browser { + args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures a resource to use a container registry pub fn with_container_registry(&self, registry: &IResource) -> Result> { let mut args: HashMap = HashMap::new(); @@ -17158,6 +17242,18 @@ impl TestVaultResource { &self.client } + /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. + pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = browser { + args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures a resource to use a container registry pub fn with_container_registry(&self, registry: &IResource) -> Result> { let mut args: HashMap = HashMap::new(); diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs index 53a7dea12c1..d15c324c2fa 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs @@ -379,6 +379,19 @@ public async Task Scanner_HostingAssembly_AddContainerCapability() await Verify(addContainer).UseFileName("HostingAddContainerCapability"); } + [Fact] + public void Scanner_HostingAssembly_WithBrowserLogsCapability() + { + var capabilities = ScanCapabilitiesFromHostingAssembly(); + + var withBrowserLogs = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/withBrowserLogs"); + Assert.NotNull(withBrowserLogs); + Assert.Equal("withBrowserLogs", withBrowserLogs.MethodName); + Assert.Equal("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", withBrowserLogs.TargetTypeId); + Assert.Contains(withBrowserLogs.Parameters, p => p.Name == "browser" && p.Type?.TypeId == "string" && p.IsOptional); + Assert.True(withBrowserLogs.ReturnsBuilder); + } + [Fact] public async Task Scanner_HostingAssembly_ContainerResourceCapabilities() { diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/HostingContainerResourceCapabilities.verified.txt b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/HostingContainerResourceCapabilities.verified.txt index ad297d9059b..4a1cb0e1546 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/HostingContainerResourceCapabilities.verified.txt +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/HostingContainerResourceCapabilities.verified.txt @@ -1,4 +1,4 @@ -[ +[ { CapabilityId: Aspire.Hosting/asHttp2Service, MethodName: asHttp2Service, @@ -293,6 +293,20 @@ } ] }, + { + CapabilityId: Aspire.Hosting/withBrowserLogs, + MethodName: withBrowserLogs, + TargetType: { + TypeId: Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints, + IsInterface: true + }, + ExpandedTargetTypes: [ + { + TypeId: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource, + IsInterface: false + } + ] + }, { CapabilityId: Aspire.Hosting/withBuildArg, MethodName: withBuildArg, diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index 9820e093ee2..cf7048d7c60 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -742,6 +742,10 @@ export interface WithBindMountOptions { isReadOnly?: boolean; } +export interface WithBrowserLogsOptions { + browser?: string; +} + export interface WithCommandOptions { commandOptions?: CommandOptions; } @@ -9998,6 +10002,7 @@ class ContainerRegistryResourcePromiseImpl implements ContainerRegistryResourceP export interface ContainerResource { toJSON(): MarshalledHandle; + withBrowserLogs(options?: WithBrowserLogsOptions): ContainerResourcePromise; withContainerRegistry(registry: Awaitable): ContainerResourcePromise; withBindMount(source: string, target: string, options?: WithBindMountOptions): ContainerResourcePromise; withEntrypoint(entrypoint: string): ContainerResourcePromise; @@ -10111,6 +10116,7 @@ export interface ContainerResource { } export interface ContainerResourcePromise extends PromiseLike { + withBrowserLogs(options?: WithBrowserLogsOptions): ContainerResourcePromise; withContainerRegistry(registry: Awaitable): ContainerResourcePromise; withBindMount(source: string, target: string, options?: WithBindMountOptions): ContainerResourcePromise; withEntrypoint(entrypoint: string): ContainerResourcePromise; @@ -10232,6 +10238,23 @@ class ContainerResourceImpl extends ResourceBuilderBase super(handle, client); } + /** @internal */ + private async _withBrowserLogsInternal(browser?: string): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (browser !== undefined) rpcArgs.browser = browser; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withBrowserLogs', + rpcArgs + ); + return new ContainerResourceImpl(result, this._client); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): ContainerResourcePromise { + const browser = options?.browser; + return new ContainerResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + } + /** @internal */ private async _withContainerRegistryInternal(registry: Awaitable): Promise { registry = isPromiseLike(registry) ? await registry : registry; @@ -12162,6 +12185,11 @@ class ContainerResourcePromiseImpl implements ContainerResourcePromise { return this._promise.then(onfulfilled, onrejected); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): ContainerResourcePromise { + return new ContainerResourcePromiseImpl(this._promise.then(obj => obj.withBrowserLogs(options)), this._client); + } + /** Configures a resource to use a container registry */ withContainerRegistry(registry: Awaitable): ContainerResourcePromise { return new ContainerResourcePromiseImpl(this._promise.then(obj => obj.withContainerRegistry(registry)), this._client); @@ -12732,6 +12760,7 @@ class ContainerResourcePromiseImpl implements ContainerResourcePromise { export interface CSharpAppResource { toJSON(): MarshalledHandle; + withBrowserLogs(options?: WithBrowserLogsOptions): CSharpAppResourcePromise; withContainerRegistry(registry: Awaitable): CSharpAppResourcePromise; withDockerfileBaseImage(options?: WithDockerfileBaseImageOptions): CSharpAppResourcePromise; withMcpServer(options?: WithMcpServerOptions): CSharpAppResourcePromise; @@ -12829,6 +12858,7 @@ export interface CSharpAppResource { } export interface CSharpAppResourcePromise extends PromiseLike { + withBrowserLogs(options?: WithBrowserLogsOptions): CSharpAppResourcePromise; withContainerRegistry(registry: Awaitable): CSharpAppResourcePromise; withDockerfileBaseImage(options?: WithDockerfileBaseImageOptions): CSharpAppResourcePromise; withMcpServer(options?: WithMcpServerOptions): CSharpAppResourcePromise; @@ -12934,6 +12964,23 @@ class CSharpAppResourceImpl extends ResourceBuilderBase super(handle, client); } + /** @internal */ + private async _withBrowserLogsInternal(browser?: string): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (browser !== undefined) rpcArgs.browser = browser; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withBrowserLogs', + rpcArgs + ); + return new CSharpAppResourceImpl(result, this._client); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): CSharpAppResourcePromise { + const browser = options?.browser; + return new CSharpAppResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + } + /** @internal */ private async _withContainerRegistryInternal(registry: Awaitable): Promise { registry = isPromiseLike(registry) ? await registry : registry; @@ -14605,6 +14652,11 @@ class CSharpAppResourcePromiseImpl implements CSharpAppResourcePromise { return this._promise.then(onfulfilled, onrejected); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): CSharpAppResourcePromise { + return new CSharpAppResourcePromiseImpl(this._promise.then(obj => obj.withBrowserLogs(options)), this._client); + } + /** Configures a resource to use a container registry */ withContainerRegistry(registry: Awaitable): CSharpAppResourcePromise { return new CSharpAppResourcePromiseImpl(this._promise.then(obj => obj.withContainerRegistry(registry)), this._client); @@ -15095,6 +15147,7 @@ class CSharpAppResourcePromiseImpl implements CSharpAppResourcePromise { export interface DotnetToolResource { toJSON(): MarshalledHandle; + withBrowserLogs(options?: WithBrowserLogsOptions): DotnetToolResourcePromise; withContainerRegistry(registry: Awaitable): DotnetToolResourcePromise; withDockerfileBaseImage(options?: WithDockerfileBaseImageOptions): DotnetToolResourcePromise; withToolPackage(packageId: string): DotnetToolResourcePromise; @@ -15198,6 +15251,7 @@ export interface DotnetToolResource { } export interface DotnetToolResourcePromise extends PromiseLike { + withBrowserLogs(options?: WithBrowserLogsOptions): DotnetToolResourcePromise; withContainerRegistry(registry: Awaitable): DotnetToolResourcePromise; withDockerfileBaseImage(options?: WithDockerfileBaseImageOptions): DotnetToolResourcePromise; withToolPackage(packageId: string): DotnetToolResourcePromise; @@ -15309,6 +15363,23 @@ class DotnetToolResourceImpl extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle }; + if (browser !== undefined) rpcArgs.browser = browser; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withBrowserLogs', + rpcArgs + ); + return new DotnetToolResourceImpl(result, this._client); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): DotnetToolResourcePromise { + const browser = options?.browser; + return new DotnetToolResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + } + /** @internal */ private async _withContainerRegistryInternal(registry: Awaitable): Promise { registry = isPromiseLike(registry) ? await registry : registry; @@ -17067,6 +17138,11 @@ class DotnetToolResourcePromiseImpl implements DotnetToolResourcePromise { return this._promise.then(onfulfilled, onrejected); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): DotnetToolResourcePromise { + return new DotnetToolResourcePromiseImpl(this._promise.then(obj => obj.withBrowserLogs(options)), this._client); + } + /** Configures a resource to use a container registry */ withContainerRegistry(registry: Awaitable): DotnetToolResourcePromise { return new DotnetToolResourcePromiseImpl(this._promise.then(obj => obj.withContainerRegistry(registry)), this._client); @@ -17587,6 +17663,7 @@ class DotnetToolResourcePromiseImpl implements DotnetToolResourcePromise { export interface ExecutableResource { toJSON(): MarshalledHandle; + withBrowserLogs(options?: WithBrowserLogsOptions): ExecutableResourcePromise; withContainerRegistry(registry: Awaitable): ExecutableResourcePromise; withDockerfileBaseImage(options?: WithDockerfileBaseImageOptions): ExecutableResourcePromise; publishAsDockerFile(): ExecutableResourcePromise; @@ -17684,6 +17761,7 @@ export interface ExecutableResource { } export interface ExecutableResourcePromise extends PromiseLike { + withBrowserLogs(options?: WithBrowserLogsOptions): ExecutableResourcePromise; withContainerRegistry(registry: Awaitable): ExecutableResourcePromise; withDockerfileBaseImage(options?: WithDockerfileBaseImageOptions): ExecutableResourcePromise; publishAsDockerFile(): ExecutableResourcePromise; @@ -17789,6 +17867,23 @@ class ExecutableResourceImpl extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle }; + if (browser !== undefined) rpcArgs.browser = browser; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withBrowserLogs', + rpcArgs + ); + return new ExecutableResourceImpl(result, this._client); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): ExecutableResourcePromise { + const browser = options?.browser; + return new ExecutableResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + } + /** @internal */ private async _withContainerRegistryInternal(registry: Awaitable): Promise { registry = isPromiseLike(registry) ? await registry : registry; @@ -19457,6 +19552,11 @@ class ExecutableResourcePromiseImpl implements ExecutableResourcePromise { return this._promise.then(onfulfilled, onrejected); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): ExecutableResourcePromise { + return new ExecutableResourcePromiseImpl(this._promise.then(obj => obj.withBrowserLogs(options)), this._client); + } + /** Configures a resource to use a container registry */ withContainerRegistry(registry: Awaitable): ExecutableResourcePromise { return new ExecutableResourcePromiseImpl(this._promise.then(obj => obj.withContainerRegistry(registry)), this._client); @@ -22281,6 +22381,7 @@ class ParameterResourcePromiseImpl implements ParameterResourcePromise { export interface ProjectResource { toJSON(): MarshalledHandle; + withBrowserLogs(options?: WithBrowserLogsOptions): ProjectResourcePromise; withContainerRegistry(registry: Awaitable): ProjectResourcePromise; withDockerfileBaseImage(options?: WithDockerfileBaseImageOptions): ProjectResourcePromise; withMcpServer(options?: WithMcpServerOptions): ProjectResourcePromise; @@ -22378,6 +22479,7 @@ export interface ProjectResource { } export interface ProjectResourcePromise extends PromiseLike { + withBrowserLogs(options?: WithBrowserLogsOptions): ProjectResourcePromise; withContainerRegistry(registry: Awaitable): ProjectResourcePromise; withDockerfileBaseImage(options?: WithDockerfileBaseImageOptions): ProjectResourcePromise; withMcpServer(options?: WithMcpServerOptions): ProjectResourcePromise; @@ -22483,6 +22585,23 @@ class ProjectResourceImpl extends ResourceBuilderBase imp super(handle, client); } + /** @internal */ + private async _withBrowserLogsInternal(browser?: string): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (browser !== undefined) rpcArgs.browser = browser; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withBrowserLogs', + rpcArgs + ); + return new ProjectResourceImpl(result, this._client); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): ProjectResourcePromise { + const browser = options?.browser; + return new ProjectResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + } + /** @internal */ private async _withContainerRegistryInternal(registry: Awaitable): Promise { registry = isPromiseLike(registry) ? await registry : registry; @@ -24154,6 +24273,11 @@ class ProjectResourcePromiseImpl implements ProjectResourcePromise { return this._promise.then(onfulfilled, onrejected); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): ProjectResourcePromise { + return new ProjectResourcePromiseImpl(this._promise.then(obj => obj.withBrowserLogs(options)), this._client); + } + /** Configures a resource to use a container registry */ withContainerRegistry(registry: Awaitable): ProjectResourcePromise { return new ProjectResourcePromiseImpl(this._promise.then(obj => obj.withContainerRegistry(registry)), this._client); @@ -24644,6 +24768,7 @@ class ProjectResourcePromiseImpl implements ProjectResourcePromise { export interface TestDatabaseResource { toJSON(): MarshalledHandle; + withBrowserLogs(options?: WithBrowserLogsOptions): TestDatabaseResourcePromise; withContainerRegistry(registry: Awaitable): TestDatabaseResourcePromise; withBindMount(source: string, target: string, options?: WithBindMountOptions): TestDatabaseResourcePromise; withEntrypoint(entrypoint: string): TestDatabaseResourcePromise; @@ -24757,6 +24882,7 @@ export interface TestDatabaseResource { } export interface TestDatabaseResourcePromise extends PromiseLike { + withBrowserLogs(options?: WithBrowserLogsOptions): TestDatabaseResourcePromise; withContainerRegistry(registry: Awaitable): TestDatabaseResourcePromise; withBindMount(source: string, target: string, options?: WithBindMountOptions): TestDatabaseResourcePromise; withEntrypoint(entrypoint: string): TestDatabaseResourcePromise; @@ -24878,6 +25004,23 @@ class TestDatabaseResourceImpl extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle }; + if (browser !== undefined) rpcArgs.browser = browser; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withBrowserLogs', + rpcArgs + ); + return new TestDatabaseResourceImpl(result, this._client); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): TestDatabaseResourcePromise { + const browser = options?.browser; + return new TestDatabaseResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + } + /** @internal */ private async _withContainerRegistryInternal(registry: Awaitable): Promise { registry = isPromiseLike(registry) ? await registry : registry; @@ -26808,6 +26951,11 @@ class TestDatabaseResourcePromiseImpl implements TestDatabaseResourcePromise { return this._promise.then(onfulfilled, onrejected); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromiseImpl(this._promise.then(obj => obj.withBrowserLogs(options)), this._client); + } + /** Configures a resource to use a container registry */ withContainerRegistry(registry: Awaitable): TestDatabaseResourcePromise { return new TestDatabaseResourcePromiseImpl(this._promise.then(obj => obj.withContainerRegistry(registry)), this._client); @@ -27378,6 +27526,7 @@ class TestDatabaseResourcePromiseImpl implements TestDatabaseResourcePromise { export interface TestRedisResource { toJSON(): MarshalledHandle; + withBrowserLogs(options?: WithBrowserLogsOptions): TestRedisResourcePromise; withContainerRegistry(registry: Awaitable): TestRedisResourcePromise; withBindMount(source: string, target: string, options?: WithBindMountOptions): TestRedisResourcePromise; withEntrypoint(entrypoint: string): TestRedisResourcePromise; @@ -27507,6 +27656,7 @@ export interface TestRedisResource { } export interface TestRedisResourcePromise extends PromiseLike { + withBrowserLogs(options?: WithBrowserLogsOptions): TestRedisResourcePromise; withContainerRegistry(registry: Awaitable): TestRedisResourcePromise; withBindMount(source: string, target: string, options?: WithBindMountOptions): TestRedisResourcePromise; withEntrypoint(entrypoint: string): TestRedisResourcePromise; @@ -27644,6 +27794,23 @@ class TestRedisResourceImpl extends ResourceBuilderBase super(handle, client); } + /** @internal */ + private async _withBrowserLogsInternal(browser?: string): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (browser !== undefined) rpcArgs.browser = browser; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withBrowserLogs', + rpcArgs + ); + return new TestRedisResourceImpl(result, this._client); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): TestRedisResourcePromise { + const browser = options?.browser; + return new TestRedisResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + } + /** @internal */ private async _withContainerRegistryInternal(registry: Awaitable): Promise { registry = isPromiseLike(registry) ? await registry : registry; @@ -29802,6 +29969,11 @@ class TestRedisResourcePromiseImpl implements TestRedisResourcePromise { return this._promise.then(onfulfilled, onrejected); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): TestRedisResourcePromise { + return new TestRedisResourcePromiseImpl(this._promise.then(obj => obj.withBrowserLogs(options)), this._client); + } + /** Configures a resource to use a container registry */ withContainerRegistry(registry: Awaitable): TestRedisResourcePromise { return new TestRedisResourcePromiseImpl(this._promise.then(obj => obj.withContainerRegistry(registry)), this._client); @@ -30452,6 +30624,7 @@ class TestRedisResourcePromiseImpl implements TestRedisResourcePromise { export interface TestVaultResource { toJSON(): MarshalledHandle; + withBrowserLogs(options?: WithBrowserLogsOptions): TestVaultResourcePromise; withContainerRegistry(registry: Awaitable): TestVaultResourcePromise; withBindMount(source: string, target: string, options?: WithBindMountOptions): TestVaultResourcePromise; withEntrypoint(entrypoint: string): TestVaultResourcePromise; @@ -30566,6 +30739,7 @@ export interface TestVaultResource { } export interface TestVaultResourcePromise extends PromiseLike { + withBrowserLogs(options?: WithBrowserLogsOptions): TestVaultResourcePromise; withContainerRegistry(registry: Awaitable): TestVaultResourcePromise; withBindMount(source: string, target: string, options?: WithBindMountOptions): TestVaultResourcePromise; withEntrypoint(entrypoint: string): TestVaultResourcePromise; @@ -30688,6 +30862,23 @@ class TestVaultResourceImpl extends ResourceBuilderBase super(handle, client); } + /** @internal */ + private async _withBrowserLogsInternal(browser?: string): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (browser !== undefined) rpcArgs.browser = browser; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withBrowserLogs', + rpcArgs + ); + return new TestVaultResourceImpl(result, this._client); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): TestVaultResourcePromise { + const browser = options?.browser; + return new TestVaultResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + } + /** @internal */ private async _withContainerRegistryInternal(registry: Awaitable): Promise { registry = isPromiseLike(registry) ? await registry : registry; @@ -32633,6 +32824,11 @@ class TestVaultResourcePromiseImpl implements TestVaultResourcePromise { return this._promise.then(onfulfilled, onrejected); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): TestVaultResourcePromise { + return new TestVaultResourcePromiseImpl(this._promise.then(obj => obj.withBrowserLogs(options)), this._client); + } + /** Configures a resource to use a container registry */ withContainerRegistry(registry: Awaitable): TestVaultResourcePromise { return new TestVaultResourcePromiseImpl(this._promise.then(obj => obj.withContainerRegistry(registry)), this._client); @@ -34873,6 +35069,7 @@ class ResourceWithContainerFilesPromiseImpl implements ResourceWithContainerFile export interface ResourceWithEndpoints { toJSON(): MarshalledHandle; + withBrowserLogs(options?: WithBrowserLogsOptions): ResourceWithEndpointsPromise; withMcpServer(options?: WithMcpServerOptions): ResourceWithEndpointsPromise; withEndpointCallback(endpointName: string, callback: (obj: EndpointUpdateContext) => Promise, options?: WithEndpointCallbackOptions): ResourceWithEndpointsPromise; withHttpEndpointCallback(callback: (obj: EndpointUpdateContext) => Promise, options?: WithHttpEndpointCallbackOptions): ResourceWithEndpointsPromise; @@ -34891,6 +35088,7 @@ export interface ResourceWithEndpoints { } export interface ResourceWithEndpointsPromise extends PromiseLike { + withBrowserLogs(options?: WithBrowserLogsOptions): ResourceWithEndpointsPromise; withMcpServer(options?: WithMcpServerOptions): ResourceWithEndpointsPromise; withEndpointCallback(endpointName: string, callback: (obj: EndpointUpdateContext) => Promise, options?: WithEndpointCallbackOptions): ResourceWithEndpointsPromise; withHttpEndpointCallback(callback: (obj: EndpointUpdateContext) => Promise, options?: WithHttpEndpointCallbackOptions): ResourceWithEndpointsPromise; @@ -34917,6 +35115,23 @@ class ResourceWithEndpointsImpl extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle }; + if (browser !== undefined) rpcArgs.browser = browser; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withBrowserLogs', + rpcArgs + ); + return new ResourceWithEndpointsImpl(result, this._client); + } + + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): ResourceWithEndpointsPromise { + const browser = options?.browser; + return new ResourceWithEndpointsPromiseImpl(this._withBrowserLogsInternal(browser), this._client); + } + /** @internal */ private async _withMcpServerInternal(path?: string, endpointName?: string): Promise { const rpcArgs: Record = { builder: this._handle }; @@ -35251,6 +35466,11 @@ class ResourceWithEndpointsPromiseImpl implements ResourceWithEndpointsPromise { return this._promise.then(onfulfilled, onrejected); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + withBrowserLogs(options?: WithBrowserLogsOptions): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromiseImpl(this._promise.then(obj => obj.withBrowserLogs(options)), this._client); + } + /** Configures an MCP server endpoint on the resource */ withMcpServer(options?: WithMcpServerOptions): ResourceWithEndpointsPromise { return new ResourceWithEndpointsPromiseImpl(this._promise.then(obj => obj.withMcpServer(options)), this._client); diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs new file mode 100644 index 00000000000..1302268667c --- /dev/null +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -0,0 +1,455 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Hosting.Resources; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Aspire.Hosting.Eventing; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using HealthStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus; + +namespace Aspire.Hosting.Tests; + +[Trait("Partition", "2")] +public class BrowserLogsBuilderExtensionsTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public void WithBrowserLogs_CreatesChildResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(browser: "chrome"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var browserLogsResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("web-browser-logs", browserLogsResource.Name); + Assert.Equal(web.Resource.Name, browserLogsResource.ParentResource.Name); + Assert.Equal("chrome", browserLogsResource.Browser); + + Assert.True(browserLogsResource.TryGetAnnotationsOfType(out var relationships)); + var parentRelationship = Assert.Single(relationships, relationship => relationship.Type == "Parent"); + Assert.Equal(web.Resource.Name, parentRelationship.Resource.Name); + + var command = Assert.Single(browserLogsResource.Annotations.OfType(), annotation => annotation.Name == BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName); + Assert.Equal(CommandStrings.OpenTrackedBrowserName, command.DisplayName); + Assert.Equal(CommandStrings.OpenTrackedBrowserDescription, command.DisplayDescription); + + var snapshot = browserLogsResource.Annotations.OfType().Single().InitialSnapshot; + Assert.Equal(BrowserLogsBuilderExtensions.BrowserResourceType, snapshot.ResourceType); + Assert.NotNull(snapshot.CreationTimeStamp); + Assert.Contains(snapshot.Properties, property => property.Name == CustomResourceKnownProperties.Source && Equals(property.Value, "web")); + Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.BrowserPropertyName && Equals(property.Value, "chrome")); + Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName && Equals(property.Value, 0)); + Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.ActiveSessionsPropertyName && Equals(property.Value, "None")); + Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.TotalSessionsLaunchedPropertyName && Equals(property.Value, 0)); + Assert.Empty(snapshot.HealthReports); + } + + [Fact] + public async Task WithBrowserLogs_CommandStartsTrackedSession() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var sessionManager = new FakeBrowserLogsSessionManager(); + builder.Services.AddSingleton(sessionManager); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(browser: "chrome"); + + using var app = builder.Build(); + await app.StartAsync(); + + var browserLogsResource = app.Services.GetRequiredService().Resources.OfType().Single(); + var result = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + + Assert.True(result.Success); + + var call = Assert.Single(sessionManager.Calls); + Assert.Same(browserLogsResource, call.Resource); + Assert.Equal(browserLogsResource.Name, call.ResourceName); + Assert.Equal(new Uri("http://localhost:8080", UriKind.Absolute), call.Url); + } + + [Fact] + public async Task WithBrowserLogs_CommandFailsWhenEndpointIsMissing() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var sessionManager = new FakeBrowserLogsSessionManager(); + builder.Services.AddSingleton(sessionManager); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(); + + using var app = builder.Build(); + await app.StartAsync(); + + var browserLogsResource = app.Services.GetRequiredService().Resources.OfType().Single(); + var result = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + + Assert.False(result.Success); + Assert.Equal("Resource 'web' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to.", result.Message); + Assert.Empty(sessionManager.Calls); + } + + [Fact] + public async Task WithBrowserLogs_CommandBecomesEnabledWhenParentReady() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = KnownResourceStates.NotStarted, + Properties = [] + }); + + web.WithBrowserLogs(browser: "chrome"); + + using var app = builder.Build(); + await app.StartAsync(); + + var browserLogsResource = app.Services.GetRequiredService().Resources.OfType().Single(); + var initialEvent = await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => resourceEvent.Snapshot.Commands.Any(command => + command.Name == BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName && + command.State == ResourceCommandState.Disabled)).DefaultTimeout(); + + Assert.Equal(ResourceCommandState.Disabled, initialEvent.Snapshot.Commands.Single(command => command.Name == BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).State); + + await app.ResourceNotifications.PublishUpdateAsync(web.Resource, snapshot => snapshot with + { + State = KnownResourceStates.Running + }).DefaultTimeout(); + + var eventing = app.Services.GetRequiredService(); + await eventing.PublishAsync(new ResourceReadyEvent(web.Resource, app.Services)).DefaultTimeout(); + + var enabledEvent = await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => resourceEvent.Snapshot.Commands.Any(command => + command.Name == BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName && + command.State == ResourceCommandState.Enabled)).DefaultTimeout(); + + Assert.Equal(ResourceCommandState.Enabled, enabledEvent.Snapshot.Commands.Single(command => command.Name == BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).State); + } + + [Fact] + public async Task WithBrowserLogs_CommandTracksMultipleSessionsWithUniqueIds() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var sessionFactory = new FakeBrowserLogsRunningSessionFactory(); + + builder.Services.AddSingleton(sp => + new BrowserLogsSessionManager( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sessionFactory)); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(browser: "chrome"); + + using var app = builder.Build(); + await app.StartAsync(); + + var browserLogsResource = app.Services.GetRequiredService().Resources.OfType().Single(); + + var firstResult = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + Assert.True(firstResult.Success); + + var firstSession = Assert.Single(sessionFactory.Sessions); + Assert.Equal("session-0001", firstSession.SessionId); + + var firstRunningEvent = await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 1) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionsPropertyName, "session-0001 (PID 1001)") && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.TotalSessionsLaunchedPropertyName, 1) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.LastSessionPropertyName, "session-0001") && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserExecutablePropertyName, "/fake/browser-1") && + resourceEvent.Snapshot.HealthReports.Any(report => report.Name == "session-0001" && report.Status == HealthStatus.Healthy)).DefaultTimeout(); + + Assert.Single(firstRunningEvent.Snapshot.HealthReports); + Assert.Equal(0, firstSession.StopCallCount); + + var secondResult = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + Assert.True(secondResult.Success); + + Assert.Equal(2, sessionFactory.Sessions.Count); + var secondSession = sessionFactory.Sessions[1]; + Assert.Equal("session-0002", secondSession.SessionId); + + var secondRunningEvent = await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 2) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionsPropertyName, "session-0001 (PID 1001), session-0002 (PID 1002)") && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.TotalSessionsLaunchedPropertyName, 2) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.LastSessionPropertyName, "session-0002") && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserExecutablePropertyName, "/fake/browser-2") && + resourceEvent.Snapshot.HealthReports.Any(report => report.Name == "session-0001" && report.Status == HealthStatus.Healthy) && + resourceEvent.Snapshot.HealthReports.Any(report => report.Name == "session-0002" && report.Status == HealthStatus.Healthy)).DefaultTimeout(); + + Assert.Equal(2, secondRunningEvent.Snapshot.HealthReports.Length); + Assert.Equal(0, firstSession.StopCallCount); + + await firstSession.CompleteAsync(exitCode: 0); + + var firstCompletedEvent = await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 1) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionsPropertyName, "session-0002 (PID 1002)") && + resourceEvent.Snapshot.HealthReports.Length == 1 && + resourceEvent.Snapshot.HealthReports[0].Name == "session-0002").DefaultTimeout(); + + Assert.Equal("session-0002", firstCompletedEvent.Snapshot.HealthReports[0].Name); + + await secondSession.CompleteAsync(exitCode: 0); + + var allCompletedEvent = await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Finished && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 0) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionsPropertyName, "None") && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.TotalSessionsLaunchedPropertyName, 2) && + resourceEvent.Snapshot.HealthReports.IsEmpty).DefaultTimeout(); + + Assert.Equal(KnownResourceStates.Finished, allCompletedEvent.Snapshot.State?.Text); + } + + [Fact] + public async Task BrowserEventLogger_LogsSuccessfulNetworkRequests() + { + var resourceLoggerService = ConsoleLoggingTestHelpers.GetResourceLoggerService(); + var resourceLogger = resourceLoggerService.GetLogger("web-browser-logs"); + var eventLogger = new BrowserLogsSessionManager.BrowserEventLogger("session-0001", resourceLogger); + var logs = await CaptureLogsAsync(resourceLoggerService, "web-browser-logs", () => + { + eventLogger.HandleEvent("Network.requestWillBeSent", ParseJsonElement(""" + { + "requestId": "request-1", + "timestamp": 1.5, + "type": "Fetch", + "request": { + "url": "https://example.test/api/todos", + "method": "GET" + } + } + """)); + eventLogger.HandleEvent("Network.responseReceived", ParseJsonElement(""" + { + "requestId": "request-1", + "timestamp": 1.6, + "type": "Fetch", + "response": { + "url": "https://example.test/api/todos", + "status": 200, + "statusText": "OK", + "fromDiskCache": false, + "fromServiceWorker": false + } + } + """)); + eventLogger.HandleEvent("Network.loadingFinished", ParseJsonElement(""" + { + "requestId": "request-1", + "timestamp": 1.75, + "encodedDataLength": 1024 + } + """)); + }); + var log = Assert.Single(logs); + + Assert.Equal("2000-12-29T20:59:59.0000000Z [session-0001] [network.fetch] GET https://example.test/api/todos -> 200 OK (250 ms, 1024 B)", log.Content); + } + + [Fact] + public async Task BrowserEventLogger_LogsFailedNetworkRequests() + { + var resourceLoggerService = ConsoleLoggingTestHelpers.GetResourceLoggerService(); + var resourceLogger = resourceLoggerService.GetLogger("web-browser-logs"); + var eventLogger = new BrowserLogsSessionManager.BrowserEventLogger("session-0002", resourceLogger); + var logs = await CaptureLogsAsync(resourceLoggerService, "web-browser-logs", () => + { + eventLogger.HandleEvent("Network.requestWillBeSent", ParseJsonElement(""" + { + "requestId": "request-2", + "timestamp": 5.0, + "type": "Document", + "request": { + "url": "https://127.0.0.1:1/browser-network-failure", + "method": "GET" + } + } + """)); + eventLogger.HandleEvent("Network.loadingFailed", ParseJsonElement(""" + { + "requestId": "request-2", + "timestamp": 5.15, + "errorText": "net::ERR_CONNECTION_REFUSED", + "canceled": false + } + """)); + }); + var log = Assert.Single(logs); + + Assert.Equal("2000-12-29T20:59:59.0000000Z [session-0002] [network.document] GET https://127.0.0.1:1/browser-network-failure failed: net::ERR_CONNECTION_REFUSED (150 ms)", log.Content); + } + + private sealed class TestHttpResource(string name) : Resource(name), IResourceWithEndpoints; + + private static bool HasProperty(CustomResourceSnapshot snapshot, string name, object expectedValue) => + snapshot.Properties.Any(property => property.Name == name && Equals(property.Value, expectedValue)); + + private static JsonElement ParseJsonElement(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private static async Task> CaptureLogsAsync(ResourceLoggerService resourceLoggerService, string resourceName, Action writeLogs) + { + var subscribedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var watchTask = ConsoleLoggingTestHelpers.WatchForLogsAsync(resourceLoggerService.WatchAsync(resourceName), targetLogCount: 1); + + _ = Task.Run(async () => + { + await foreach (var subscriber in resourceLoggerService.WatchAnySubscribersAsync()) + { + if (subscriber.Name == resourceName && subscriber.AnySubscribers) + { + subscribedTcs.TrySetResult(); + return; + } + } + }); + + await subscribedTcs.Task.DefaultTimeout(); + writeLogs(); + + return await watchTask.DefaultTimeout(); + } + + private sealed class FakeBrowserLogsSessionManager : IBrowserLogsSessionManager + { + public List Calls { get; } = []; + + public Task StartSessionAsync(BrowserLogsResource resource, string resourceName, Uri url, CancellationToken cancellationToken) + { + Calls.Add(new SessionStartCall(resource, resourceName, url)); + return Task.CompletedTask; + } + } + + private sealed record SessionStartCall(BrowserLogsResource Resource, string ResourceName, Uri Url); + + private sealed class FakeBrowserLogsRunningSessionFactory : IBrowserLogsRunningSessionFactory + { + public List Sessions { get; } = []; + + public Task StartSessionAsync( + BrowserLogsResource resource, + string resourceName, + Uri url, + string sessionId, + ILogger resourceLogger, + CancellationToken cancellationToken) + { + var session = new FakeBrowserLogsRunningSession( + sessionId, + $"/fake/browser-{Sessions.Count + 1}", + processId: 1001 + Sessions.Count, + startedAt: DateTime.UtcNow); + + Sessions.Add(session); + + return Task.FromResult(session); + } + } + + private sealed class FakeBrowserLogsRunningSession( + string sessionId, + string browserExecutable, + int processId, + DateTime startedAt) : IBrowserLogsRunningSession + { + private Func? _onCompleted; + + public string SessionId { get; } = sessionId; + + public string BrowserExecutable { get; } = browserExecutable; + + public int ProcessId { get; } = processId; + + public DateTime StartedAt { get; } = startedAt; + + public int StopCallCount { get; private set; } + + public void StartCompletionObserver(Func onCompleted) + { + _onCompleted = onCompleted; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + StopCallCount++; + return Task.CompletedTask; + } + + public Task CompleteAsync(int exitCode, Exception? error = null) + { + Assert.NotNull(_onCompleted); + return _onCompleted!(exitCode, error); + } + } +} From ee8df5295ae87f4de20eb0029dc9f22a56642e5c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 19 Apr 2026 15:40:27 -0700 Subject: [PATCH 2/8] Harden tracked browser log runtime Add typed CDP protocol parsing, preserve connection failure reasons in resource logs, and strengthen reconnect handling for tracked browser sessions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogsBuilderExtensions.cs | 28 +- src/Aspire.Hosting/BrowserLogsProtocol.cs | 715 ++++++++++ .../BrowserLogsSessionManager.cs | 1178 +++++++++++------ .../BrowserLogsBuilderExtensionsTests.cs | 95 +- .../BrowserLogsProtocolTests.cs | 80 ++ .../BrowserLogsSessionManagerTests.cs | 95 ++ 6 files changed, 1757 insertions(+), 434 deletions(-) create mode 100644 src/Aspire.Hosting/BrowserLogsProtocol.cs create mode 100644 tests/Aspire.Hosting.Tests/BrowserLogsProtocolTests.cs create mode 100644 tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs diff --git a/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs index 28ff0b412aa..93ee51d7644 100644 --- a/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs +++ b/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs @@ -34,18 +34,22 @@ public static class BrowserLogsBuilderExtensions /// "msedge" and "chrome", or an explicit browser executable path. /// /// A reference to the original for further chaining. - /// - /// - /// This method adds a child browser logs resource beneath the parent resource represented by . - /// The child resource exposes a dashboard command that launches a Chromium-based browser in a tracked mode, attaches to - /// the browser's debugging protocol, and forwards browser console, error, and exception output to the child resource's - /// console log stream. - /// - /// - /// 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. - /// - /// +/// +/// +/// This method adds a child browser logs resource beneath the parent resource represented by . +/// The child resource exposes a dashboard command that launches a Chromium-based browser in a tracked mode, attaches to +/// the browser's debugging protocol, and forwards browser console, error, and exception output to the child resource's +/// console log stream. +/// +/// +/// The tracked browser session uses the Chrome DevTools +/// Protocol (CDP) to subscribe to browser runtime, log, page, and network events. +/// +/// +/// 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. +/// +/// /// /// Add tracked browser logs for a web front end: /// diff --git a/src/Aspire.Hosting/BrowserLogsProtocol.cs b/src/Aspire.Hosting/BrowserLogsProtocol.cs new file mode 100644 index 00000000000..72e81e01b89 --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogsProtocol.cs @@ -0,0 +1,715 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Aspire.Hosting; + +// Chrome DevTools Protocol (CDP) references: +// - Message envelope and domain index: https://chromedevtools.github.io/devtools-protocol/ +// - Target domain: https://chromedevtools.github.io/devtools-protocol/tot/Target/ +// - Runtime domain: https://chromedevtools.github.io/devtools-protocol/tot/Runtime/ +// - Log domain: https://chromedevtools.github.io/devtools-protocol/tot/Log/ +// - Page domain: https://chromedevtools.github.io/devtools-protocol/tot/Page/ +// - Network domain: https://chromedevtools.github.io/devtools-protocol/tot/Network/ +// +// Browser websocket frames are JSON objects shaped like: +// - command request: { "id": 1, "method": "...", "params": { ... }, "sessionId": "..."? } +// - command response: { "id": 1, "result": { ... } } or { "id": 1, "error": { ... } } +// - event: { "method": "...", "params": { ... }, "sessionId": "..."? } +// +// Keep this file focused on protocol serialization and parsing so browser networking and session orchestration can be +// tested independently from CDP frame handling. +internal static class BrowserLogsProtocol +{ + internal const string BrowserCloseMethod = "Browser.close"; + internal const string LogEnableMethod = "Log.enable"; + internal const string LogEntryAddedMethod = "Log.entryAdded"; + internal const string NetworkEnableMethod = "Network.enable"; + internal const string NetworkLoadingFailedMethod = "Network.loadingFailed"; + internal const string NetworkLoadingFinishedMethod = "Network.loadingFinished"; + internal const string NetworkRequestWillBeSentMethod = "Network.requestWillBeSent"; + internal const string NetworkResponseReceivedMethod = "Network.responseReceived"; + internal const string PageEnableMethod = "Page.enable"; + internal const string PageNavigateMethod = "Page.navigate"; + internal const string RuntimeConsoleApiCalledMethod = "Runtime.consoleAPICalled"; + internal const string RuntimeEnableMethod = "Runtime.enable"; + internal const string RuntimeExceptionThrownMethod = "Runtime.exceptionThrown"; + internal const string TargetAttachToTargetMethod = "Target.attachToTarget"; + internal const string TargetCreateTargetMethod = "Target.createTarget"; + + internal static BrowserLogsProtocolMessageHeader ParseMessageHeader(ReadOnlySpan framePayload) + { + var reader = new Utf8JsonReader(framePayload, isFinalBlock: true, state: default); + + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) + { + throw new InvalidOperationException("Tracked browser protocol frame was not a JSON object."); + } + + long? id = null; + string? method = null; + string? sessionId = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new InvalidOperationException("Tracked browser protocol frame was malformed."); + } + + var propertyName = reader.GetString(); + if (!reader.Read()) + { + throw new InvalidOperationException("Tracked browser protocol frame ended unexpectedly."); + } + + switch (propertyName) + { + case "id": + if (!reader.TryGetInt64(out var parsedId)) + { + throw new InvalidOperationException("Tracked browser protocol response id was not an integer."); + } + + id = parsedId; + break; + case "method": + method = reader.TokenType == JsonTokenType.String + ? reader.GetString() + : throw new InvalidOperationException("Tracked browser protocol event method was not a string."); + break; + case "sessionId": + sessionId = reader.TokenType == JsonTokenType.String + ? reader.GetString() + : null; + break; + default: + reader.Skip(); + break; + } + } + + return new BrowserLogsProtocolMessageHeader(id, method, sessionId); + } + + internal static byte[] CreateCommandFrame(long id, string method, string? sessionId, Action? writeParameters) + { + var buffer = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer); + + writer.WriteStartObject(); + writer.WriteNumber("id", id); + writer.WriteString("method", method); + + if (sessionId is not null) + { + writer.WriteString("sessionId", sessionId); + } + + if (writeParameters is not null) + { + writer.WritePropertyName("params"); + writer.WriteStartObject(); + writeParameters(writer); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + writer.Flush(); + + return buffer.WrittenSpan.ToArray(); + } + + internal static BrowserLogsProtocolEvent? ParseEvent(BrowserLogsProtocolMessageHeader header, ReadOnlySpan framePayload) => header.Method switch + { + RuntimeConsoleApiCalledMethod => CreateConsoleApiCalledEvent(framePayload), + RuntimeExceptionThrownMethod => CreateExceptionThrownEvent(framePayload), + LogEntryAddedMethod => CreateLogEntryAddedEvent(framePayload), + NetworkRequestWillBeSentMethod => CreateRequestWillBeSentEvent(framePayload), + NetworkResponseReceivedMethod => CreateResponseReceivedEvent(framePayload), + NetworkLoadingFinishedMethod => CreateLoadingFinishedEvent(framePayload), + NetworkLoadingFailedMethod => CreateLoadingFailedEvent(framePayload), + _ => null + }; + + internal static BrowserLogsCreateTargetResult ParseCreateTargetResponse(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsCreateTargetResponseEnvelope); + ThrowIfProtocolError(envelope.Error); + + return envelope.Result ?? throw new InvalidOperationException("Tracked browser target creation did not return a result payload."); + } + + internal static BrowserLogsAttachToTargetResult ParseAttachToTargetResponse(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsAttachToTargetResponseEnvelope); + ThrowIfProtocolError(envelope.Error); + + return envelope.Result ?? throw new InvalidOperationException("Tracked browser target attachment did not return a result payload."); + } + + internal static BrowserLogsCommandAck ParseCommandAckResponse(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsCommandAckResponseEnvelope); + ThrowIfProtocolError(envelope.Error); + + return BrowserLogsCommandAck.Instance; + } + + internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLength = 512) + { + var text = Encoding.UTF8.GetString(framePayload); + return text.Length <= maxLength + ? text + : $"{text[..maxLength]}..."; + } + + private static BrowserLogsConsoleApiCalledEvent? CreateConsoleApiCalledEvent(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsConsoleApiCalledEnvelope); + return envelope.Params is null + ? null + : new BrowserLogsConsoleApiCalledEvent(envelope.SessionId, envelope.Params); + } + + private static BrowserLogsExceptionThrownEvent? CreateExceptionThrownEvent(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsExceptionThrownEnvelope); + return envelope.Params is null + ? null + : new BrowserLogsExceptionThrownEvent(envelope.SessionId, envelope.Params); + } + + private static BrowserLogsLogEntryAddedEvent? CreateLogEntryAddedEvent(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsLogEntryAddedEnvelope); + return envelope.Params is null + ? null + : new BrowserLogsLogEntryAddedEvent(envelope.SessionId, envelope.Params); + } + + private static BrowserLogsRequestWillBeSentEvent? CreateRequestWillBeSentEvent(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsRequestWillBeSentEnvelope); + return envelope.Params is null + ? null + : new BrowserLogsRequestWillBeSentEvent(envelope.SessionId, envelope.Params); + } + + private static BrowserLogsResponseReceivedEvent? CreateResponseReceivedEvent(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsResponseReceivedEnvelope); + return envelope.Params is null + ? null + : new BrowserLogsResponseReceivedEvent(envelope.SessionId, envelope.Params); + } + + private static BrowserLogsLoadingFinishedEvent? CreateLoadingFinishedEvent(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsLoadingFinishedEnvelope); + return envelope.Params is null + ? null + : new BrowserLogsLoadingFinishedEvent(envelope.SessionId, envelope.Params); + } + + private static BrowserLogsLoadingFailedEvent? CreateLoadingFailedEvent(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsLoadingFailedEnvelope); + return envelope.Params is null + ? null + : new BrowserLogsLoadingFailedEvent(envelope.SessionId, envelope.Params); + } + + private static T DeserializeFrame(ReadOnlySpan framePayload, JsonTypeInfo jsonTypeInfo) + where T : class + { + return JsonSerializer.Deserialize(framePayload, jsonTypeInfo) + ?? throw new InvalidOperationException("Tracked browser protocol frame was empty."); + } + + private static void ThrowIfProtocolError(BrowserLogsProtocolError? error) + { + if (error is null) + { + return; + } + + var message = string.IsNullOrWhiteSpace(error.Message) + ? "Unknown browser protocol error." + : error.Message; + + if (error.Code is int code) + { + throw new InvalidOperationException($"{message} (CDP error {code})."); + } + + throw new InvalidOperationException(message); + } +} + +internal readonly record struct BrowserLogsProtocolMessageHeader(long? Id, string? Method, string? SessionId); + +internal abstract record BrowserLogsProtocolEvent(string Method, string? SessionId); + +internal sealed record BrowserLogsConsoleApiCalledEvent(string? SessionId, BrowserLogsRuntimeConsoleApiCalledParameters Parameters) + : BrowserLogsProtocolEvent(BrowserLogsProtocol.RuntimeConsoleApiCalledMethod, SessionId); + +internal sealed record BrowserLogsExceptionThrownEvent(string? SessionId, BrowserLogsExceptionThrownParameters Parameters) + : BrowserLogsProtocolEvent(BrowserLogsProtocol.RuntimeExceptionThrownMethod, SessionId); + +internal sealed record BrowserLogsLoadingFailedEvent(string? SessionId, BrowserLogsLoadingFailedParameters Parameters) + : BrowserLogsProtocolEvent(BrowserLogsProtocol.NetworkLoadingFailedMethod, SessionId); + +internal sealed record BrowserLogsLoadingFinishedEvent(string? SessionId, BrowserLogsLoadingFinishedParameters Parameters) + : BrowserLogsProtocolEvent(BrowserLogsProtocol.NetworkLoadingFinishedMethod, SessionId); + +internal sealed record BrowserLogsLogEntryAddedEvent(string? SessionId, BrowserLogsLogEntryAddedParameters Parameters) + : BrowserLogsProtocolEvent(BrowserLogsProtocol.LogEntryAddedMethod, SessionId); + +internal sealed record BrowserLogsRequestWillBeSentEvent(string? SessionId, BrowserLogsRequestWillBeSentParameters Parameters) + : BrowserLogsProtocolEvent(BrowserLogsProtocol.NetworkRequestWillBeSentMethod, SessionId); + +internal sealed record BrowserLogsResponseReceivedEvent(string? SessionId, BrowserLogsResponseReceivedParameters Parameters) + : BrowserLogsProtocolEvent(BrowserLogsProtocol.NetworkResponseReceivedMethod, SessionId); + +internal sealed class BrowserLogsAttachToTargetResponseEnvelope +{ + [JsonPropertyName("error")] + public BrowserLogsProtocolError? Error { get; init; } + + [JsonPropertyName("id")] + public long Id { get; init; } + + [JsonPropertyName("result")] + public BrowserLogsAttachToTargetResult? Result { get; init; } +} + +internal sealed class BrowserLogsAttachToTargetResult +{ + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } +} + +internal sealed class BrowserLogsCommandAck +{ + public static BrowserLogsCommandAck Instance { get; } = new(); + + private BrowserLogsCommandAck() + { + } +} + +internal sealed class BrowserLogsCommandAckResponseEnvelope +{ + [JsonPropertyName("error")] + public BrowserLogsProtocolError? Error { get; init; } + + [JsonPropertyName("id")] + public long Id { get; init; } +} + +internal sealed class BrowserLogsConsoleApiCalledEnvelope +{ + [JsonPropertyName("params")] + public BrowserLogsRuntimeConsoleApiCalledParameters? Params { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } +} + +internal sealed class BrowserLogsCreateTargetResponseEnvelope +{ + [JsonPropertyName("error")] + public BrowserLogsProtocolError? Error { get; init; } + + [JsonPropertyName("id")] + public long Id { get; init; } + + [JsonPropertyName("result")] + public BrowserLogsCreateTargetResult? Result { get; init; } +} + +internal sealed class BrowserLogsCreateTargetResult +{ + [JsonPropertyName("targetId")] + public string? TargetId { get; init; } +} + +internal sealed class BrowserLogsExceptionDetails : BrowserLogsSourceLocation +{ + [JsonPropertyName("exception")] + public BrowserLogsExceptionObject? Exception { get; init; } + + [JsonPropertyName("text")] + public string? Text { get; init; } +} + +internal sealed class BrowserLogsExceptionObject +{ + [JsonPropertyName("description")] + public string? Description { get; init; } +} + +internal sealed class BrowserLogsExceptionThrownEnvelope +{ + [JsonPropertyName("params")] + public BrowserLogsExceptionThrownParameters? Params { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } +} + +internal sealed class BrowserLogsExceptionThrownParameters +{ + [JsonPropertyName("exceptionDetails")] + public BrowserLogsExceptionDetails? ExceptionDetails { get; init; } +} + +internal sealed class BrowserLogsLoadingFailedEnvelope +{ + [JsonPropertyName("params")] + public BrowserLogsLoadingFailedParameters? Params { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } +} + +internal sealed class BrowserLogsLoadingFailedParameters +{ + [JsonPropertyName("blockedReason")] + public string? BlockedReason { get; init; } + + [JsonPropertyName("canceled")] + public bool? Canceled { get; init; } + + [JsonPropertyName("errorText")] + public string? ErrorText { get; init; } + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + [JsonPropertyName("timestamp")] + public double? Timestamp { get; init; } +} + +internal sealed class BrowserLogsLoadingFinishedEnvelope +{ + [JsonPropertyName("params")] + public BrowserLogsLoadingFinishedParameters? Params { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } +} + +internal sealed class BrowserLogsLoadingFinishedParameters +{ + [JsonPropertyName("encodedDataLength")] + public double? EncodedDataLength { get; init; } + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + [JsonPropertyName("timestamp")] + public double? Timestamp { get; init; } +} + +internal sealed class BrowserLogsLogEntry : BrowserLogsSourceLocation +{ + [JsonPropertyName("level")] + public string? Level { get; init; } + + [JsonPropertyName("text")] + public string? Text { get; init; } +} + +internal sealed class BrowserLogsLogEntryAddedEnvelope +{ + [JsonPropertyName("params")] + public BrowserLogsLogEntryAddedParameters? Params { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } +} + +internal sealed class BrowserLogsLogEntryAddedParameters +{ + [JsonPropertyName("entry")] + public BrowserLogsLogEntry? Entry { get; init; } +} + +internal sealed class BrowserLogsProtocolError +{ + [JsonPropertyName("code")] + public int? Code { get; init; } + + [JsonPropertyName("message")] + public string? Message { get; init; } +} + +internal sealed class BrowserLogsProtocolRemoteObject +{ + [JsonPropertyName("description")] + public string? Description { get; init; } + + [JsonPropertyName("unserializableValue")] + public string? UnserializableValue { get; init; } + + [JsonPropertyName("value")] + public BrowserLogsProtocolValue? Value { get; init; } +} + +internal sealed class BrowserLogsRequest +{ + [JsonPropertyName("method")] + public string? Method { get; init; } + + [JsonPropertyName("url")] + public string? Url { get; init; } +} + +internal sealed class BrowserLogsRequestWillBeSentEnvelope +{ + [JsonPropertyName("params")] + public BrowserLogsRequestWillBeSentParameters? Params { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } +} + +internal sealed class BrowserLogsRequestWillBeSentParameters +{ + [JsonPropertyName("redirectResponse")] + public BrowserLogsResponse? RedirectResponse { get; init; } + + [JsonPropertyName("request")] + public BrowserLogsRequest? Request { get; init; } + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + [JsonPropertyName("timestamp")] + public double? Timestamp { get; init; } + + [JsonPropertyName("type")] + public string? Type { get; init; } +} + +internal sealed class BrowserLogsResponse +{ + [JsonPropertyName("fromDiskCache")] + public bool? FromDiskCache { get; init; } + + [JsonPropertyName("fromServiceWorker")] + public bool? FromServiceWorker { get; init; } + + [JsonPropertyName("status")] + public int? Status { get; init; } + + [JsonPropertyName("statusText")] + public string? StatusText { get; init; } + + [JsonPropertyName("url")] + public string? Url { get; init; } +} + +internal sealed class BrowserLogsResponseReceivedEnvelope +{ + [JsonPropertyName("params")] + public BrowserLogsResponseReceivedParameters? Params { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } +} + +internal sealed class BrowserLogsResponseReceivedParameters +{ + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + [JsonPropertyName("response")] + public BrowserLogsResponse? Response { get; init; } + + [JsonPropertyName("type")] + public string? Type { get; init; } +} + +internal sealed class BrowserLogsRuntimeConsoleApiCalledParameters +{ + [JsonPropertyName("args")] + public BrowserLogsProtocolRemoteObject[]? Args { get; init; } + + [JsonPropertyName("type")] + public string? Type { get; init; } +} + +internal class BrowserLogsSourceLocation +{ + [JsonPropertyName("columnNumber")] + public int? ColumnNumber { get; init; } + + [JsonPropertyName("lineNumber")] + public int? LineNumber { get; init; } + + [JsonPropertyName("url")] + public string? Url { get; init; } +} + +[JsonConverter(typeof(BrowserLogsProtocolValueJsonConverter))] +internal abstract record BrowserLogsProtocolValue; + +internal sealed record BrowserLogsProtocolArrayValue(IReadOnlyList Items) : BrowserLogsProtocolValue; + +internal sealed record BrowserLogsProtocolBooleanValue(bool Value) : BrowserLogsProtocolValue; + +internal sealed record BrowserLogsProtocolNullValue : BrowserLogsProtocolValue +{ + public static BrowserLogsProtocolNullValue Instance { get; } = new(); + + private BrowserLogsProtocolNullValue() + { + } +} + +internal sealed record BrowserLogsProtocolNumberValue(string RawValue) : BrowserLogsProtocolValue; + +internal sealed record BrowserLogsProtocolObjectValue(IReadOnlyDictionary Properties) : BrowserLogsProtocolValue; + +internal sealed record BrowserLogsProtocolStringValue(string Value) : BrowserLogsProtocolValue; + +internal sealed class BrowserLogsProtocolValueJsonConverter : JsonConverter +{ + public override BrowserLogsProtocolValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType switch + { + JsonTokenType.StartArray => ReadArray(ref reader, options), + JsonTokenType.StartObject => ReadObject(ref reader, options), + JsonTokenType.String => new BrowserLogsProtocolStringValue(reader.GetString() ?? string.Empty), + JsonTokenType.True => new BrowserLogsProtocolBooleanValue(true), + JsonTokenType.False => new BrowserLogsProtocolBooleanValue(false), + JsonTokenType.Null => BrowserLogsProtocolNullValue.Instance, + JsonTokenType.Number => new BrowserLogsProtocolNumberValue(GetRawNumber(ref reader)), + _ => throw new JsonException($"Unsupported JSON token '{reader.TokenType}' for tracked browser protocol value.") + }; + + public override void Write(Utf8JsonWriter writer, BrowserLogsProtocolValue value, JsonSerializerOptions options) + { + switch (value) + { + case BrowserLogsProtocolArrayValue arrayValue: + writer.WriteStartArray(); + foreach (var item in arrayValue.Items) + { + Write(writer, item, options); + } + + writer.WriteEndArray(); + break; + case BrowserLogsProtocolBooleanValue booleanValue: + writer.WriteBooleanValue(booleanValue.Value); + break; + case BrowserLogsProtocolNullValue: + writer.WriteNullValue(); + break; + case BrowserLogsProtocolNumberValue numberValue: + writer.WriteRawValue(numberValue.RawValue, skipInputValidation: true); + break; + case BrowserLogsProtocolObjectValue objectValue: + writer.WriteStartObject(); + foreach (var (propertyName, propertyValue) in objectValue.Properties) + { + writer.WritePropertyName(propertyName); + Write(writer, propertyValue, options); + } + + writer.WriteEndObject(); + break; + case BrowserLogsProtocolStringValue stringValue: + writer.WriteStringValue(stringValue.Value); + break; + default: + throw new JsonException($"Unsupported tracked browser protocol value type '{value.GetType()}'."); + } + } + + private static string GetRawNumber(ref Utf8JsonReader reader) + { + return reader.HasValueSequence + ? Encoding.UTF8.GetString(reader.ValueSequence.ToArray()) + : Encoding.UTF8.GetString(reader.ValueSpan); + } + + private static BrowserLogsProtocolArrayValue ReadArray(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var items = new List(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + break; + } + + items.Add(ReadValue(ref reader, options)); + } + + return new BrowserLogsProtocolArrayValue(items); + } + + private static BrowserLogsProtocolObjectValue ReadObject(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var properties = new Dictionary(StringComparer.Ordinal); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Tracked browser protocol object value was malformed."); + } + + var propertyName = reader.GetString() + ?? throw new JsonException("Tracked browser protocol object property name was null."); + + if (!reader.Read()) + { + throw new JsonException("Tracked browser protocol object value ended unexpectedly."); + } + + properties[propertyName] = ReadValue(ref reader, options); + } + + return new BrowserLogsProtocolObjectValue(properties); + } + + private static BrowserLogsProtocolValue ReadValue(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var converter = (BrowserLogsProtocolValueJsonConverter)options.GetConverter(typeof(BrowserLogsProtocolValue)); + return converter.Read(ref reader, typeof(BrowserLogsProtocolValue), options); + } +} + +[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(BrowserLogsAttachToTargetResponseEnvelope))] +[JsonSerializable(typeof(BrowserLogsCommandAckResponseEnvelope))] +[JsonSerializable(typeof(BrowserLogsConsoleApiCalledEnvelope))] +[JsonSerializable(typeof(BrowserLogsCreateTargetResponseEnvelope))] +[JsonSerializable(typeof(BrowserLogsExceptionThrownEnvelope))] +[JsonSerializable(typeof(BrowserLogsLoadingFailedEnvelope))] +[JsonSerializable(typeof(BrowserLogsLoadingFinishedEnvelope))] +[JsonSerializable(typeof(BrowserLogsLogEntryAddedEnvelope))] +[JsonSerializable(typeof(BrowserLogsRequestWillBeSentEnvelope))] +[JsonSerializable(typeof(BrowserLogsResponseReceivedEnvelope))] +internal sealed partial class BrowserLogsProtocolJsonContext : JsonSerializerContext; diff --git a/src/Aspire.Hosting/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogsSessionManager.cs index 63a6660da2c..f28102f1826 100644 --- a/src/Aspire.Hosting/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogsSessionManager.cs @@ -5,15 +5,12 @@ using System.Collections.Concurrent; using System.Collections.Immutable; -using System.Diagnostics; using System.Globalization; -using System.Net; -using System.Net.Http.Json; -using System.Net.Sockets; using System.Net.WebSockets; +using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Dcp.Process; using Microsoft.Extensions.Logging; using HealthStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus; @@ -355,172 +352,161 @@ internal sealed class BrowserEventLogger(string sessionId, ILogger resourceLogge private readonly ILogger _resourceLogger = resourceLogger; private readonly Dictionary _networkRequests = new(StringComparer.Ordinal); - public void HandleEvent(string method, JsonElement parameters) + public void HandleEvent(BrowserLogsProtocolEvent protocolEvent) { - switch (method) + switch (protocolEvent) { - case "Runtime.consoleAPICalled": - LogConsoleMessage(parameters); + case BrowserLogsConsoleApiCalledEvent consoleApiCalledEvent: + LogConsoleMessage(consoleApiCalledEvent.Parameters); break; - case "Runtime.exceptionThrown": - LogUnhandledException(parameters); + case BrowserLogsExceptionThrownEvent exceptionThrownEvent: + LogUnhandledException(exceptionThrownEvent.Parameters); break; - case "Log.entryAdded": - LogEntryAdded(parameters); + case BrowserLogsLogEntryAddedEvent logEntryAddedEvent: + LogEntryAdded(logEntryAddedEvent.Parameters); break; - case "Network.requestWillBeSent": - TrackRequestStarted(parameters); + case BrowserLogsRequestWillBeSentEvent requestWillBeSentEvent: + TrackRequestStarted(requestWillBeSentEvent.Parameters); break; - case "Network.responseReceived": - TrackResponseReceived(parameters); + case BrowserLogsResponseReceivedEvent responseReceivedEvent: + TrackResponseReceived(responseReceivedEvent.Parameters); break; - case "Network.loadingFinished": - TrackRequestCompleted(parameters); + case BrowserLogsLoadingFinishedEvent loadingFinishedEvent: + TrackRequestCompleted(loadingFinishedEvent.Parameters); break; - case "Network.loadingFailed": - TrackRequestFailed(parameters); + case BrowserLogsLoadingFailedEvent loadingFailedEvent: + TrackRequestFailed(loadingFailedEvent.Parameters); break; } } - private void LogConsoleMessage(JsonElement parameters) + private void LogConsoleMessage(BrowserLogsRuntimeConsoleApiCalledParameters parameters) { - var level = TryGetString(parameters, "type") ?? "log"; - var message = parameters.TryGetProperty("args", out var argsElement) && argsElement.ValueKind == JsonValueKind.Array - ? string.Join(" ", argsElement.EnumerateArray().Select(FormatRemoteObject).Where(static value => !string.IsNullOrEmpty(value))) + var level = parameters.Type ?? "log"; + var message = parameters.Args is { Length: > 0 } + ? string.Join(" ", parameters.Args.Select(FormatRemoteObject).Where(static value => !string.IsNullOrEmpty(value))) : string.Empty; WriteLog(MapConsoleLevel(level), $"[console.{level}] {message}".TrimEnd()); } - private void LogUnhandledException(JsonElement parameters) + private void LogUnhandledException(BrowserLogsExceptionThrownParameters parameters) { - if (!parameters.TryGetProperty("exceptionDetails", out var exceptionDetails)) + var exceptionDetails = parameters.ExceptionDetails; + if (exceptionDetails is null) { return; } - var message = exceptionDetails.TryGetProperty("exception", out var exception) && exception.TryGetProperty("description", out var description) - ? description.GetString() - : exceptionDetails.TryGetProperty("text", out var text) - ? text.GetString() - : "Unhandled browser exception"; + var message = exceptionDetails.Exception?.Description + ?? exceptionDetails.Text + ?? "Unhandled browser exception"; var location = GetLocationSuffix(exceptionDetails); WriteLog(LogLevel.Error, $"[exception] {message}{location}"); } - private void LogEntryAdded(JsonElement parameters) + private void LogEntryAdded(BrowserLogsLogEntryAddedParameters parameters) { - if (!parameters.TryGetProperty("entry", out var entry)) + var entry = parameters.Entry; + if (entry is null) { return; } - var level = TryGetString(entry, "level") ?? "info"; - var text = TryGetString(entry, "text") ?? string.Empty; + var level = entry.Level ?? "info"; + var text = entry.Text ?? string.Empty; var location = GetLocationSuffix(entry); WriteLog(MapLogEntryLevel(level), $"[log.{level}] {text}{location}".TrimEnd()); } - private void TrackRequestStarted(JsonElement parameters) + private void TrackRequestStarted(BrowserLogsRequestWillBeSentParameters parameters) { - if (TryGetString(parameters, "requestId") is not { Length: > 0 } requestId) + if (parameters.RequestId is not { Length: > 0 } requestId || parameters.Request is not { } request) { return; } - if (!parameters.TryGetProperty("request", out var request)) - { - return; - } - - var url = TryGetString(request, "url"); - var method = TryGetString(request, "method"); + var url = request.Url; + var method = request.Method; if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(method)) { return; } - var startTimestamp = TryGetDouble(parameters, "timestamp"); - - if (parameters.TryGetProperty("redirectResponse", out var redirectResponse) && + if (parameters.RedirectResponse is not null && _networkRequests.Remove(requestId, out var redirectedRequest)) { - UpdateResponse(redirectedRequest, redirectResponse); - LogCompletedRequest(redirectedRequest, startTimestamp, encodedDataLength: null, redirectUrl: url); + UpdateResponse(redirectedRequest, parameters.RedirectResponse); + LogCompletedRequest(redirectedRequest, parameters.Timestamp, encodedDataLength: null, redirectUrl: url); } - var resourceType = NormalizeResourceType(TryGetString(parameters, "type")); _networkRequests[requestId] = new BrowserNetworkRequestState { Method = method, - Url = url, - ResourceType = resourceType, - StartTimestamp = startTimestamp + ResourceType = NormalizeResourceType(parameters.Type), + StartTimestamp = parameters.Timestamp, + Url = url }; } - private void TrackResponseReceived(JsonElement parameters) + private void TrackResponseReceived(BrowserLogsResponseReceivedParameters parameters) { - if (TryGetString(parameters, "requestId") is not { Length: > 0 } requestId || + if (parameters.RequestId is not { Length: > 0 } requestId || !_networkRequests.TryGetValue(requestId, out var request)) { return; } - if (parameters.TryGetProperty("response", out var response)) + if (parameters.Response is not null) { - UpdateResponse(request, response); + UpdateResponse(request, parameters.Response); } - if (TryGetString(parameters, "type") is { Length: > 0 } resourceType) + if (parameters.Type is { Length: > 0 } resourceType) { request.ResourceType = NormalizeResourceType(resourceType); } } - private void TrackRequestCompleted(JsonElement parameters) + private void TrackRequestCompleted(BrowserLogsLoadingFinishedParameters parameters) { - if (TryGetString(parameters, "requestId") is not { Length: > 0 } requestId || + if (parameters.RequestId is not { Length: > 0 } requestId || !_networkRequests.Remove(requestId, out var request)) { return; } - LogCompletedRequest(request, TryGetDouble(parameters, "timestamp"), TryGetDouble(parameters, "encodedDataLength"), redirectUrl: null); + LogCompletedRequest(request, parameters.Timestamp, parameters.EncodedDataLength, redirectUrl: null); } - private void TrackRequestFailed(JsonElement parameters) + private void TrackRequestFailed(BrowserLogsLoadingFailedParameters parameters) { - if (TryGetString(parameters, "requestId") is not { Length: > 0 } requestId || + if (parameters.RequestId is not { Length: > 0 } requestId || !_networkRequests.Remove(requestId, out var request)) { return; } - var errorText = TryGetString(parameters, "errorText") ?? "Request failed"; - var canceled = TryGetBoolean(parameters, "canceled"); - var blockedReason = TryGetString(parameters, "blockedReason"); var details = new List(); - if (FormatDuration(request.StartTimestamp, TryGetDouble(parameters, "timestamp")) is { Length: > 0 } duration) + if (FormatDuration(request.StartTimestamp, parameters.Timestamp) is { Length: > 0 } duration) { details.Add(duration); } - if (canceled == true) + if (parameters.Canceled == true) { details.Add("canceled"); } - if (!string.IsNullOrEmpty(blockedReason)) + if (!string.IsNullOrEmpty(parameters.BlockedReason)) { - details.Add($"blocked={blockedReason}"); + details.Add($"blocked={parameters.BlockedReason}"); } - WriteLog(LogLevel.Warning, $"[network.{request.ResourceType}] {request.Method} {request.Url} failed: {errorText}{FormatDetails(details)}"); + WriteLog(LogLevel.Warning, $"[network.{request.ResourceType}] {request.Method} {request.Url} failed: {parameters.ErrorText ?? "Request failed"}{FormatDetails(details)}"); } private void LogCompletedRequest(BrowserNetworkRequestState request, double? completedTimestamp, double? encodedDataLength, string? redirectUrl) @@ -563,13 +549,13 @@ private void LogCompletedRequest(BrowserNetworkRequestState request, double? com WriteLog(LogLevel.Information, $"[network.{request.ResourceType}] {request.Method} {request.Url}{statusText}{FormatDetails(details)}"); } - private static void UpdateResponse(BrowserNetworkRequestState request, JsonElement response) + private static void UpdateResponse(BrowserNetworkRequestState request, BrowserLogsResponse response) { - request.Url = TryGetString(response, "url") ?? request.Url; - request.StatusCode = TryGetInt32(response, "status"); - request.StatusText = TryGetString(response, "statusText"); - request.FromDiskCache = TryGetBoolean(response, "fromDiskCache"); - request.FromServiceWorker = TryGetBoolean(response, "fromServiceWorker"); + request.Url = response.Url ?? request.Url; + request.StatusCode = response.Status; + request.StatusText = response.StatusText; + request.FromDiskCache = response.FromDiskCache; + request.FromServiceWorker = response.FromServiceWorker; } private void WriteLog(LogLevel logLevel, string message) @@ -600,43 +586,6 @@ private static string NormalizeResourceType(string? resourceType) => ? "request" : resourceType.ToLowerInvariant(); - private static string? TryGetString(JsonElement element, string propertyName) => - element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String - ? property.GetString() - : null; - - private static double? TryGetDouble(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Number) - { - return null; - } - - return property.TryGetDouble(out var value) ? value : null; - } - - private static int? TryGetInt32(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Number) - { - return null; - } - - if (property.TryGetInt32(out var value)) - { - return value; - } - - return property.TryGetDouble(out var doubleValue) - ? (int)Math.Round(doubleValue, MidpointRounding.AwayFromZero) - : null; - } - - private static bool? TryGetBoolean(JsonElement element, string propertyName) => - element.TryGetProperty(propertyName, out var property) && (property.ValueKind == JsonValueKind.True || property.ValueKind == JsonValueKind.False) - ? property.GetBoolean() - : null; - private static string? FormatDuration(double? startTimestamp, double? endTimestamp) { if (startTimestamp is null || endTimestamp is null || endTimestamp < startTimestamp) @@ -669,48 +618,143 @@ private static string FormatDetails(IReadOnlyList details) => _ => LogLevel.Information }; - private static string FormatRemoteObject(JsonElement remoteObject) + private static string FormatRemoteObject(BrowserLogsProtocolRemoteObject remoteObject) { - if (remoteObject.TryGetProperty("value", out var value)) + if (remoteObject.Value is BrowserLogsProtocolValue value) { - return value.ValueKind switch + return value switch { - JsonValueKind.String => value.GetString() ?? string.Empty, - JsonValueKind.Null => "null", - JsonValueKind.True => bool.TrueString, - JsonValueKind.False => bool.FalseString, - _ => value.GetRawText() + BrowserLogsProtocolStringValue stringValue => stringValue.Value, + BrowserLogsProtocolNullValue => "null", + BrowserLogsProtocolBooleanValue booleanValue => booleanValue.Value ? bool.TrueString : bool.FalseString, + BrowserLogsProtocolNumberValue numberValue => numberValue.RawValue, + _ => FormatStructuredValue(value) }; } - if (remoteObject.TryGetProperty("unserializableValue", out var unserializableValue)) + if (!string.IsNullOrEmpty(remoteObject.UnserializableValue)) { - return unserializableValue.GetString() ?? string.Empty; + return remoteObject.UnserializableValue; } - if (remoteObject.TryGetProperty("description", out var description)) + return remoteObject.Description ?? string.Empty; + } + + private static string FormatStructuredValue(BrowserLogsProtocolValue value) + { + var builder = new StringBuilder(); + AppendStructuredValue(builder, value); + return builder.ToString(); + } + + private static void AppendStructuredValue(StringBuilder builder, BrowserLogsProtocolValue value) + { + switch (value) { - return description.GetString() ?? string.Empty; - } + case BrowserLogsProtocolArrayValue arrayValue: + builder.Append('['); + for (var i = 0; i < arrayValue.Items.Count; i++) + { + if (i > 0) + { + builder.Append(','); + } + + AppendStructuredValue(builder, arrayValue.Items[i]); + } + + builder.Append(']'); + break; + case BrowserLogsProtocolBooleanValue booleanValue: + builder.Append(booleanValue.Value ? "true" : "false"); + break; + case BrowserLogsProtocolNullValue: + builder.Append("null"); + break; + case BrowserLogsProtocolNumberValue numberValue: + builder.Append(numberValue.RawValue); + break; + case BrowserLogsProtocolObjectValue objectValue: + builder.Append('{'); + var needsComma = false; + foreach (var (propertyName, propertyValue) in objectValue.Properties) + { + if (needsComma) + { + builder.Append(','); + } + + needsComma = true; + AppendEscapedString(builder, propertyName); + builder.Append(':'); + AppendStructuredValue(builder, propertyValue); + } - return remoteObject.GetRawText(); + builder.Append('}'); + break; + case BrowserLogsProtocolStringValue stringValue: + AppendEscapedString(builder, stringValue.Value); + break; + } } - private static string GetLocationSuffix(JsonElement details) + private static void AppendEscapedString(StringBuilder builder, string value) { - if (!details.TryGetProperty("url", out var urlElement)) + builder.Append('"'); + + foreach (var character in value) { - return string.Empty; + switch (character) + { + case '\\': + builder.Append("\\\\"); + break; + case '"': + builder.Append("\\\""); + break; + case '\b': + builder.Append("\\b"); + break; + case '\f': + builder.Append("\\f"); + break; + case '\n': + builder.Append("\\n"); + break; + case '\r': + builder.Append("\\r"); + break; + case '\t': + builder.Append("\\t"); + break; + default: + if (char.IsControl(character)) + { + builder.Append("\\u"); + builder.Append(((int)character).ToString("x4", CultureInfo.InvariantCulture)); + } + else + { + builder.Append(character); + } + + break; + } } - var url = urlElement.GetString(); + builder.Append('"'); + } + + private static string GetLocationSuffix(BrowserLogsSourceLocation details) + { + var url = details.Url; if (string.IsNullOrEmpty(url)) { return string.Empty; } - var lineNumber = details.TryGetProperty("lineNumber", out var lineElement) ? lineElement.GetInt32() + 1 : 0; - var columnNumber = details.TryGetProperty("columnNumber", out var columnElement) ? columnElement.GetInt32() + 1 : 0; + var lineNumber = details.LineNumber + 1; + var columnNumber = details.ColumnNumber + 1; if (lineNumber > 0 && columnNumber > 0) { @@ -722,9 +766,11 @@ private static string GetLocationSuffix(JsonElement details) private sealed class BrowserNetworkRequestState { - public required string Method { get; set; } + public bool? FromDiskCache { get; set; } - public required string Url { get; set; } + public bool? FromServiceWorker { get; set; } + + public required string Method { get; set; } public required string ResourceType { get; set; } @@ -734,42 +780,83 @@ private sealed class BrowserNetworkRequestState public string? StatusText { get; set; } - public bool? FromDiskCache { get; set; } - - public bool? FromServiceWorker { get; set; } + public required string Url { get; set; } } } - private sealed class RunningSession - : IBrowserLogsRunningSession + internal sealed class BrowserConnectionDiagnosticsLogger(string sessionId, ILogger resourceLogger) { - private static readonly HttpClient s_httpClient = new(new SocketsHttpHandler + private readonly ILogger _resourceLogger = resourceLogger; + private readonly string _sessionId = sessionId; + + public void LogSetupFailure(string stage, Exception exception) { - UseProxy = false - }); - private static readonly JsonSerializerOptions s_jsonOptions = new() + _resourceLogger.LogError("[{SessionId}] {Stage} failed: {Reason}", _sessionId, stage, DescribeConnectionProblem(exception)); + } + + public void LogConnectionLost(Exception exception) { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; + _resourceLogger.LogWarning("[{SessionId}] Tracked browser debug connection lost: {Reason}. Attempting to reconnect.", _sessionId, DescribeConnectionProblem(exception)); + } + + public void LogReconnectAttemptFailed(int attempt, Exception exception) + { + _resourceLogger.LogWarning("[{SessionId}] Reconnect attempt {Attempt} failed: {Reason}", _sessionId, attempt, DescribeConnectionProblem(exception)); + } + + public void LogReconnectFailed(Exception exception) + { + _resourceLogger.LogError("[{SessionId}] Unable to reconnect tracked browser debug connection. Closing the tracked browser session. Last error: {Reason}", _sessionId, DescribeConnectionProblem(exception)); + } + + internal static string DescribeConnectionProblem(Exception exception) + { + var messages = new List(); + + for (var current = exception; current is not null; current = current.InnerException) + { + var message = string.IsNullOrWhiteSpace(current.Message) + ? current.GetType().Name + : $"{current.GetType().Name}: {current.Message}"; + + if (!messages.Contains(message, StringComparer.Ordinal)) + { + messages.Add(message); + } + } + + return string.Join(" --> ", messages); + } + } + + private sealed class RunningSession : IBrowserLogsRunningSession + { + private static readonly TimeSpan s_browserEndpointTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan s_browserShutdownTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan s_connectionRecoveryDelay = TimeSpan.FromMilliseconds(200); + private static readonly TimeSpan s_connectionRecoveryTimeout = TimeSpan.FromSeconds(5); + private readonly BrowserEventLogger _eventLogger; + private readonly BrowserConnectionDiagnosticsLogger _connectionDiagnostics; + private readonly ILogger _logger; private readonly BrowserLogsResource _resource; + private readonly ILogger _resourceLogger; private readonly string _resourceName; private readonly string _sessionId; + private readonly CancellationTokenSource _stopCts = new(); private readonly Uri _url; private readonly TempDirectory _userDataDirectory; - private readonly ILogger _resourceLogger; - private readonly ILogger _logger; - private readonly BrowserEventLogger _eventLogger; - private readonly CancellationTokenSource _stopCts = new(); - private Process? _process; - private Task? _stdoutTask; - private Task? _stderrTask; + private string? _browserExecutable; + private Uri? _browserEndpoint; + private Task? _browserProcessTask; + private IAsyncDisposable? _browserProcessLifetime; private ChromeDevToolsConnection? _connection; - private string? _targetSessionId; private Task? _completion; private int _cleanupState; - private string? _browserExecutable; + private int? _processId; + private string? _targetId; + private string? _targetSessionId; private RunningSession( BrowserLogsResource resource, @@ -780,21 +867,22 @@ private RunningSession( ILogger resourceLogger, ILogger logger) { + _eventLogger = new BrowserEventLogger(sessionId, resourceLogger); + _connectionDiagnostics = new BrowserConnectionDiagnosticsLogger(sessionId, resourceLogger); + _logger = logger; _resource = resource; + _resourceLogger = resourceLogger; _resourceName = resourceName; _sessionId = sessionId; _url = url; _userDataDirectory = userDataDirectory; - _resourceLogger = resourceLogger; - _logger = logger; - _eventLogger = new BrowserEventLogger(sessionId, resourceLogger); } public string SessionId => _sessionId; public string BrowserExecutable => _browserExecutable ?? throw new InvalidOperationException("Browser executable is not available before the session starts."); - public int ProcessId => _process?.Id ?? throw new InvalidOperationException("Browser process has not started."); + public int ProcessId => _processId ?? throw new InvalidOperationException("Browser process has not started."); public DateTime StartedAt { get; private set; } @@ -821,7 +909,7 @@ public static async Task StartAsync( } catch { - await session.CleanupAsync(forceKillProcess: true).ConfigureAwait(false); + await session.CleanupAsync().ConfigureAwait(false); throw; } } @@ -847,29 +935,18 @@ public async Task StopAsync(CancellationToken cancellationToken) } } - if (_process is { HasExited: false }) + if (_browserProcessTask is { IsCompleted: false } browserProcessTask) { using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - waitCts.CancelAfter(TimeSpan.FromSeconds(5)); + waitCts.CancelAfter(s_browserShutdownTimeout); try { - await _process.WaitForExitAsync(waitCts.Token).ConfigureAwait(false); + await browserProcessTask.WaitAsync(waitCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - } - - if (!_process.HasExited) - { - try - { - _process.Kill(entireProcessTree: true); - } - catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) - { - _logger.LogDebug(ex, "Failed to kill tracked browser process for resource '{ResourceName}'.", _resourceName); - } + await DisposeBrowserProcessAsync().ConfigureAwait(false); } } @@ -890,107 +967,272 @@ private async Task InitializeAsync(CancellationToken cancellationToken) throw new InvalidOperationException($"Unable to locate browser '{_resource.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); } - var startInfo = new ProcessStartInfo(_browserExecutable) + var devToolsActivePortFilePath = GetDevToolsActivePortFilePath(); + await StartBrowserProcessAsync(cancellationToken).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Started tracked browser process '{BrowserExecutable}'.", _sessionId, _browserExecutable); + _resourceLogger.LogInformation("[{SessionId}] Waiting for tracked browser debug endpoint metadata in '{DevToolsActivePortFilePath}'.", _sessionId, devToolsActivePortFilePath); + + try + { + _browserEndpoint = await WaitForBrowserEndpointAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _connectionDiagnostics.LogSetupFailure("Discovering the tracked browser debug endpoint", ex); + throw; + } + + _resourceLogger.LogInformation("[{SessionId}] Discovered tracked browser debug endpoint '{Endpoint}'.", _sessionId, _browserEndpoint); + + try + { + await ConnectAsync(createTarget: true, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true + _connectionDiagnostics.LogSetupFailure("Setting up the tracked browser debug connection", ex); + throw; + } + + _resourceLogger.LogInformation("[{SessionId}] Tracking browser console logs for '{Url}'.", _sessionId, _url); + } + + private async Task StartBrowserProcessAsync(CancellationToken cancellationToken) + { + var processStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var browserExecutable = _browserExecutable ?? throw new InvalidOperationException("Browser executable was not resolved."); + var processSpec = new ProcessSpec(browserExecutable) + { + Arguments = BuildBrowserArguments(), + InheritEnv = true, + OnErrorData = error => _logger.LogTrace("[{SessionId}] Tracked browser stderr: {Line}", _sessionId, error), + OnOutputData = output => _logger.LogTrace("[{SessionId}] Tracked browser stdout: {Line}", _sessionId, output), + OnStart = processId => + { + _processId = processId; + processStarted.TrySetResult(processId); + }, + ThrowOnNonZeroReturnCode = false }; - var browserDebuggingPort = AllocateBrowserDebuggingPort(); - startInfo.ArgumentList.Add($"--user-data-dir={_userDataDirectory.Path}"); - startInfo.ArgumentList.Add($"--remote-debugging-port={browserDebuggingPort}"); - startInfo.ArgumentList.Add("--no-first-run"); - startInfo.ArgumentList.Add("--no-default-browser-check"); - startInfo.ArgumentList.Add("--new-window"); - startInfo.ArgumentList.Add("--allow-insecure-localhost"); - startInfo.ArgumentList.Add("--ignore-certificate-errors"); - startInfo.ArgumentList.Add("about:blank"); - - _process = Process.Start(startInfo) ?? throw new InvalidOperationException($"Failed to start tracked browser process '{_browserExecutable}'."); + var (browserProcessTask, browserProcessLifetime) = ProcessUtil.Run(processSpec); + _browserProcessTask = browserProcessTask; + _browserProcessLifetime = browserProcessLifetime; StartedAt = DateTime.UtcNow; - _stdoutTask = DrainStreamAsync(_process.StandardOutput, _stopCts.Token); - _stderrTask = DrainStreamAsync(_process.StandardError, _stopCts.Token); - _resourceLogger.LogInformation("[{SessionId}] Started tracked browser process '{BrowserExecutable}'.", _sessionId, _browserExecutable); - _resourceLogger.LogInformation("[{SessionId}] Waiting for tracked browser debug endpoint on port {Port}.", _sessionId, browserDebuggingPort); - var browserEndpoint = await WaitForBrowserEndpointAsync(browserDebuggingPort, cancellationToken).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Discovered tracked browser debug endpoint '{Endpoint}'.", _sessionId, browserEndpoint); - _connection = await ChromeDevToolsConnection.ConnectAsync(browserEndpoint, HandleEventAsync, cancellationToken).ConfigureAwait(false); + await processStarted.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private string BuildBrowserArguments() + { + return BuildCommandLine( + [ + $"--user-data-dir={_userDataDirectory.Path}", + "--remote-debugging-port=0", + "--no-first-run", + "--no-default-browser-check", + "--new-window", + "--allow-insecure-localhost", + "--ignore-certificate-errors", + "about:blank" + ]); + } + + private async Task ConnectAsync(bool createTarget, CancellationToken cancellationToken) + { + var browserEndpoint = _browserEndpoint ?? throw new InvalidOperationException("Browser debugging endpoint is not available."); + + await DisposeConnectionAsync().ConfigureAwait(false); + + _connection = await ExecuteConnectionStageAsync( + "Connecting to the tracked browser debug endpoint", + () => ChromeDevToolsConnection.ConnectAsync(browserEndpoint, HandleEventAsync, _logger, cancellationToken)).ConfigureAwait(false); _resourceLogger.LogInformation("[{SessionId}] Connected to the tracked browser debug endpoint.", _sessionId); - var targetId = await _connection.CreateTargetAsync(cancellationToken).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Created tracked browser target '{TargetId}'.", _sessionId, targetId); - _targetSessionId = await _connection.AttachToTargetAsync(targetId, cancellationToken).ConfigureAwait(false); + if (createTarget) + { + var createTargetResult = await ExecuteConnectionStageAsync( + "Creating the tracked browser target", + () => _connection.CreateTargetAsync(cancellationToken)).ConfigureAwait(false); + _targetId = createTargetResult.TargetId + ?? throw new InvalidOperationException("Browser target creation did not return a target id."); + _resourceLogger.LogInformation("[{SessionId}] Created tracked browser target '{TargetId}'.", _sessionId, _targetId); + } + + if (_targetId is null) + { + throw new InvalidOperationException("Tracked browser target id is not available."); + } + + var attachToTargetResult = await ExecuteConnectionStageAsync( + "Attaching to the tracked browser target", + () => _connection.AttachToTargetAsync(_targetId, cancellationToken)).ConfigureAwait(false); + _targetSessionId = attachToTargetResult.SessionId + ?? throw new InvalidOperationException("Browser target attachment did not return a session id."); _resourceLogger.LogInformation("[{SessionId}] Attached to the tracked browser target.", _sessionId); - await _connection.EnablePageInstrumentationAsync(_targetSessionId, cancellationToken).ConfigureAwait(false); + + await ExecuteConnectionStageAsync( + "Enabling tracked browser instrumentation", + () => _connection.EnablePageInstrumentationAsync(_targetSessionId, cancellationToken)).ConfigureAwait(false); _resourceLogger.LogInformation("[{SessionId}] Enabled tracked browser logging.", _sessionId); - await _connection.NavigateAsync(_targetSessionId, _url, cancellationToken).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Navigated tracked browser to '{Url}'.", _sessionId, _url); - _resourceLogger.LogInformation("[{SessionId}] Tracking browser console logs for '{Url}'.", _sessionId, _url); + if (createTarget) + { + await ExecuteConnectionStageAsync( + "Navigating the tracked browser target", + () => _connection.NavigateAsync(_targetSessionId, _url, cancellationToken)).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Navigated tracked browser to '{Url}'.", _sessionId, _url); + } } - private async Task MonitorAsync() + private static async Task ExecuteConnectionStageAsync(string stage, Func> action) + { + try + { + return await action().ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new InvalidOperationException($"{stage} failed.", ex); + } + } + + private static async Task ExecuteConnectionStageAsync(string stage, Func action) { try { - Debug.Assert(_process is not null); - Debug.Assert(_connection is not null); + await action().ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new InvalidOperationException($"{stage} failed.", ex); + } + } - var processExitTask = _process.WaitForExitAsync(CancellationToken.None); - var completedTask = await Task.WhenAny(processExitTask, _connection.Completion).ConfigureAwait(false); + private async Task MonitorAsync() + { + try + { + var browserProcessTask = _browserProcessTask ?? throw new InvalidOperationException("Browser process task is not available."); - Exception? error = null; - if (completedTask == _connection.Completion) + while (true) { + var connection = _connection ?? throw new InvalidOperationException("Tracked browser debug connection is not available."); + var completedTask = await Task.WhenAny(browserProcessTask, connection.Completion).ConfigureAwait(false); + + if (completedTask == browserProcessTask) + { + var processResult = await browserProcessTask.ConfigureAwait(false); + if (!_stopCts.IsCancellationRequested) + { + _resourceLogger.LogInformation("[{SessionId}] Tracked browser exited with code {ExitCode}.", _sessionId, processResult.ExitCode); + } + + return new BrowserSessionResult(processResult.ExitCode, Error: null); + } + + Exception? connectionError = null; try { - await _connection.Completion.ConfigureAwait(false); + await connection.Completion.ConfigureAwait(false); } catch (Exception ex) { - error = ex; + connectionError = ex; } - if (!_stopCts.IsCancellationRequested && !_process.HasExited) + if (_stopCts.IsCancellationRequested) { - _resourceLogger.LogWarning("[{SessionId}] Tracked browser debug connection closed before the browser process exited.", _sessionId); + var processResult = await browserProcessTask.ConfigureAwait(false); + return new BrowserSessionResult(processResult.ExitCode, Error: null); + } - try - { - _process.Kill(entireProcessTree: true); - } - catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) - { - _logger.LogDebug(ex, "Failed to kill tracked browser process after the debug connection closed for resource '{ResourceName}'.", _resourceName); - } + connectionError ??= new InvalidOperationException("The tracked browser debug connection closed without reporting a reason."); + + if (await TryReconnectAsync(connectionError).ConfigureAwait(false)) + { + continue; } + + await DisposeBrowserProcessAsync().ConfigureAwait(false); + + var exitResult = await browserProcessTask.ConfigureAwait(false); + return new BrowserSessionResult(exitResult.ExitCode, connectionError); } + } + finally + { + await CleanupAsync().ConfigureAwait(false); + } + } - await processExitTask.ConfigureAwait(false); + private async Task TryReconnectAsync(Exception? connectionError) + { + if (_browserEndpoint is null || _targetId is null) + { + return false; + } + + connectionError ??= new InvalidOperationException("The tracked browser debug connection closed without reporting a reason."); + _connectionDiagnostics.LogConnectionLost(connectionError); + await DisposeConnectionAsync().ConfigureAwait(false); - if (!_stopCts.IsCancellationRequested) + var reconnectDeadline = TimeProvider.System.GetUtcNow() + s_connectionRecoveryTimeout; + Exception? lastError = connectionError; + var attempt = 0; + + while (!_stopCts.IsCancellationRequested && TimeProvider.System.GetUtcNow() < reconnectDeadline) + { + if (_browserProcessTask?.IsCompleted == true) { - _resourceLogger.LogInformation("[{SessionId}] Tracked browser exited with code {ExitCode}.", _sessionId, _process.ExitCode); + return false; } - return new BrowserSessionResult(_process.ExitCode, error); + try + { + attempt++; + await ConnectAsync(createTarget: false, _stopCts.Token).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Reconnected tracked browser debug connection.", _sessionId); + return true; + } + catch (OperationCanceledException) when (_stopCts.IsCancellationRequested) + { + return false; + } + catch (Exception ex) + { + lastError = ex; + _connectionDiagnostics.LogReconnectAttemptFailed(attempt, ex); + await DisposeConnectionAsync().ConfigureAwait(false); + } + + try + { + await Task.Delay(s_connectionRecoveryDelay, _stopCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_stopCts.IsCancellationRequested) + { + return false; + } } - finally + + if (lastError is not null) { - await CleanupAsync(forceKillProcess: false).ConfigureAwait(false); + _connectionDiagnostics.LogReconnectFailed(lastError); + _logger.LogDebug(lastError, "Timed out reconnecting tracked browser debug session for resource '{ResourceName}' and session '{SessionId}'.", _resourceName, _sessionId); } + + return false; } - private ValueTask HandleEventAsync(CdpEvent cdpEvent) + private ValueTask HandleEventAsync(BrowserLogsProtocolEvent protocolEvent) { - if (!string.Equals(cdpEvent.SessionId, _targetSessionId, StringComparison.Ordinal)) + if (!string.Equals(protocolEvent.SessionId, _targetSessionId, StringComparison.Ordinal)) { return ValueTask.CompletedTask; } - _eventLogger.HandleEvent(cdpEvent.Method, cdpEvent.Params); + _eventLogger.HandleEvent(protocolEvent); return ValueTask.CompletedTask; } @@ -1007,72 +1249,45 @@ private async Task ObserveCompletionAsync(Func onComplete } } - private async Task CleanupAsync(bool forceKillProcess) + private async Task CleanupAsync() { if (Interlocked.Exchange(ref _cleanupState, 1) != 0) { return; } - if (forceKillProcess && _process is { HasExited: false }) - { - try - { - _process.Kill(entireProcessTree: true); - } - catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) - { - _logger.LogDebug(ex, "Failed to kill tracked browser process during cleanup for resource '{ResourceName}'.", _resourceName); - } - } - - if (_connection is not null) - { - await _connection.DisposeAsync().ConfigureAwait(false); - } - - if (_stdoutTask is not null) - { - await AwaitQuietlyAsync(_stdoutTask).ConfigureAwait(false); - } - - if (_stderrTask is not null) - { - await AwaitQuietlyAsync(_stderrTask).ConfigureAwait(false); - } - - _process?.Dispose(); + await DisposeConnectionAsync().ConfigureAwait(false); + await DisposeBrowserProcessAsync().ConfigureAwait(false); _stopCts.Dispose(); _userDataDirectory.Dispose(); } - private static async Task AwaitQuietlyAsync(Task task) + private async Task DisposeBrowserProcessAsync() { - try - { - await task.ConfigureAwait(false); - } - catch + var browserProcessLifetime = _browserProcessLifetime; + _browserProcessLifetime = null; + + if (browserProcessLifetime is not null) { + await browserProcessLifetime.DisposeAsync().ConfigureAwait(false); } } - private static async Task DrainStreamAsync(StreamReader reader, CancellationToken cancellationToken) + private async Task DisposeConnectionAsync() { - while (!cancellationToken.IsCancellationRequested) + var connection = _connection; + _connection = null; + + if (connection is not null) { - var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); - if (line is null) - { - break; - } + await connection.DisposeAsync().ConfigureAwait(false); } } - private static async Task WaitForBrowserEndpointAsync(int browserDebuggingPort, CancellationToken cancellationToken) + private async Task WaitForBrowserEndpointAsync(CancellationToken cancellationToken) { - var browserVersionUri = new Uri($"http://127.0.0.1:{browserDebuggingPort}/json/version", UriKind.Absolute); - var timeoutAt = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(30); + var devToolsActivePortFilePath = GetDevToolsActivePortFilePath(); + var timeoutAt = TimeProvider.System.GetUtcNow() + s_browserEndpointTimeout; while (TimeProvider.System.GetUtcNow() < timeoutAt) { @@ -1080,39 +1295,31 @@ private static async Task WaitForBrowserEndpointAsync(int browserDebuggingP try { - using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - probeCts.CancelAfter(TimeSpan.FromSeconds(1)); - - using var response = await s_httpClient.GetAsync(browserVersionUri, probeCts.Token).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var version = await response.Content.ReadFromJsonAsync(probeCts.Token).ConfigureAwait(false); - if (version?.WebSocketDebuggerUrl is { } browserEndpoint) + if (File.Exists(devToolsActivePortFilePath)) { - return browserEndpoint; + var contents = await File.ReadAllTextAsync(devToolsActivePortFilePath, cancellationToken).ConfigureAwait(false); + if (TryParseBrowserDebugEndpoint(contents) is { } browserEndpoint) + { + return browserEndpoint; + } } } - catch (HttpRequestException) - { - } - catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + catch (IOException) { } - catch (JsonException) + catch (UnauthorizedAccessException) { } await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false); } - throw new TimeoutException("Timed out waiting for the tracked browser to expose its debugging endpoint."); + throw new TimeoutException($"Timed out waiting for the tracked browser to write '{devToolsActivePortFilePath}'."); } - private static int AllocateBrowserDebuggingPort() + private string GetDevToolsActivePortFilePath() { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; + return Path.Combine(_userDataDirectory.Path, "DevToolsActivePort"); } private static string? ResolveBrowserExecutable(string browser) @@ -1192,71 +1399,171 @@ private static IEnumerable GetBrowserCandidates(string browser) }; } - private sealed record BrowserSessionResult(int ExitCode, Exception? Error); + private static string BuildCommandLine(IReadOnlyList arguments) + { + var builder = new StringBuilder(); + + for (var i = 0; i < arguments.Count; i++) + { + if (i > 0) + { + builder.Append(' '); + } + + AppendCommandLineArgument(builder, arguments[i]); + } - private sealed record CdpEvent(string Method, string? SessionId, JsonElement Params); + return builder.ToString(); + } - private sealed class BrowserVersionResponse + // Adapted from dotnet/runtime PasteArguments.AppendArgument so ProcessSpec can safely represent Chromium flags. + private static void AppendCommandLineArgument(StringBuilder builder, string argument) { - [JsonPropertyName("webSocketDebuggerUrl")] - public required Uri WebSocketDebuggerUrl { get; init; } + if (argument.Length != 0 && !argument.AsSpan().ContainsAny(' ', '\t', '"')) + { + builder.Append(argument); + return; + } + + builder.Append('"'); + + var index = 0; + while (index < argument.Length) + { + var character = argument[index++]; + if (character == '\\') + { + var backslashCount = 1; + while (index < argument.Length && argument[index] == '\\') + { + index++; + backslashCount++; + } + + if (index == argument.Length) + { + builder.Append('\\', backslashCount * 2); + } + else if (argument[index] == '"') + { + builder.Append('\\', backslashCount * 2 + 1); + builder.Append('"'); + index++; + } + else + { + builder.Append('\\', backslashCount); + } + + continue; + } + + if (character == '"') + { + builder.Append('\\'); + builder.Append('"'); + continue; + } + + builder.Append(character); + } + + builder.Append('"'); } + private sealed record BrowserSessionResult(int ExitCode, Exception? Error); + private sealed class ChromeDevToolsConnection : IAsyncDisposable { - private readonly ClientWebSocket _webSocket; - private readonly Func _eventHandler; - private readonly ConcurrentDictionary> _pendingCommands = new(); + private static readonly TimeSpan s_commandTimeout = TimeSpan.FromSeconds(10); + + private readonly CancellationTokenSource _disposeCts = new(); + private readonly Func _eventHandler; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _pendingCommands = new(); private readonly Task _receiveLoop; + private readonly SemaphoreSlim _sendLock = new(1, 1); + private readonly ClientWebSocket _webSocket; private long _nextCommandId; - public ChromeDevToolsConnection(ClientWebSocket webSocket, Func eventHandler) + private ChromeDevToolsConnection(ClientWebSocket webSocket, Func eventHandler, ILogger logger) { - _webSocket = webSocket; _eventHandler = eventHandler; + _logger = logger; + _webSocket = webSocket; _receiveLoop = Task.Run(ReceiveLoopAsync); } public Task Completion => _receiveLoop; - public static async Task ConnectAsync(Uri webSocketUri, Func eventHandler, CancellationToken cancellationToken) + public static async Task ConnectAsync( + Uri webSocketUri, + Func eventHandler, + ILogger logger, + CancellationToken cancellationToken) { var webSocket = new ClientWebSocket(); - webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30); + webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(15); await webSocket.ConnectAsync(webSocketUri, cancellationToken).ConfigureAwait(false); - return new ChromeDevToolsConnection(webSocket, eventHandler); + return new ChromeDevToolsConnection(webSocket, eventHandler, logger); } - public async Task CreateTargetAsync(CancellationToken cancellationToken) + public Task CreateTargetAsync(CancellationToken cancellationToken) { - var result = await SendCommandAsync("Target.createTarget", new { url = "about:blank" }, sessionId: null, cancellationToken).ConfigureAwait(false); - return result.GetProperty("targetId").GetString() - ?? throw new InvalidOperationException("Browser target creation did not return a target id."); + return SendCommandAsync( + BrowserLogsProtocol.TargetCreateTargetMethod, + sessionId: null, + static writer => writer.WriteString("url", "about:blank"), + BrowserLogsProtocol.ParseCreateTargetResponse, + cancellationToken); } - public async Task AttachToTargetAsync(string targetId, CancellationToken cancellationToken) + public Task AttachToTargetAsync(string targetId, CancellationToken cancellationToken) { - var result = await SendCommandAsync("Target.attachToTarget", new { targetId, flatten = true }, sessionId: null, cancellationToken).ConfigureAwait(false); - return result.GetProperty("sessionId").GetString() - ?? throw new InvalidOperationException("Browser target attachment did not return a session id."); + return SendCommandAsync( + BrowserLogsProtocol.TargetAttachToTargetMethod, + sessionId: null, + writer => + { + writer.WriteString("targetId", targetId); + writer.WriteBoolean("flatten", true); + }, + BrowserLogsProtocol.ParseAttachToTargetResponse, + cancellationToken); } public async Task EnablePageInstrumentationAsync(string sessionId, CancellationToken cancellationToken) { - await SendCommandAsync("Runtime.enable", parameters: null, sessionId, cancellationToken).ConfigureAwait(false); - await SendCommandAsync("Log.enable", parameters: null, sessionId, cancellationToken).ConfigureAwait(false); - await SendCommandAsync("Page.enable", parameters: null, sessionId, cancellationToken).ConfigureAwait(false); - await SendCommandAsync("Network.enable", parameters: null, sessionId, cancellationToken).ConfigureAwait(false); + await SendCommandAsync(BrowserLogsProtocol.RuntimeEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); + await SendCommandAsync(BrowserLogsProtocol.LogEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); + await SendCommandAsync(BrowserLogsProtocol.PageEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); + await SendCommandAsync(BrowserLogsProtocol.NetworkEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); } - public Task NavigateAsync(string sessionId, Uri url, CancellationToken cancellationToken) => - SendCommandAsync("Page.navigate", new { url = url.ToString() }, sessionId, cancellationToken); + public Task NavigateAsync(string sessionId, Uri url, CancellationToken cancellationToken) + { + return SendCommandAsync( + BrowserLogsProtocol.PageNavigateMethod, + sessionId, + writer => writer.WriteString("url", url.ToString()), + BrowserLogsProtocol.ParseCommandAckResponse, + cancellationToken); + } - public Task CloseBrowserAsync(CancellationToken cancellationToken) => - SendCommandAsync("Browser.close", parameters: null, sessionId: null, cancellationToken); + public Task CloseBrowserAsync(CancellationToken cancellationToken) + { + return SendCommandAsync( + BrowserLogsProtocol.BrowserCloseMethod, + sessionId: null, + writeParameters: null, + BrowserLogsProtocol.ParseCommandAckResponse, + cancellationToken); + } public async ValueTask DisposeAsync() { + _disposeCts.Cancel(); + try { if (_webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) @@ -1280,33 +1587,55 @@ public async ValueTask DisposeAsync() catch { } + + _disposeCts.Dispose(); + _sendLock.Dispose(); } - private async Task SendCommandAsync(string method, object? parameters, string? sessionId, CancellationToken cancellationToken) + private async Task SendCommandAsync( + string method, + string? sessionId, + Action? writeParameters, + ResponseParser parseResponse, + CancellationToken cancellationToken) { var commandId = Interlocked.Increment(ref _nextCommandId); - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _pendingCommands[commandId] = tcs; + var pendingCommand = new PendingCommand(parseResponse); + _pendingCommands[commandId] = pendingCommand; - using var registration = cancellationToken.Register(static state => + try { - var source = (TaskCompletionSource)state!; - source.TrySetCanceled(); - }, tcs); + using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token); + sendCts.CancelAfter(s_commandTimeout); - var payload = JsonSerializer.SerializeToUtf8Bytes(new CdpCommand - { - Id = commandId, - Method = method, - Params = parameters, - SessionId = sessionId - }, s_jsonOptions); + using var registration = sendCts.Token.Register(static state => + { + ((IPendingCommand)state!).SetCanceled(); + }, pendingCommand); - await _webSocket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, cancellationToken).ConfigureAwait(false); + var payload = BrowserLogsProtocol.CreateCommandFrame(commandId, method, sessionId, writeParameters); + _logger.LogTrace("Tracked browser protocol -> {Frame}", BrowserLogsProtocol.DescribeFrame(payload)); - try + var lockHeld = false; + try + { + await _sendLock.WaitAsync(sendCts.Token).ConfigureAwait(false); + lockHeld = true; + await _webSocket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, sendCts.Token).ConfigureAwait(false); + } + finally + { + if (lockHeld) + { + _sendLock.Release(); + } + } + + return await pendingCommand.Task.ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && !_disposeCts.IsCancellationRequested) { - return await tcs.Task.ConfigureAwait(false); + throw new TimeoutException($"Timed out waiting for a tracked browser protocol response to '{method}'."); } finally { @@ -1318,14 +1647,16 @@ private async Task ReceiveLoopAsync() { var buffer = new byte[16 * 1024]; using var messageBuffer = new MemoryStream(); + Exception? terminalException = null; try { - while (_webSocket.State is WebSocketState.Open or WebSocketState.CloseSent) + while (!_disposeCts.IsCancellationRequested && _webSocket.State is WebSocketState.Open or WebSocketState.CloseSent) { - var result = await _webSocket.ReceiveAsync(buffer, CancellationToken.None).ConfigureAwait(false); + var result = await _webSocket.ReceiveAsync(buffer, _disposeCts.Token).ConfigureAwait(false); if (result.MessageType == WebSocketMessageType.Close) { + terminalException = CreateUnexpectedConnectionClosureException(result); break; } @@ -1335,73 +1666,150 @@ private async Task ReceiveLoopAsync() continue; } - var message = messageBuffer.ToArray(); + var frame = messageBuffer.ToArray(); messageBuffer.SetLength(0); - using var document = JsonDocument.Parse(message); - var root = document.RootElement; + _logger.LogTrace("Tracked browser protocol <- {Frame}", BrowserLogsProtocol.DescribeFrame(frame)); - if (root.TryGetProperty("id", out var idElement)) + try { - var commandId = idElement.GetInt64(); - if (_pendingCommands.TryGetValue(commandId, out var pendingCommand)) + var header = BrowserLogsProtocol.ParseMessageHeader(frame); + if (header.Id is long commandId) { - if (root.TryGetProperty("error", out var error)) - { - var errorMessage = error.TryGetProperty("message", out var errorMessageElement) - ? errorMessageElement.GetString() - : "Unknown browser protocol error."; - pendingCommand.TrySetException(new InvalidOperationException(errorMessage)); - } - else if (root.TryGetProperty("result", out var responseResult)) + if (_pendingCommands.TryGetValue(commandId, out var pendingCommand)) { - pendingCommand.TrySetResult(responseResult.Clone()); - } - else - { - pendingCommand.TrySetResult(default); + pendingCommand.SetResult(frame); } + + continue; + } + + if (header.Method is not null && BrowserLogsProtocol.ParseEvent(header, frame) is { } protocolEvent) + { + await _eventHandler(protocolEvent).ConfigureAwait(false); } } - else if (root.TryGetProperty("method", out var methodElement)) + catch (Exception ex) { - var sessionId = root.TryGetProperty("sessionId", out var sessionIdElement) - ? sessionIdElement.GetString() - : null; - var parameters = root.TryGetProperty("params", out var paramsElement) - ? paramsElement.Clone() - : default; - - await _eventHandler(new CdpEvent(methodElement.GetString() ?? string.Empty, sessionId, parameters)).ConfigureAwait(false); + terminalException = new InvalidOperationException( + $"Tracked browser protocol receive loop failed while processing frame {BrowserLogsProtocol.DescribeFrame(frame)}.", + ex); + break; } } } + catch (OperationCanceledException) when (_disposeCts.IsCancellationRequested) + { + } + catch (Exception ex) + { + terminalException = ex; + } finally { - foreach (var (_, pendingCommand) in _pendingCommands) + terminalException ??= new InvalidOperationException("Browser debug connection closed."); + + foreach (var pendingCommand in _pendingCommands.Values) + { + pendingCommand.SetException(terminalException); + } + } + + if (!_disposeCts.IsCancellationRequested) + { + throw terminalException ?? new InvalidOperationException("Browser debug connection closed."); + } + } + + private static InvalidOperationException CreateUnexpectedConnectionClosureException(WebSocketReceiveResult result) + { + if (result.CloseStatus is { } closeStatus) + { + if (!string.IsNullOrWhiteSpace(result.CloseStatusDescription)) { - pendingCommand.TrySetException(new InvalidOperationException("Browser debug connection closed.")); + return new InvalidOperationException($"Browser debug connection closed by the remote endpoint with status '{closeStatus}' ({(int)closeStatus}): {result.CloseStatusDescription}"); } + + return new InvalidOperationException($"Browser debug connection closed by the remote endpoint with status '{closeStatus}' ({(int)closeStatus})."); } + + return new InvalidOperationException("Browser debug connection closed by the remote endpoint without a close status."); + } + + private interface IPendingCommand + { + void SetCanceled(); + + void SetException(Exception exception); + + void SetResult(ReadOnlyMemory framePayload); } - private sealed class CdpCommand + private delegate TResult ResponseParser(ReadOnlySpan framePayload); + + private sealed class PendingCommand(ResponseParser parseResponse) : IPendingCommand { - [JsonPropertyName("id")] - public required long Id { get; init; } + private readonly ResponseParser _parseResponse = parseResponse; + private readonly TaskCompletionSource _taskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Task => _taskCompletionSource.Task; - [JsonPropertyName("method")] - public required string Method { get; init; } + public void SetCanceled() + { + _taskCompletionSource.TrySetCanceled(); + } - [JsonPropertyName("params")] - public object? Params { get; init; } + public void SetException(Exception exception) + { + _taskCompletionSource.TrySetException(exception); + } - [JsonPropertyName("sessionId")] - public string? SessionId { get; init; } + public void SetResult(ReadOnlyMemory framePayload) + { + try + { + _taskCompletionSource.TrySetResult(_parseResponse(framePayload.Span)); + } + catch (Exception ex) + { + _taskCompletionSource.TrySetException(ex); + } + } } } } + internal static Uri? TryParseBrowserDebugEndpoint(string activePortFileContents) + { + if (string.IsNullOrWhiteSpace(activePortFileContents)) + { + return null; + } + + using var reader = new StringReader(activePortFileContents); + var portLine = reader.ReadLine(); + var browserPathLine = reader.ReadLine(); + + if (!int.TryParse(portLine, NumberStyles.None, CultureInfo.InvariantCulture, out var port) || port <= 0) + { + return null; + } + + if (string.IsNullOrWhiteSpace(browserPathLine)) + { + return null; + } + + if (!browserPathLine.StartsWith("/", StringComparison.Ordinal)) + { + browserPathLine = $"/{browserPathLine}"; + } + + return Uri.TryCreate($"ws://127.0.0.1:{port}{browserPathLine}", UriKind.Absolute, out var browserEndpoint) + ? browserEndpoint + : null; + } + private sealed class BrowserLogsRunningSessionFactory : IBrowserLogsRunningSessionFactory { private readonly IFileSystemService _fileSystemService; diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index 1302268667c..040f2493d81 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Text.Json; +using System.Text; using Aspire.Hosting.Resources; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; @@ -275,36 +275,48 @@ public async Task BrowserEventLogger_LogsSuccessfulNetworkRequests() var eventLogger = new BrowserLogsSessionManager.BrowserEventLogger("session-0001", resourceLogger); var logs = await CaptureLogsAsync(resourceLoggerService, "web-browser-logs", () => { - eventLogger.HandleEvent("Network.requestWillBeSent", ParseJsonElement(""" + eventLogger.HandleEvent(ParseProtocolEvent(""" { - "requestId": "request-1", - "timestamp": 1.5, - "type": "Fetch", - "request": { - "url": "https://example.test/api/todos", - "method": "GET" + "method": "Network.requestWillBeSent", + "sessionId": "target-session-1", + "params": { + "requestId": "request-1", + "timestamp": 1.5, + "type": "Fetch", + "request": { + "url": "https://example.test/api/todos", + "method": "GET" + } } } """)); - eventLogger.HandleEvent("Network.responseReceived", ParseJsonElement(""" + eventLogger.HandleEvent(ParseProtocolEvent(""" { - "requestId": "request-1", - "timestamp": 1.6, - "type": "Fetch", - "response": { - "url": "https://example.test/api/todos", - "status": 200, - "statusText": "OK", - "fromDiskCache": false, - "fromServiceWorker": false + "method": "Network.responseReceived", + "sessionId": "target-session-1", + "params": { + "requestId": "request-1", + "timestamp": 1.6, + "type": "Fetch", + "response": { + "url": "https://example.test/api/todos", + "status": 200, + "statusText": "OK", + "fromDiskCache": false, + "fromServiceWorker": false + } } } """)); - eventLogger.HandleEvent("Network.loadingFinished", ParseJsonElement(""" + eventLogger.HandleEvent(ParseProtocolEvent(""" { - "requestId": "request-1", - "timestamp": 1.75, - "encodedDataLength": 1024 + "method": "Network.loadingFinished", + "sessionId": "target-session-1", + "params": { + "requestId": "request-1", + "timestamp": 1.75, + "encodedDataLength": 1024 + } } """)); }); @@ -321,23 +333,31 @@ public async Task BrowserEventLogger_LogsFailedNetworkRequests() var eventLogger = new BrowserLogsSessionManager.BrowserEventLogger("session-0002", resourceLogger); var logs = await CaptureLogsAsync(resourceLoggerService, "web-browser-logs", () => { - eventLogger.HandleEvent("Network.requestWillBeSent", ParseJsonElement(""" + eventLogger.HandleEvent(ParseProtocolEvent(""" { - "requestId": "request-2", - "timestamp": 5.0, - "type": "Document", - "request": { - "url": "https://127.0.0.1:1/browser-network-failure", - "method": "GET" + "method": "Network.requestWillBeSent", + "sessionId": "target-session-2", + "params": { + "requestId": "request-2", + "timestamp": 5.0, + "type": "Document", + "request": { + "url": "https://127.0.0.1:1/browser-network-failure", + "method": "GET" + } } } """)); - eventLogger.HandleEvent("Network.loadingFailed", ParseJsonElement(""" + eventLogger.HandleEvent(ParseProtocolEvent(""" { - "requestId": "request-2", - "timestamp": 5.15, - "errorText": "net::ERR_CONNECTION_REFUSED", - "canceled": false + "method": "Network.loadingFailed", + "sessionId": "target-session-2", + "params": { + "requestId": "request-2", + "timestamp": 5.15, + "errorText": "net::ERR_CONNECTION_REFUSED", + "canceled": false + } } """)); }); @@ -351,10 +371,11 @@ private sealed class TestHttpResource(string name) : Resource(name), IResourceWi private static bool HasProperty(CustomResourceSnapshot snapshot, string name, object expectedValue) => snapshot.Properties.Any(property => property.Name == name && Equals(property.Value, expectedValue)); - private static JsonElement ParseJsonElement(string json) + private static BrowserLogsProtocolEvent ParseProtocolEvent(string json) { - using var document = JsonDocument.Parse(json); - return document.RootElement.Clone(); + var payload = Encoding.UTF8.GetBytes(json); + return BrowserLogsProtocol.ParseEvent(BrowserLogsProtocol.ParseMessageHeader(payload), payload) + ?? throw new InvalidOperationException("Expected a browser protocol event frame."); } private static async Task> CaptureLogsAsync(ResourceLoggerService resourceLoggerService, string resourceName, Action writeLogs) diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsProtocolTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsProtocolTests.cs new file mode 100644 index 00000000000..8d6cc0d9b48 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/BrowserLogsProtocolTests.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; + +namespace Aspire.Hosting.Tests; + +[Trait("Partition", "2")] +public class BrowserLogsProtocolTests +{ + [Fact] + public void ParseEvent_ConsoleApiCalled_ReturnsStronglyTypedParameters() + { + var payload = Encoding.UTF8.GetBytes(""" + { + "method": "Runtime.consoleAPICalled", + "sessionId": "target-session-1", + "params": { + "type": "error", + "args": [ + { "value": "boom" }, + { "value": true }, + { "value": 42 }, + { "value": { "nested": "value" } }, + { "unserializableValue": "Infinity" } + ] + } + } + """); + + var header = BrowserLogsProtocol.ParseMessageHeader(payload); + var @event = Assert.IsType(BrowserLogsProtocol.ParseEvent(header, payload)); + + Assert.Equal("target-session-1", @event.SessionId); + Assert.Equal("error", @event.Parameters.Type); + + var args = Assert.IsType(@event.Parameters.Args); + Assert.IsType(args[0].Value); + Assert.IsType(args[1].Value); + Assert.IsType(args[2].Value); + Assert.IsType(args[3].Value); + Assert.Equal("Infinity", args[4].UnserializableValue); + } + + [Fact] + public void ParseCreateTargetResponse_ReturnsTypedResult() + { + var payload = Encoding.UTF8.GetBytes(""" + { + "id": 7, + "result": { + "targetId": "target-123" + } + } + """); + + var result = BrowserLogsProtocol.ParseCreateTargetResponse(payload); + + Assert.Equal("target-123", result.TargetId); + } + + [Fact] + public void ParseCommandAckResponse_IncludesProtocolErrorDetails() + { + var payload = Encoding.UTF8.GetBytes(""" + { + "id": 3, + "error": { + "code": -32601, + "message": "Method not found" + } + } + """); + + var exception = Assert.Throws(() => BrowserLogsProtocol.ParseCommandAckResponse(payload)); + + Assert.Contains("Method not found", exception.Message); + Assert.Contains("-32601", exception.Message); + } +} diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs new file mode 100644 index 00000000000..c895c1931d4 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.WebSockets; +using Aspire.Hosting.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Hosting.Tests; + +[Trait("Partition", "2")] +public class BrowserLogsSessionManagerTests +{ + [Fact] + public void TryParseBrowserDebugEndpoint_ReturnsBrowserWebSocketUri() + { + var endpoint = BrowserLogsSessionManager.TryParseBrowserDebugEndpoint(""" + 51943 + /devtools/browser/4c8404fb-06f8-45f0-9d89-112233445566 + """); + + Assert.NotNull(endpoint); + Assert.Equal("ws://127.0.0.1:51943/devtools/browser/4c8404fb-06f8-45f0-9d89-112233445566", endpoint.AbsoluteUri); + } + + [Theory] + [InlineData("")] + [InlineData("not-a-port")] + [InlineData("51943")] + public void TryParseBrowserDebugEndpoint_ReturnsNullForInvalidMetadata(string metadata) + { + var endpoint = BrowserLogsSessionManager.TryParseBrowserDebugEndpoint(metadata); + + Assert.Null(endpoint); + } + + [Fact] + public async Task BrowserConnectionDiagnosticsLogger_LogsConnectionProblems() + { + var resourceLoggerService = ConsoleLoggingTestHelpers.GetResourceLoggerService(); + var resourceName = "web-browser-logs"; + var diagnostics = new BrowserLogsSessionManager.BrowserConnectionDiagnosticsLogger("session-0001", resourceLoggerService.GetLogger(resourceName)); + + var logs = await CaptureLogsAsync(resourceLoggerService, resourceName, targetLogCount: 4, () => + { + diagnostics.LogSetupFailure( + "Setting up the tracked browser debug connection", + new InvalidOperationException("Connecting to the tracked browser debug endpoint failed.", new TimeoutException("Timed out waiting for a tracked browser protocol response to 'Target.attachToTarget'."))); + diagnostics.LogConnectionLost( + new InvalidOperationException("Browser debug connection closed by the remote endpoint with status 'EndpointUnavailable' (1001): browser crashed")); + diagnostics.LogReconnectAttemptFailed( + 2, + new InvalidOperationException("Attaching to the tracked browser target failed.", new TimeoutException("Timed out waiting for a tracked browser protocol response to 'Target.attachToTarget'."))); + diagnostics.LogReconnectFailed( + new InvalidOperationException("Connecting to the tracked browser debug endpoint failed.", new WebSocketException("Connection refused"))); + }); + + Assert.Collection( + logs, + log => Assert.Equal( + "2000-12-29T20:59:59.0000000Z [session-0001] Setting up the tracked browser debug connection failed: InvalidOperationException: Connecting to the tracked browser debug endpoint failed. --> TimeoutException: Timed out waiting for a tracked browser protocol response to 'Target.attachToTarget'.", + log.Content), + log => Assert.Equal( + "2000-12-29T20:59:59.0000000Z [session-0001] Tracked browser debug connection lost: InvalidOperationException: Browser debug connection closed by the remote endpoint with status 'EndpointUnavailable' (1001): browser crashed. Attempting to reconnect.", + log.Content), + log => Assert.Equal( + "2000-12-29T20:59:59.0000000Z [session-0001] Reconnect attempt 2 failed: InvalidOperationException: Attaching to the tracked browser target failed. --> TimeoutException: Timed out waiting for a tracked browser protocol response to 'Target.attachToTarget'.", + log.Content), + log => Assert.Equal( + "2000-12-29T20:59:59.0000000Z [session-0001] Unable to reconnect tracked browser debug connection. Closing the tracked browser session. Last error: InvalidOperationException: Connecting to the tracked browser debug endpoint failed. --> WebSocketException: Connection refused", + log.Content)); + } + + private static async Task> CaptureLogsAsync(ResourceLoggerService resourceLoggerService, string resourceName, int targetLogCount, Action writeLogs) + { + var subscribedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var watchTask = ConsoleLoggingTestHelpers.WatchForLogsAsync(resourceLoggerService.WatchAsync(resourceName), targetLogCount); + + _ = Task.Run(async () => + { + await foreach (var subscriber in resourceLoggerService.WatchAnySubscribersAsync()) + { + if (subscriber.Name == resourceName && subscriber.AnySubscribers) + { + subscribedTcs.TrySetResult(); + return; + } + } + }); + + await subscribedTcs.Task.DefaultTimeout(); + writeLogs(); + + return await watchTask.DefaultTimeout(); + } +} From 5235c7c480714ca464dab81672a2ce3596102ed4 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 20 Apr 2026 09:24:33 +1000 Subject: [PATCH 3/8] Fix XML doc indentation and use injected TimeProvider consistently Fix indentation of block in BrowserLogsBuilderExtensions.cs to match surrounding XML doc tags. Thread TimeProvider through RunningSession and its factory so timeout calculations in WaitForBrowserEndpointAsync and TryReconnectAsync use the injected provider instead of TimeProvider.System/DateTime.UtcNow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogsBuilderExtensions.cs | 32 +++++++++---------- .../BrowserLogsSessionManager.cs | 25 +++++++++------ 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs index 93ee51d7644..f00b85dc6fe 100644 --- a/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs +++ b/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs @@ -34,22 +34,22 @@ public static class BrowserLogsBuilderExtensions /// "msedge" and "chrome", or an explicit browser executable path. /// /// A reference to the original for further chaining. -/// -/// -/// This method adds a child browser logs resource beneath the parent resource represented by . -/// The child resource exposes a dashboard command that launches a Chromium-based browser in a tracked mode, attaches to -/// the browser's debugging protocol, and forwards browser console, error, and exception output to the child resource's -/// console log stream. -/// -/// -/// The tracked browser session uses the Chrome DevTools -/// Protocol (CDP) to subscribe to browser runtime, log, page, and network events. -/// -/// -/// 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. -/// -/// + /// + /// + /// This method adds a child browser logs resource beneath the parent resource represented by . + /// The child resource exposes a dashboard command that launches a Chromium-based browser in a tracked mode, attaches to + /// the browser's debugging protocol, and forwards browser console, error, and exception output to the child resource's + /// console log stream. + /// + /// + /// The tracked browser session uses the Chrome DevTools + /// Protocol (CDP) to subscribe to browser runtime, log, page, and network events. + /// + /// + /// 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. + /// + /// /// /// Add tracked browser logs for a web front end: /// diff --git a/src/Aspire.Hosting/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogsSessionManager.cs index f28102f1826..1e01f15b4f4 100644 --- a/src/Aspire.Hosting/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogsSessionManager.cs @@ -62,7 +62,7 @@ public BrowserLogsSessionManager( resourceNotificationService, timeProvider, logger, - new BrowserLogsRunningSessionFactory(fileSystemService, logger)) + new BrowserLogsRunningSessionFactory(fileSystemService, logger, timeProvider)) { } @@ -844,6 +844,7 @@ private sealed class RunningSession : IBrowserLogsRunningSession private readonly string _resourceName; private readonly string _sessionId; private readonly CancellationTokenSource _stopCts = new(); + private readonly TimeProvider _timeProvider; private readonly Uri _url; private readonly TempDirectory _userDataDirectory; @@ -865,7 +866,8 @@ private RunningSession( Uri url, TempDirectory userDataDirectory, ILogger resourceLogger, - ILogger logger) + ILogger logger, + TimeProvider timeProvider) { _eventLogger = new BrowserEventLogger(sessionId, resourceLogger); _connectionDiagnostics = new BrowserConnectionDiagnosticsLogger(sessionId, resourceLogger); @@ -874,6 +876,7 @@ private RunningSession( _resourceLogger = resourceLogger; _resourceName = resourceName; _sessionId = sessionId; + _timeProvider = timeProvider; _url = url; _userDataDirectory = userDataDirectory; } @@ -896,10 +899,11 @@ public static async Task StartAsync( IFileSystemService fileSystemService, ILogger resourceLogger, ILogger logger, + TimeProvider timeProvider, CancellationToken cancellationToken) { var userDataDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-browser-logs"); - var session = new RunningSession(resource, resourceName, sessionId, url, userDataDirectory, resourceLogger, logger); + var session = new RunningSession(resource, resourceName, sessionId, url, userDataDirectory, resourceLogger, logger, timeProvider); try { @@ -1018,7 +1022,7 @@ private async Task StartBrowserProcessAsync(CancellationToken cancellationToken) var (browserProcessTask, browserProcessLifetime) = ProcessUtil.Run(processSpec); _browserProcessTask = browserProcessTask; _browserProcessLifetime = browserProcessLifetime; - StartedAt = DateTime.UtcNow; + StartedAt = _timeProvider.GetUtcNow().UtcDateTime; await processStarted.Task.WaitAsync(cancellationToken).ConfigureAwait(false); } @@ -1177,11 +1181,11 @@ private async Task TryReconnectAsync(Exception? connectionError) _connectionDiagnostics.LogConnectionLost(connectionError); await DisposeConnectionAsync().ConfigureAwait(false); - var reconnectDeadline = TimeProvider.System.GetUtcNow() + s_connectionRecoveryTimeout; + var reconnectDeadline = _timeProvider.GetUtcNow() + s_connectionRecoveryTimeout; Exception? lastError = connectionError; var attempt = 0; - while (!_stopCts.IsCancellationRequested && TimeProvider.System.GetUtcNow() < reconnectDeadline) + while (!_stopCts.IsCancellationRequested && _timeProvider.GetUtcNow() < reconnectDeadline) { if (_browserProcessTask?.IsCompleted == true) { @@ -1287,9 +1291,9 @@ private async Task DisposeConnectionAsync() private async Task WaitForBrowserEndpointAsync(CancellationToken cancellationToken) { var devToolsActivePortFilePath = GetDevToolsActivePortFilePath(); - var timeoutAt = TimeProvider.System.GetUtcNow() + s_browserEndpointTimeout; + var timeoutAt = _timeProvider.GetUtcNow() + s_browserEndpointTimeout; - while (TimeProvider.System.GetUtcNow() < timeoutAt) + while (_timeProvider.GetUtcNow() < timeoutAt) { cancellationToken.ThrowIfCancellationRequested(); @@ -1814,11 +1818,13 @@ private sealed class BrowserLogsRunningSessionFactory : IBrowserLogsRunningSessi { private readonly IFileSystemService _fileSystemService; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; - public BrowserLogsRunningSessionFactory(IFileSystemService fileSystemService, ILogger logger) + public BrowserLogsRunningSessionFactory(IFileSystemService fileSystemService, ILogger logger, TimeProvider timeProvider) { _fileSystemService = fileSystemService; _logger = logger; + _timeProvider = timeProvider; } public async Task StartSessionAsync( @@ -1837,6 +1843,7 @@ public async Task StartSessionAsync( _fileSystemService, resourceLogger, _logger, + _timeProvider, cancellationToken).ConfigureAwait(false); } } From cb9c2842affcde8c59e4946b398714ec4bb5a3dd Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 19 Apr 2026 16:31:17 -0700 Subject: [PATCH 4/8] Split tracked browser log runtime Extract the running-session lifecycle, CDP websocket transport, and event/diagnostic logging into focused files so the browser connection layers can be tested in isolation. Also add targeted comments describing the browser and CDP quirks these layers handle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogsDevToolsConnection.cs | 321 ++++ src/Aspire.Hosting/BrowserLogsEventLogger.cs | 498 ++++++ .../BrowserLogsRunningSession.cs | 763 ++++++++ .../BrowserLogsSessionManager.cs | 1533 ----------------- .../BrowserLogsBuilderExtensionsTests.cs | 4 +- .../BrowserLogsSessionManagerTests.cs | 6 +- 6 files changed, 1587 insertions(+), 1538 deletions(-) create mode 100644 src/Aspire.Hosting/BrowserLogsDevToolsConnection.cs create mode 100644 src/Aspire.Hosting/BrowserLogsEventLogger.cs create mode 100644 src/Aspire.Hosting/BrowserLogsRunningSession.cs diff --git a/src/Aspire.Hosting/BrowserLogsDevToolsConnection.cs b/src/Aspire.Hosting/BrowserLogsDevToolsConnection.cs new file mode 100644 index 00000000000..efc31eff0a0 --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogsDevToolsConnection.cs @@ -0,0 +1,321 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +// Owns the browser-level websocket only. Protocol parsing stays in BrowserLogsProtocol and +// higher-level recovery stays in BrowserLogsRunningSession. +internal sealed class ChromeDevToolsConnection : IAsyncDisposable +{ + private static readonly TimeSpan s_commandTimeout = TimeSpan.FromSeconds(10); + + private readonly CancellationTokenSource _disposeCts = new(); + private readonly Func _eventHandler; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _pendingCommands = new(); + private readonly Task _receiveLoop; + private readonly SemaphoreSlim _sendLock = new(1, 1); + private readonly ClientWebSocket _webSocket; + private long _nextCommandId; + + private ChromeDevToolsConnection(ClientWebSocket webSocket, Func eventHandler, ILogger logger) + { + _eventHandler = eventHandler; + _logger = logger; + _webSocket = webSocket; + _receiveLoop = Task.Run(ReceiveLoopAsync); + } + + public Task Completion => _receiveLoop; + + public static async Task ConnectAsync( + Uri webSocketUri, + Func eventHandler, + ILogger logger, + CancellationToken cancellationToken) + { + var webSocket = new ClientWebSocket(); + webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(15); + await webSocket.ConnectAsync(webSocketUri, cancellationToken).ConfigureAwait(false); + return new ChromeDevToolsConnection(webSocket, eventHandler, logger); + } + + public Task CreateTargetAsync(CancellationToken cancellationToken) + { + return SendCommandAsync( + BrowserLogsProtocol.TargetCreateTargetMethod, + sessionId: null, + static writer => writer.WriteString("url", "about:blank"), + BrowserLogsProtocol.ParseCreateTargetResponse, + cancellationToken); + } + + public Task AttachToTargetAsync(string targetId, CancellationToken cancellationToken) + { + return SendCommandAsync( + BrowserLogsProtocol.TargetAttachToTargetMethod, + sessionId: null, + writer => + { + writer.WriteString("targetId", targetId); + writer.WriteBoolean("flatten", true); + }, + BrowserLogsProtocol.ParseAttachToTargetResponse, + cancellationToken); + } + + public async Task EnablePageInstrumentationAsync(string sessionId, CancellationToken cancellationToken) + { + await SendCommandAsync(BrowserLogsProtocol.RuntimeEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); + await SendCommandAsync(BrowserLogsProtocol.LogEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); + await SendCommandAsync(BrowserLogsProtocol.PageEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); + await SendCommandAsync(BrowserLogsProtocol.NetworkEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); + } + + public Task NavigateAsync(string sessionId, Uri url, CancellationToken cancellationToken) + { + return SendCommandAsync( + BrowserLogsProtocol.PageNavigateMethod, + sessionId, + writer => writer.WriteString("url", url.ToString()), + BrowserLogsProtocol.ParseCommandAckResponse, + cancellationToken); + } + + public Task CloseBrowserAsync(CancellationToken cancellationToken) + { + return SendCommandAsync( + BrowserLogsProtocol.BrowserCloseMethod, + sessionId: null, + writeParameters: null, + BrowserLogsProtocol.ParseCommandAckResponse, + cancellationToken); + } + + public async ValueTask DisposeAsync() + { + _disposeCts.Cancel(); + + try + { + if (_webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposed", CancellationToken.None).ConfigureAwait(false); + } + } + catch + { + _webSocket.Abort(); + } + finally + { + _webSocket.Dispose(); + } + + try + { + await _receiveLoop.ConfigureAwait(false); + } + catch + { + } + + _disposeCts.Dispose(); + _sendLock.Dispose(); + } + + private async Task SendCommandAsync( + string method, + string? sessionId, + Action? writeParameters, + ResponseParser parseResponse, + CancellationToken cancellationToken) + { + var commandId = Interlocked.Increment(ref _nextCommandId); + var pendingCommand = new PendingCommand(parseResponse); + _pendingCommands[commandId] = pendingCommand; + + try + { + using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token); + sendCts.CancelAfter(s_commandTimeout); + + using var registration = sendCts.Token.Register(static state => + { + ((IPendingCommand)state!).SetCanceled(); + }, pendingCommand); + + var payload = BrowserLogsProtocol.CreateCommandFrame(commandId, method, sessionId, writeParameters); + _logger.LogTrace("Tracked browser protocol -> {Frame}", BrowserLogsProtocol.DescribeFrame(payload)); + + var lockHeld = false; + try + { + // ClientWebSocket does not allow overlapping sends, so startup, reconnect, and shutdown all share + // this serialized path. + await _sendLock.WaitAsync(sendCts.Token).ConfigureAwait(false); + lockHeld = true; + await _webSocket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, sendCts.Token).ConfigureAwait(false); + } + finally + { + if (lockHeld) + { + _sendLock.Release(); + } + } + + return await pendingCommand.Task.ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && !_disposeCts.IsCancellationRequested) + { + throw new TimeoutException($"Timed out waiting for a tracked browser protocol response to '{method}'."); + } + finally + { + _pendingCommands.TryRemove(commandId, out _); + } + } + + private async Task ReceiveLoopAsync() + { + var buffer = new byte[16 * 1024]; + using var messageBuffer = new MemoryStream(); + Exception? terminalException = null; + + try + { + while (!_disposeCts.IsCancellationRequested && _webSocket.State is WebSocketState.Open or WebSocketState.CloseSent) + { + var result = await _webSocket.ReceiveAsync(buffer, _disposeCts.Token).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + terminalException = CreateUnexpectedConnectionClosureException(result); + break; + } + + messageBuffer.Write(buffer, 0, result.Count); + if (!result.EndOfMessage) + { + continue; + } + + var frame = messageBuffer.ToArray(); + messageBuffer.SetLength(0); + + _logger.LogTrace("Tracked browser protocol <- {Frame}", BrowserLogsProtocol.DescribeFrame(frame)); + + try + { + var header = BrowserLogsProtocol.ParseMessageHeader(frame); + if (header.Id is long commandId) + { + if (_pendingCommands.TryGetValue(commandId, out var pendingCommand)) + { + pendingCommand.SetResult(frame); + } + + continue; + } + + if (header.Method is not null && BrowserLogsProtocol.ParseEvent(header, frame) is { } protocolEvent) + { + await _eventHandler(protocolEvent).ConfigureAwait(false); + } + } + catch (Exception ex) + { + terminalException = new InvalidOperationException( + $"Tracked browser protocol receive loop failed while processing frame {BrowserLogsProtocol.DescribeFrame(frame)}.", + ex); + break; + } + } + } + catch (OperationCanceledException) when (_disposeCts.IsCancellationRequested) + { + } + catch (Exception ex) + { + terminalException = ex; + } + finally + { + terminalException ??= new InvalidOperationException("Browser debug connection closed."); + + // Any terminal transport failure must fault in-flight commands so callers can recover or shut down + // instead of waiting forever on a response that will never arrive. + foreach (var pendingCommand in _pendingCommands.Values) + { + pendingCommand.SetException(terminalException); + } + } + + if (!_disposeCts.IsCancellationRequested) + { + throw terminalException ?? new InvalidOperationException("Browser debug connection closed."); + } + } + + private static InvalidOperationException CreateUnexpectedConnectionClosureException(WebSocketReceiveResult result) + { + // Preserve the remote close details; they become the reconnect/resource-log diagnostics when CDP drops. + if (result.CloseStatus is { } closeStatus) + { + if (!string.IsNullOrWhiteSpace(result.CloseStatusDescription)) + { + return new InvalidOperationException($"Browser debug connection closed by the remote endpoint with status '{closeStatus}' ({(int)closeStatus}): {result.CloseStatusDescription}"); + } + + return new InvalidOperationException($"Browser debug connection closed by the remote endpoint with status '{closeStatus}' ({(int)closeStatus})."); + } + + return new InvalidOperationException("Browser debug connection closed by the remote endpoint without a close status."); + } + + private interface IPendingCommand + { + void SetCanceled(); + + void SetException(Exception exception); + + void SetResult(ReadOnlyMemory framePayload); + } + + private delegate TResult ResponseParser(ReadOnlySpan framePayload); + + private sealed class PendingCommand(ResponseParser parseResponse) : IPendingCommand + { + private readonly ResponseParser _parseResponse = parseResponse; + private readonly TaskCompletionSource _taskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Task => _taskCompletionSource.Task; + + public void SetCanceled() + { + _taskCompletionSource.TrySetCanceled(); + } + + public void SetException(Exception exception) + { + _taskCompletionSource.TrySetException(exception); + } + + public void SetResult(ReadOnlyMemory framePayload) + { + try + { + _taskCompletionSource.TrySetResult(_parseResponse(framePayload.Span)); + } + catch (Exception ex) + { + _taskCompletionSource.TrySetException(ex); + } + } + } +} diff --git a/src/Aspire.Hosting/BrowserLogsEventLogger.cs b/src/Aspire.Hosting/BrowserLogsEventLogger.cs new file mode 100644 index 00000000000..b0b83e9558f --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogsEventLogger.cs @@ -0,0 +1,498 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +// Turns low-level CDP events into resource log lines. Keeping this logic stateful but transport-free lets tests cover +// redirects, timing, and console formatting without needing a live browser. +internal sealed class BrowserEventLogger(string sessionId, ILogger resourceLogger) +{ + private readonly string _sessionId = sessionId; + private readonly ILogger _resourceLogger = resourceLogger; + private readonly Dictionary _networkRequests = new(StringComparer.Ordinal); + + public void HandleEvent(BrowserLogsProtocolEvent protocolEvent) + { + switch (protocolEvent) + { + case BrowserLogsConsoleApiCalledEvent consoleApiCalledEvent: + LogConsoleMessage(consoleApiCalledEvent.Parameters); + break; + case BrowserLogsExceptionThrownEvent exceptionThrownEvent: + LogUnhandledException(exceptionThrownEvent.Parameters); + break; + case BrowserLogsLogEntryAddedEvent logEntryAddedEvent: + LogEntryAdded(logEntryAddedEvent.Parameters); + break; + case BrowserLogsRequestWillBeSentEvent requestWillBeSentEvent: + TrackRequestStarted(requestWillBeSentEvent.Parameters); + break; + case BrowserLogsResponseReceivedEvent responseReceivedEvent: + TrackResponseReceived(responseReceivedEvent.Parameters); + break; + case BrowserLogsLoadingFinishedEvent loadingFinishedEvent: + TrackRequestCompleted(loadingFinishedEvent.Parameters); + break; + case BrowserLogsLoadingFailedEvent loadingFailedEvent: + TrackRequestFailed(loadingFailedEvent.Parameters); + break; + } + } + + private void LogConsoleMessage(BrowserLogsRuntimeConsoleApiCalledParameters parameters) + { + var level = parameters.Type ?? "log"; + var message = parameters.Args is { Length: > 0 } + ? string.Join(" ", parameters.Args.Select(FormatRemoteObject).Where(static value => !string.IsNullOrEmpty(value))) + : string.Empty; + + WriteLog(MapConsoleLevel(level), $"[console.{level}] {message}".TrimEnd()); + } + + private void LogUnhandledException(BrowserLogsExceptionThrownParameters parameters) + { + var exceptionDetails = parameters.ExceptionDetails; + if (exceptionDetails is null) + { + return; + } + + var message = exceptionDetails.Exception?.Description + ?? exceptionDetails.Text + ?? "Unhandled browser exception"; + + var location = GetLocationSuffix(exceptionDetails); + WriteLog(LogLevel.Error, $"[exception] {message}{location}"); + } + + private void LogEntryAdded(BrowserLogsLogEntryAddedParameters parameters) + { + var entry = parameters.Entry; + if (entry is null) + { + return; + } + + var level = entry.Level ?? "info"; + var text = entry.Text ?? string.Empty; + var location = GetLocationSuffix(entry); + + WriteLog(MapLogEntryLevel(level), $"[log.{level}] {text}{location}".TrimEnd()); + } + + private void TrackRequestStarted(BrowserLogsRequestWillBeSentParameters parameters) + { + if (parameters.RequestId is not { Length: > 0 } requestId || parameters.Request is not { } request) + { + return; + } + + var url = request.Url; + var method = request.Method; + if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(method)) + { + return; + } + + if (parameters.RedirectResponse is not null && + _networkRequests.Remove(requestId, out var redirectedRequest)) + { + // CDP reuses the same request id when a redirect starts the next hop, so emit the completed hop before + // overwriting it with the redirected request state. + UpdateResponse(redirectedRequest, parameters.RedirectResponse); + LogCompletedRequest(redirectedRequest, parameters.Timestamp, encodedDataLength: null, redirectUrl: url); + } + + _networkRequests[requestId] = new BrowserNetworkRequestState + { + Method = method, + ResourceType = NormalizeResourceType(parameters.Type), + StartTimestamp = parameters.Timestamp, + Url = url + }; + } + + private void TrackResponseReceived(BrowserLogsResponseReceivedParameters parameters) + { + if (parameters.RequestId is not { Length: > 0 } requestId || + !_networkRequests.TryGetValue(requestId, out var request)) + { + return; + } + + if (parameters.Response is not null) + { + UpdateResponse(request, parameters.Response); + } + + if (parameters.Type is { Length: > 0 } resourceType) + { + request.ResourceType = NormalizeResourceType(resourceType); + } + } + + private void TrackRequestCompleted(BrowserLogsLoadingFinishedParameters parameters) + { + if (parameters.RequestId is not { Length: > 0 } requestId || + !_networkRequests.Remove(requestId, out var request)) + { + return; + } + + LogCompletedRequest(request, parameters.Timestamp, parameters.EncodedDataLength, redirectUrl: null); + } + + private void TrackRequestFailed(BrowserLogsLoadingFailedParameters parameters) + { + if (parameters.RequestId is not { Length: > 0 } requestId || + !_networkRequests.Remove(requestId, out var request)) + { + return; + } + + var details = new List(); + + if (FormatDuration(request.StartTimestamp, parameters.Timestamp) is { Length: > 0 } duration) + { + details.Add(duration); + } + + if (parameters.Canceled == true) + { + details.Add("canceled"); + } + + if (!string.IsNullOrEmpty(parameters.BlockedReason)) + { + details.Add($"blocked={parameters.BlockedReason}"); + } + + WriteLog(LogLevel.Warning, $"[network.{request.ResourceType}] {request.Method} {request.Url} failed: {parameters.ErrorText ?? "Request failed"}{FormatDetails(details)}"); + } + + private void LogCompletedRequest(BrowserNetworkRequestState request, double? completedTimestamp, double? encodedDataLength, string? redirectUrl) + { + var details = new List(); + + if (FormatDuration(request.StartTimestamp, completedTimestamp) is { Length: > 0 } duration) + { + details.Add(duration); + } + + if (encodedDataLength is > 0) + { + details.Add($"{Math.Round(encodedDataLength.Value, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture)} B"); + } + + if (request.FromDiskCache == true) + { + details.Add("disk-cache"); + } + + if (request.FromServiceWorker == true) + { + details.Add("service-worker"); + } + + if (!string.IsNullOrEmpty(redirectUrl)) + { + details.Add($"redirect to {redirectUrl}"); + } + + var statusText = request.StatusCode is int statusCode + ? string.IsNullOrEmpty(request.StatusText) + ? $" -> {statusCode}" + : $" -> {statusCode} {request.StatusText}" + : redirectUrl is null + ? " completed" + : " -> redirect"; + + WriteLog(LogLevel.Information, $"[network.{request.ResourceType}] {request.Method} {request.Url}{statusText}{FormatDetails(details)}"); + } + + private static void UpdateResponse(BrowserNetworkRequestState request, BrowserLogsResponse response) + { + request.Url = response.Url ?? request.Url; + request.StatusCode = response.Status; + request.StatusText = response.StatusText; + request.FromDiskCache = response.FromDiskCache; + request.FromServiceWorker = response.FromServiceWorker; + } + + private void WriteLog(LogLevel logLevel, string message) + { + var sessionMessage = $"[{_sessionId}] {message}"; + + switch (logLevel) + { + case LogLevel.Error: + case LogLevel.Critical: + _resourceLogger.LogError("{Message}", sessionMessage); + break; + case LogLevel.Warning: + _resourceLogger.LogWarning("{Message}", sessionMessage); + break; + case LogLevel.Debug: + case LogLevel.Trace: + _resourceLogger.LogDebug("{Message}", sessionMessage); + break; + default: + _resourceLogger.LogInformation("{Message}", sessionMessage); + break; + } + } + + private static string NormalizeResourceType(string? resourceType) => + string.IsNullOrEmpty(resourceType) + ? "request" + : resourceType.ToLowerInvariant(); + + private static string? FormatDuration(double? startTimestamp, double? endTimestamp) + { + if (startTimestamp is null || endTimestamp is null || endTimestamp < startTimestamp) + { + return null; + } + + var durationMs = Math.Round((endTimestamp.Value - startTimestamp.Value) * 1000, MidpointRounding.AwayFromZero); + return $"{durationMs.ToString(CultureInfo.InvariantCulture)} ms"; + } + + private static string FormatDetails(IReadOnlyList details) => + details.Count > 0 + ? $" ({string.Join(", ", details)})" + : string.Empty; + + private static LogLevel MapConsoleLevel(string level) => level switch + { + "error" or "assert" => LogLevel.Error, + "warning" or "warn" => LogLevel.Warning, + "debug" => LogLevel.Debug, + _ => LogLevel.Information + }; + + private static LogLevel MapLogEntryLevel(string level) => level switch + { + "error" => LogLevel.Error, + "warning" => LogLevel.Warning, + "verbose" => LogLevel.Debug, + _ => LogLevel.Information + }; + + private static string FormatRemoteObject(BrowserLogsProtocolRemoteObject remoteObject) + { + // Console arguments can arrive either as pre-rendered descriptions or as structured values that need stable + // formatting for logs and tests. + if (remoteObject.Value is BrowserLogsProtocolValue value) + { + return value switch + { + BrowserLogsProtocolStringValue stringValue => stringValue.Value, + BrowserLogsProtocolNullValue => "null", + BrowserLogsProtocolBooleanValue booleanValue => booleanValue.Value ? bool.TrueString : bool.FalseString, + BrowserLogsProtocolNumberValue numberValue => numberValue.RawValue, + _ => FormatStructuredValue(value) + }; + } + + if (!string.IsNullOrEmpty(remoteObject.UnserializableValue)) + { + return remoteObject.UnserializableValue; + } + + return remoteObject.Description ?? string.Empty; + } + + private static string FormatStructuredValue(BrowserLogsProtocolValue value) + { + var builder = new StringBuilder(); + AppendStructuredValue(builder, value); + return builder.ToString(); + } + + private static void AppendStructuredValue(StringBuilder builder, BrowserLogsProtocolValue value) + { + switch (value) + { + case BrowserLogsProtocolArrayValue arrayValue: + builder.Append('['); + for (var i = 0; i < arrayValue.Items.Count; i++) + { + if (i > 0) + { + builder.Append(','); + } + + AppendStructuredValue(builder, arrayValue.Items[i]); + } + + builder.Append(']'); + break; + case BrowserLogsProtocolBooleanValue booleanValue: + builder.Append(booleanValue.Value ? "true" : "false"); + break; + case BrowserLogsProtocolNullValue: + builder.Append("null"); + break; + case BrowserLogsProtocolNumberValue numberValue: + builder.Append(numberValue.RawValue); + break; + case BrowserLogsProtocolObjectValue objectValue: + builder.Append('{'); + var needsComma = false; + foreach (var (propertyName, propertyValue) in objectValue.Properties) + { + if (needsComma) + { + builder.Append(','); + } + + needsComma = true; + AppendEscapedString(builder, propertyName); + builder.Append(':'); + AppendStructuredValue(builder, propertyValue); + } + + builder.Append('}'); + break; + case BrowserLogsProtocolStringValue stringValue: + AppendEscapedString(builder, stringValue.Value); + break; + } + } + + private static void AppendEscapedString(StringBuilder builder, string value) + { + builder.Append('"'); + + foreach (var character in value) + { + switch (character) + { + case '\\': + builder.Append("\\\\"); + break; + case '"': + builder.Append("\\\""); + break; + case '\b': + builder.Append("\\b"); + break; + case '\f': + builder.Append("\\f"); + break; + case '\n': + builder.Append("\\n"); + break; + case '\r': + builder.Append("\\r"); + break; + case '\t': + builder.Append("\\t"); + break; + default: + if (char.IsControl(character)) + { + builder.Append("\\u"); + builder.Append(((int)character).ToString("x4", CultureInfo.InvariantCulture)); + } + else + { + builder.Append(character); + } + + break; + } + } + + builder.Append('"'); + } + + private static string GetLocationSuffix(BrowserLogsSourceLocation details) + { + var url = details.Url; + if (string.IsNullOrEmpty(url)) + { + return string.Empty; + } + + var lineNumber = details.LineNumber + 1; + var columnNumber = details.ColumnNumber + 1; + + if (lineNumber > 0 && columnNumber > 0) + { + return $" ({url}:{lineNumber}:{columnNumber})"; + } + + return $" ({url})"; + } + + private sealed class BrowserNetworkRequestState + { + public bool? FromDiskCache { get; set; } + + public bool? FromServiceWorker { get; set; } + + public required string Method { get; set; } + + public required string ResourceType { get; set; } + + public double? StartTimestamp { get; set; } + + public int? StatusCode { get; set; } + + public string? StatusText { get; set; } + + public required string Url { get; set; } + } +} + +// Keep message composition separate from the runtime so tests can pin the diagnostics without a live websocket failure. +internal sealed class BrowserConnectionDiagnosticsLogger(string sessionId, ILogger resourceLogger) +{ + private readonly ILogger _resourceLogger = resourceLogger; + private readonly string _sessionId = sessionId; + + public void LogSetupFailure(string stage, Exception exception) + { + _resourceLogger.LogError("[{SessionId}] {Stage} failed: {Reason}", _sessionId, stage, DescribeConnectionProblem(exception)); + } + + public void LogConnectionLost(Exception exception) + { + _resourceLogger.LogWarning("[{SessionId}] Tracked browser debug connection lost: {Reason}. Attempting to reconnect.", _sessionId, DescribeConnectionProblem(exception)); + } + + public void LogReconnectAttemptFailed(int attempt, Exception exception) + { + _resourceLogger.LogWarning("[{SessionId}] Reconnect attempt {Attempt} failed: {Reason}", _sessionId, attempt, DescribeConnectionProblem(exception)); + } + + public void LogReconnectFailed(Exception exception) + { + _resourceLogger.LogError("[{SessionId}] Unable to reconnect tracked browser debug connection. Closing the tracked browser session. Last error: {Reason}", _sessionId, DescribeConnectionProblem(exception)); + } + + internal static string DescribeConnectionProblem(Exception exception) + { + var messages = new List(); + + for (var current = exception; current is not null; current = current.InnerException) + { + var message = string.IsNullOrWhiteSpace(current.Message) + ? current.GetType().Name + : $"{current.GetType().Name}: {current.Message}"; + + if (!messages.Contains(message, StringComparer.Ordinal)) + { + messages.Add(message); + } + } + + return string.Join(" --> ", messages); + } +} diff --git a/src/Aspire.Hosting/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogsRunningSession.cs new file mode 100644 index 00000000000..d05ebc68bda --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogsRunningSession.cs @@ -0,0 +1,763 @@ +// 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 ASPIREFILESYSTEM001 // Type is for evaluation purposes only + +using System.Globalization; +using System.Text; +using Aspire.Hosting.Dcp.Process; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +internal interface IBrowserLogsRunningSession +{ + string SessionId { get; } + + string BrowserExecutable { get; } + + int ProcessId { get; } + + DateTime StartedAt { get; } + + void StartCompletionObserver(Func onCompleted); + + Task StopAsync(CancellationToken cancellationToken); +} + +internal interface IBrowserLogsRunningSessionFactory +{ + Task StartSessionAsync( + BrowserLogsResource resource, + string resourceName, + Uri url, + string sessionId, + ILogger resourceLogger, + CancellationToken cancellationToken); +} + +internal sealed class BrowserLogsRunningSessionFactory : IBrowserLogsRunningSessionFactory +{ + private readonly IFileSystemService _fileSystemService; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public BrowserLogsRunningSessionFactory(IFileSystemService fileSystemService, ILogger logger, TimeProvider timeProvider) + { + _fileSystemService = fileSystemService; + _logger = logger; + _timeProvider = timeProvider; + } + + public async Task StartSessionAsync( + BrowserLogsResource resource, + string resourceName, + Uri url, + string sessionId, + ILogger resourceLogger, + CancellationToken cancellationToken) + { + return await BrowserLogsRunningSession.StartAsync( + resource, + resourceName, + sessionId, + url, + _fileSystemService, + resourceLogger, + _logger, + _timeProvider, + cancellationToken).ConfigureAwait(false); + } +} + +// Owns one launched browser instance and its attached CDP target. The manager keeps aggregate dashboard state; +// this type keeps per-browser lifecycle, diagnostics, and recovery. +internal sealed class BrowserLogsRunningSession : IBrowserLogsRunningSession +{ + private static readonly TimeSpan s_browserEndpointTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan s_browserShutdownTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan s_connectionRecoveryDelay = TimeSpan.FromMilliseconds(200); + private static readonly TimeSpan s_connectionRecoveryTimeout = TimeSpan.FromSeconds(5); + + private readonly BrowserEventLogger _eventLogger; + private readonly BrowserConnectionDiagnosticsLogger _connectionDiagnostics; + private readonly ILogger _logger; + private readonly BrowserLogsResource _resource; + private readonly ILogger _resourceLogger; + private readonly string _resourceName; + private readonly string _sessionId; + private readonly CancellationTokenSource _stopCts = new(); + private readonly TimeProvider _timeProvider; + private readonly Uri _url; + private readonly TempDirectory _userDataDirectory; + + private string? _browserExecutable; + private Uri? _browserEndpoint; + private Task? _browserProcessTask; + private IAsyncDisposable? _browserProcessLifetime; + private ChromeDevToolsConnection? _connection; + private Task? _completion; + private int _cleanupState; + private int? _processId; + private string? _targetId; + private string? _targetSessionId; + + private BrowserLogsRunningSession( + BrowserLogsResource resource, + string resourceName, + string sessionId, + Uri url, + TempDirectory userDataDirectory, + ILogger resourceLogger, + ILogger logger, + TimeProvider timeProvider) + { + _eventLogger = new BrowserEventLogger(sessionId, resourceLogger); + _connectionDiagnostics = new BrowserConnectionDiagnosticsLogger(sessionId, resourceLogger); + _logger = logger; + _resource = resource; + _resourceLogger = resourceLogger; + _resourceName = resourceName; + _sessionId = sessionId; + _timeProvider = timeProvider; + _url = url; + _userDataDirectory = userDataDirectory; + } + + public string SessionId => _sessionId; + + public string BrowserExecutable => _browserExecutable ?? throw new InvalidOperationException("Browser executable is not available before the session starts."); + + public int ProcessId => _processId ?? throw new InvalidOperationException("Browser process has not started."); + + public DateTime StartedAt { get; private set; } + + private Task Completion => _completion ?? throw new InvalidOperationException("Session has not been started."); + + public static async Task StartAsync( + BrowserLogsResource resource, + string resourceName, + string sessionId, + Uri url, + IFileSystemService fileSystemService, + ILogger resourceLogger, + ILogger logger, + TimeProvider timeProvider, + CancellationToken cancellationToken) + { + var userDataDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-browser-logs"); + var session = new BrowserLogsRunningSession(resource, resourceName, sessionId, url, userDataDirectory, resourceLogger, logger, timeProvider); + + try + { + await session.InitializeAsync(cancellationToken).ConfigureAwait(false); + session._completion = session.MonitorAsync(); + return session; + } + catch + { + await session.CleanupAsync().ConfigureAwait(false); + throw; + } + } + + public void StartCompletionObserver(Func onCompleted) + { + _ = ObserveCompletionAsync(onCompleted); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _stopCts.Cancel(); + + if (_connection is not null) + { + try + { + await _connection.CloseBrowserAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to close tracked browser for resource '{ResourceName}' via CDP.", _resourceName); + } + } + + if (_browserProcessTask is { IsCompleted: false } browserProcessTask) + { + using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + waitCts.CancelAfter(s_browserShutdownTimeout); + + try + { + await browserProcessTask.WaitAsync(waitCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + await DisposeBrowserProcessAsync().ConfigureAwait(false); + } + } + + try + { + await Completion.ConfigureAwait(false); + } + catch + { + } + } + + private async Task InitializeAsync(CancellationToken cancellationToken) + { + _browserExecutable = ResolveBrowserExecutable(_resource.Browser); + if (_browserExecutable is null) + { + throw new InvalidOperationException($"Unable to locate browser '{_resource.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); + } + + var devToolsActivePortFilePath = GetDevToolsActivePortFilePath(); + await StartBrowserProcessAsync(cancellationToken).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Started tracked browser process '{BrowserExecutable}'.", _sessionId, _browserExecutable); + _resourceLogger.LogInformation("[{SessionId}] Waiting for tracked browser debug endpoint metadata in '{DevToolsActivePortFilePath}'.", _sessionId, devToolsActivePortFilePath); + + try + { + _browserEndpoint = await WaitForBrowserEndpointAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _connectionDiagnostics.LogSetupFailure("Discovering the tracked browser debug endpoint", ex); + throw; + } + + _resourceLogger.LogInformation("[{SessionId}] Discovered tracked browser debug endpoint '{Endpoint}'.", _sessionId, _browserEndpoint); + + try + { + await ConnectAsync(createTarget: true, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _connectionDiagnostics.LogSetupFailure("Setting up the tracked browser debug connection", ex); + throw; + } + + _resourceLogger.LogInformation("[{SessionId}] Tracking browser console logs for '{Url}'.", _sessionId, _url); + } + + private async Task StartBrowserProcessAsync(CancellationToken cancellationToken) + { + var processStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var browserExecutable = _browserExecutable ?? throw new InvalidOperationException("Browser executable was not resolved."); + var processSpec = new ProcessSpec(browserExecutable) + { + Arguments = BuildBrowserArguments(), + InheritEnv = true, + OnErrorData = error => _logger.LogTrace("[{SessionId}] Tracked browser stderr: {Line}", _sessionId, error), + OnOutputData = output => _logger.LogTrace("[{SessionId}] Tracked browser stdout: {Line}", _sessionId, output), + OnStart = processId => + { + _processId = processId; + processStarted.TrySetResult(processId); + }, + ThrowOnNonZeroReturnCode = false + }; + + var (browserProcessTask, browserProcessLifetime) = ProcessUtil.Run(processSpec); + _browserProcessTask = browserProcessTask; + _browserProcessLifetime = browserProcessLifetime; + StartedAt = _timeProvider.GetUtcNow().UtcDateTime; + + await processStarted.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private string BuildBrowserArguments() + { + return BuildCommandLine( + [ + $"--user-data-dir={_userDataDirectory.Path}", + "--remote-debugging-port=0", + "--no-first-run", + "--no-default-browser-check", + "--new-window", + "--allow-insecure-localhost", + "--ignore-certificate-errors", + "about:blank" + ]); + } + + private async Task ConnectAsync(bool createTarget, CancellationToken cancellationToken) + { + var browserEndpoint = _browserEndpoint ?? throw new InvalidOperationException("Browser debugging endpoint is not available."); + + await DisposeConnectionAsync().ConfigureAwait(false); + + _connection = await ExecuteConnectionStageAsync( + "Connecting to the tracked browser debug endpoint", + () => ChromeDevToolsConnection.ConnectAsync(browserEndpoint, HandleEventAsync, _logger, cancellationToken)).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Connected to the tracked browser debug endpoint.", _sessionId); + + if (createTarget) + { + var createTargetResult = await ExecuteConnectionStageAsync( + "Creating the tracked browser target", + () => _connection.CreateTargetAsync(cancellationToken)).ConfigureAwait(false); + _targetId = createTargetResult.TargetId + ?? throw new InvalidOperationException("Browser target creation did not return a target id."); + _resourceLogger.LogInformation("[{SessionId}] Created tracked browser target '{TargetId}'.", _sessionId, _targetId); + } + + if (_targetId is null) + { + throw new InvalidOperationException("Tracked browser target id is not available."); + } + + var attachToTargetResult = await ExecuteConnectionStageAsync( + "Attaching to the tracked browser target", + () => _connection.AttachToTargetAsync(_targetId, cancellationToken)).ConfigureAwait(false); + _targetSessionId = attachToTargetResult.SessionId + ?? throw new InvalidOperationException("Browser target attachment did not return a session id."); + _resourceLogger.LogInformation("[{SessionId}] Attached to the tracked browser target.", _sessionId); + + await ExecuteConnectionStageAsync( + "Enabling tracked browser instrumentation", + () => _connection.EnablePageInstrumentationAsync(_targetSessionId, cancellationToken)).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Enabled tracked browser logging.", _sessionId); + + if (createTarget) + { + await ExecuteConnectionStageAsync( + "Navigating the tracked browser target", + () => _connection.NavigateAsync(_targetSessionId, _url, cancellationToken)).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Navigated tracked browser to '{Url}'.", _sessionId, _url); + } + } + + // Wrap the CDP stage boundaries so resource logs can identify which phase failed without losing the inner cause. + private static async Task ExecuteConnectionStageAsync(string stage, Func> action) + { + try + { + return await action().ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new InvalidOperationException($"{stage} failed.", ex); + } + } + + private static async Task ExecuteConnectionStageAsync(string stage, Func action) + { + try + { + await action().ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new InvalidOperationException($"{stage} failed.", ex); + } + } + + private async Task MonitorAsync() + { + try + { + var browserProcessTask = _browserProcessTask ?? throw new InvalidOperationException("Browser process task is not available."); + + while (true) + { + var connection = _connection ?? throw new InvalidOperationException("Tracked browser debug connection is not available."); + var completedTask = await Task.WhenAny(browserProcessTask, connection.Completion).ConfigureAwait(false); + + if (completedTask == browserProcessTask) + { + var processResult = await browserProcessTask.ConfigureAwait(false); + if (!_stopCts.IsCancellationRequested) + { + _resourceLogger.LogInformation("[{SessionId}] Tracked browser exited with code {ExitCode}.", _sessionId, processResult.ExitCode); + } + + return new BrowserSessionResult(processResult.ExitCode, Error: null); + } + + Exception? connectionError = null; + try + { + await connection.Completion.ConfigureAwait(false); + } + catch (Exception ex) + { + connectionError = ex; + } + + if (_stopCts.IsCancellationRequested) + { + var processResult = await browserProcessTask.ConfigureAwait(false); + return new BrowserSessionResult(processResult.ExitCode, Error: null); + } + + connectionError ??= new InvalidOperationException("The tracked browser debug connection closed without reporting a reason."); + + if (await TryReconnectAsync(connectionError).ConfigureAwait(false)) + { + continue; + } + + await DisposeBrowserProcessAsync().ConfigureAwait(false); + + var exitResult = await browserProcessTask.ConfigureAwait(false); + return new BrowserSessionResult(exitResult.ExitCode, connectionError); + } + } + finally + { + await CleanupAsync().ConfigureAwait(false); + } + } + + private async Task TryReconnectAsync(Exception? connectionError) + { + if (_browserEndpoint is null || _targetId is null) + { + return false; + } + + connectionError ??= new InvalidOperationException("The tracked browser debug connection closed without reporting a reason."); + _connectionDiagnostics.LogConnectionLost(connectionError); + await DisposeConnectionAsync().ConfigureAwait(false); + + // Recovery reuses the existing target instead of creating a second browser/tab. If that cannot be restored + // quickly, the process is torn down so the resource state matches reality. + var reconnectDeadline = _timeProvider.GetUtcNow() + s_connectionRecoveryTimeout; + Exception? lastError = connectionError; + var attempt = 0; + + while (!_stopCts.IsCancellationRequested && _timeProvider.GetUtcNow() < reconnectDeadline) + { + if (_browserProcessTask?.IsCompleted == true) + { + return false; + } + + try + { + attempt++; + await ConnectAsync(createTarget: false, _stopCts.Token).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Reconnected tracked browser debug connection.", _sessionId); + return true; + } + catch (OperationCanceledException) when (_stopCts.IsCancellationRequested) + { + return false; + } + catch (Exception ex) + { + lastError = ex; + _connectionDiagnostics.LogReconnectAttemptFailed(attempt, ex); + await DisposeConnectionAsync().ConfigureAwait(false); + } + + try + { + await Task.Delay(s_connectionRecoveryDelay, _stopCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_stopCts.IsCancellationRequested) + { + return false; + } + } + + if (lastError is not null) + { + _connectionDiagnostics.LogReconnectFailed(lastError); + _logger.LogDebug(lastError, "Timed out reconnecting tracked browser debug session for resource '{ResourceName}' and session '{SessionId}'.", _resourceName, _sessionId); + } + + return false; + } + + private ValueTask HandleEventAsync(BrowserLogsProtocolEvent protocolEvent) + { + // The browser-level websocket can surface events for other targets. Only forward the target attached for + // this tracked browser session. + if (!string.Equals(protocolEvent.SessionId, _targetSessionId, StringComparison.Ordinal)) + { + return ValueTask.CompletedTask; + } + + _eventLogger.HandleEvent(protocolEvent); + return ValueTask.CompletedTask; + } + + private async Task ObserveCompletionAsync(Func onCompleted) + { + try + { + var result = await Completion.ConfigureAwait(false); + await onCompleted(result.ExitCode, result.Error).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Tracked browser completion observer failed for resource '{ResourceName}' and session '{SessionId}'.", _resourceName, _sessionId); + } + } + + private async Task CleanupAsync() + { + if (Interlocked.Exchange(ref _cleanupState, 1) != 0) + { + return; + } + + await DisposeConnectionAsync().ConfigureAwait(false); + await DisposeBrowserProcessAsync().ConfigureAwait(false); + _stopCts.Dispose(); + _userDataDirectory.Dispose(); + } + + private async Task DisposeBrowserProcessAsync() + { + var browserProcessLifetime = _browserProcessLifetime; + _browserProcessLifetime = null; + + if (browserProcessLifetime is not null) + { + await browserProcessLifetime.DisposeAsync().ConfigureAwait(false); + } + } + + private async Task DisposeConnectionAsync() + { + var connection = _connection; + _connection = null; + + if (connection is not null) + { + await connection.DisposeAsync().ConfigureAwait(false); + } + } + + private async Task WaitForBrowserEndpointAsync(CancellationToken cancellationToken) + { + var devToolsActivePortFilePath = GetDevToolsActivePortFilePath(); + var timeoutAt = _timeProvider.GetUtcNow() + s_browserEndpointTimeout; + + // Chromium chooses the actual debugging port when asked for port 0 and writes it to DevToolsActivePort. + // Waiting on that file avoids the reserve-and-release race of probing a fixed port ahead of launch. + while (_timeProvider.GetUtcNow() < timeoutAt) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + if (File.Exists(devToolsActivePortFilePath)) + { + var contents = await File.ReadAllTextAsync(devToolsActivePortFilePath, cancellationToken).ConfigureAwait(false); + if (BrowserLogsDebugEndpointParser.TryParseBrowserDebugEndpoint(contents) is { } browserEndpoint) + { + return browserEndpoint; + } + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false); + } + + throw new TimeoutException($"Timed out waiting for the tracked browser to write '{devToolsActivePortFilePath}'."); + } + + private string GetDevToolsActivePortFilePath() + { + return Path.Combine(_userDataDirectory.Path, "DevToolsActivePort"); + } + + private static string? ResolveBrowserExecutable(string browser) + { + if (Path.IsPathRooted(browser) && File.Exists(browser)) + { + return browser; + } + + foreach (var candidate in GetBrowserCandidates(browser)) + { + if (Path.IsPathRooted(candidate)) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + else if (PathLookupHelper.FindFullPathFromPath(candidate) is { } resolvedPath) + { + return resolvedPath; + } + } + + return PathLookupHelper.FindFullPathFromPath(browser); + } + + private static IEnumerable GetBrowserCandidates(string browser) + { + if (OperatingSystem.IsMacOS()) + { + return browser.ToLowerInvariant() switch + { + "msedge" or "edge" => + [ + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "msedge" + ], + "chrome" or "google-chrome" => + [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "google-chrome", + "chrome" + ], + _ => [browser] + }; + } + + if (OperatingSystem.IsWindows()) + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + + return browser.ToLowerInvariant() switch + { + "msedge" or "edge" => + [ + Path.Combine(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"), + Path.Combine(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"), + "msedge.exe" + ], + "chrome" or "google-chrome" => + [ + Path.Combine(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"), + Path.Combine(programFiles, "Google", "Chrome", "Application", "chrome.exe"), + "chrome.exe" + ], + _ => [browser] + }; + } + + return browser.ToLowerInvariant() switch + { + "msedge" or "edge" => ["microsoft-edge", "microsoft-edge-stable", "msedge"], + "chrome" or "google-chrome" => ["google-chrome", "google-chrome-stable", "chrome", "chromium-browser", "chromium"], + _ => [browser] + }; + } + + private static string BuildCommandLine(IReadOnlyList arguments) + { + var builder = new StringBuilder(); + + for (var i = 0; i < arguments.Count; i++) + { + if (i > 0) + { + builder.Append(' '); + } + + AppendCommandLineArgument(builder, arguments[i]); + } + + return builder.ToString(); + } + + // Adapted from dotnet/runtime PasteArguments.AppendArgument so ProcessSpec can safely represent Chromium flags. + private static void AppendCommandLineArgument(StringBuilder builder, string argument) + { + if (argument.Length != 0 && !argument.AsSpan().ContainsAny(' ', '\t', '"')) + { + builder.Append(argument); + return; + } + + builder.Append('"'); + + var index = 0; + while (index < argument.Length) + { + var character = argument[index++]; + if (character == '\\') + { + var backslashCount = 1; + while (index < argument.Length && argument[index] == '\\') + { + index++; + backslashCount++; + } + + if (index == argument.Length) + { + builder.Append('\\', backslashCount * 2); + } + else if (argument[index] == '"') + { + builder.Append('\\', backslashCount * 2 + 1); + builder.Append('"'); + index++; + } + else + { + builder.Append('\\', backslashCount); + } + + continue; + } + + if (character == '"') + { + builder.Append('\\'); + builder.Append('"'); + continue; + } + + builder.Append(character); + } + + builder.Append('"'); + } + + private sealed record BrowserSessionResult(int ExitCode, Exception? Error); +} + +internal static class BrowserLogsDebugEndpointParser +{ + internal static Uri? TryParseBrowserDebugEndpoint(string activePortFileContents) + { + if (string.IsNullOrWhiteSpace(activePortFileContents)) + { + return null; + } + + using var reader = new StringReader(activePortFileContents); + var portLine = reader.ReadLine(); + var browserPathLine = reader.ReadLine(); + + if (!int.TryParse(portLine, NumberStyles.None, CultureInfo.InvariantCulture, out var port) || port <= 0) + { + return null; + } + + if (string.IsNullOrWhiteSpace(browserPathLine)) + { + return null; + } + + if (!browserPathLine.StartsWith("/", StringComparison.Ordinal)) + { + browserPathLine = $"/{browserPathLine}"; + } + + return Uri.TryCreate($"ws://127.0.0.1:{port}{browserPathLine}", UriKind.Absolute, out var browserEndpoint) + ? browserEndpoint + : null; + } +} diff --git a/src/Aspire.Hosting/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogsSessionManager.cs index 1e01f15b4f4..d4a8218bea5 100644 --- a/src/Aspire.Hosting/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogsSessionManager.cs @@ -5,43 +5,12 @@ using System.Collections.Concurrent; using System.Collections.Immutable; -using System.Globalization; -using System.Net.WebSockets; -using System.Text; -using System.Text.Json; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Dcp.Process; using Microsoft.Extensions.Logging; using HealthStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus; namespace Aspire.Hosting; -internal interface IBrowserLogsRunningSession -{ - string SessionId { get; } - - string BrowserExecutable { get; } - - int ProcessId { get; } - - DateTime StartedAt { get; } - - void StartCompletionObserver(Func onCompleted); - - Task StopAsync(CancellationToken cancellationToken); -} - -internal interface IBrowserLogsRunningSessionFactory -{ - Task StartSessionAsync( - BrowserLogsResource resource, - string resourceName, - Uri url, - string sessionId, - ILogger resourceLogger, - CancellationToken cancellationToken); -} - internal sealed class BrowserLogsSessionManager : IBrowserLogsSessionManager, IAsyncDisposable { private readonly ResourceLoggerService _resourceLoggerService; @@ -346,1508 +315,6 @@ private static string FormatActiveSessions(IEnumerable ses : "None"; } - internal sealed class BrowserEventLogger(string sessionId, ILogger resourceLogger) - { - private readonly string _sessionId = sessionId; - private readonly ILogger _resourceLogger = resourceLogger; - private readonly Dictionary _networkRequests = new(StringComparer.Ordinal); - - public void HandleEvent(BrowserLogsProtocolEvent protocolEvent) - { - switch (protocolEvent) - { - case BrowserLogsConsoleApiCalledEvent consoleApiCalledEvent: - LogConsoleMessage(consoleApiCalledEvent.Parameters); - break; - case BrowserLogsExceptionThrownEvent exceptionThrownEvent: - LogUnhandledException(exceptionThrownEvent.Parameters); - break; - case BrowserLogsLogEntryAddedEvent logEntryAddedEvent: - LogEntryAdded(logEntryAddedEvent.Parameters); - break; - case BrowserLogsRequestWillBeSentEvent requestWillBeSentEvent: - TrackRequestStarted(requestWillBeSentEvent.Parameters); - break; - case BrowserLogsResponseReceivedEvent responseReceivedEvent: - TrackResponseReceived(responseReceivedEvent.Parameters); - break; - case BrowserLogsLoadingFinishedEvent loadingFinishedEvent: - TrackRequestCompleted(loadingFinishedEvent.Parameters); - break; - case BrowserLogsLoadingFailedEvent loadingFailedEvent: - TrackRequestFailed(loadingFailedEvent.Parameters); - break; - } - } - - private void LogConsoleMessage(BrowserLogsRuntimeConsoleApiCalledParameters parameters) - { - var level = parameters.Type ?? "log"; - var message = parameters.Args is { Length: > 0 } - ? string.Join(" ", parameters.Args.Select(FormatRemoteObject).Where(static value => !string.IsNullOrEmpty(value))) - : string.Empty; - - WriteLog(MapConsoleLevel(level), $"[console.{level}] {message}".TrimEnd()); - } - - private void LogUnhandledException(BrowserLogsExceptionThrownParameters parameters) - { - var exceptionDetails = parameters.ExceptionDetails; - if (exceptionDetails is null) - { - return; - } - - var message = exceptionDetails.Exception?.Description - ?? exceptionDetails.Text - ?? "Unhandled browser exception"; - - var location = GetLocationSuffix(exceptionDetails); - WriteLog(LogLevel.Error, $"[exception] {message}{location}"); - } - - private void LogEntryAdded(BrowserLogsLogEntryAddedParameters parameters) - { - var entry = parameters.Entry; - if (entry is null) - { - return; - } - - var level = entry.Level ?? "info"; - var text = entry.Text ?? string.Empty; - var location = GetLocationSuffix(entry); - - WriteLog(MapLogEntryLevel(level), $"[log.{level}] {text}{location}".TrimEnd()); - } - - private void TrackRequestStarted(BrowserLogsRequestWillBeSentParameters parameters) - { - if (parameters.RequestId is not { Length: > 0 } requestId || parameters.Request is not { } request) - { - return; - } - - var url = request.Url; - var method = request.Method; - if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(method)) - { - return; - } - - if (parameters.RedirectResponse is not null && - _networkRequests.Remove(requestId, out var redirectedRequest)) - { - UpdateResponse(redirectedRequest, parameters.RedirectResponse); - LogCompletedRequest(redirectedRequest, parameters.Timestamp, encodedDataLength: null, redirectUrl: url); - } - - _networkRequests[requestId] = new BrowserNetworkRequestState - { - Method = method, - ResourceType = NormalizeResourceType(parameters.Type), - StartTimestamp = parameters.Timestamp, - Url = url - }; - } - - private void TrackResponseReceived(BrowserLogsResponseReceivedParameters parameters) - { - if (parameters.RequestId is not { Length: > 0 } requestId || - !_networkRequests.TryGetValue(requestId, out var request)) - { - return; - } - - if (parameters.Response is not null) - { - UpdateResponse(request, parameters.Response); - } - - if (parameters.Type is { Length: > 0 } resourceType) - { - request.ResourceType = NormalizeResourceType(resourceType); - } - } - - private void TrackRequestCompleted(BrowserLogsLoadingFinishedParameters parameters) - { - if (parameters.RequestId is not { Length: > 0 } requestId || - !_networkRequests.Remove(requestId, out var request)) - { - return; - } - - LogCompletedRequest(request, parameters.Timestamp, parameters.EncodedDataLength, redirectUrl: null); - } - - private void TrackRequestFailed(BrowserLogsLoadingFailedParameters parameters) - { - if (parameters.RequestId is not { Length: > 0 } requestId || - !_networkRequests.Remove(requestId, out var request)) - { - return; - } - - var details = new List(); - - if (FormatDuration(request.StartTimestamp, parameters.Timestamp) is { Length: > 0 } duration) - { - details.Add(duration); - } - - if (parameters.Canceled == true) - { - details.Add("canceled"); - } - - if (!string.IsNullOrEmpty(parameters.BlockedReason)) - { - details.Add($"blocked={parameters.BlockedReason}"); - } - - WriteLog(LogLevel.Warning, $"[network.{request.ResourceType}] {request.Method} {request.Url} failed: {parameters.ErrorText ?? "Request failed"}{FormatDetails(details)}"); - } - - private void LogCompletedRequest(BrowserNetworkRequestState request, double? completedTimestamp, double? encodedDataLength, string? redirectUrl) - { - var details = new List(); - - if (FormatDuration(request.StartTimestamp, completedTimestamp) is { Length: > 0 } duration) - { - details.Add(duration); - } - - if (encodedDataLength is > 0) - { - details.Add($"{Math.Round(encodedDataLength.Value, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture)} B"); - } - - if (request.FromDiskCache == true) - { - details.Add("disk-cache"); - } - - if (request.FromServiceWorker == true) - { - details.Add("service-worker"); - } - - if (!string.IsNullOrEmpty(redirectUrl)) - { - details.Add($"redirect to {redirectUrl}"); - } - - var statusText = request.StatusCode is int statusCode - ? string.IsNullOrEmpty(request.StatusText) - ? $" -> {statusCode}" - : $" -> {statusCode} {request.StatusText}" - : redirectUrl is null - ? " completed" - : " -> redirect"; - - WriteLog(LogLevel.Information, $"[network.{request.ResourceType}] {request.Method} {request.Url}{statusText}{FormatDetails(details)}"); - } - - private static void UpdateResponse(BrowserNetworkRequestState request, BrowserLogsResponse response) - { - request.Url = response.Url ?? request.Url; - request.StatusCode = response.Status; - request.StatusText = response.StatusText; - request.FromDiskCache = response.FromDiskCache; - request.FromServiceWorker = response.FromServiceWorker; - } - - private void WriteLog(LogLevel logLevel, string message) - { - var sessionMessage = $"[{_sessionId}] {message}"; - - switch (logLevel) - { - case LogLevel.Error: - case LogLevel.Critical: - _resourceLogger.LogError("{Message}", sessionMessage); - break; - case LogLevel.Warning: - _resourceLogger.LogWarning("{Message}", sessionMessage); - break; - case LogLevel.Debug: - case LogLevel.Trace: - _resourceLogger.LogDebug("{Message}", sessionMessage); - break; - default: - _resourceLogger.LogInformation("{Message}", sessionMessage); - break; - } - } - - private static string NormalizeResourceType(string? resourceType) => - string.IsNullOrEmpty(resourceType) - ? "request" - : resourceType.ToLowerInvariant(); - - private static string? FormatDuration(double? startTimestamp, double? endTimestamp) - { - if (startTimestamp is null || endTimestamp is null || endTimestamp < startTimestamp) - { - return null; - } - - var durationMs = Math.Round((endTimestamp.Value - startTimestamp.Value) * 1000, MidpointRounding.AwayFromZero); - return $"{durationMs.ToString(CultureInfo.InvariantCulture)} ms"; - } - - private static string FormatDetails(IReadOnlyList details) => - details.Count > 0 - ? $" ({string.Join(", ", details)})" - : string.Empty; - - private static LogLevel MapConsoleLevel(string level) => level switch - { - "error" or "assert" => LogLevel.Error, - "warning" or "warn" => LogLevel.Warning, - "debug" => LogLevel.Debug, - _ => LogLevel.Information - }; - - private static LogLevel MapLogEntryLevel(string level) => level switch - { - "error" => LogLevel.Error, - "warning" => LogLevel.Warning, - "verbose" => LogLevel.Debug, - _ => LogLevel.Information - }; - - private static string FormatRemoteObject(BrowserLogsProtocolRemoteObject remoteObject) - { - if (remoteObject.Value is BrowserLogsProtocolValue value) - { - return value switch - { - BrowserLogsProtocolStringValue stringValue => stringValue.Value, - BrowserLogsProtocolNullValue => "null", - BrowserLogsProtocolBooleanValue booleanValue => booleanValue.Value ? bool.TrueString : bool.FalseString, - BrowserLogsProtocolNumberValue numberValue => numberValue.RawValue, - _ => FormatStructuredValue(value) - }; - } - - if (!string.IsNullOrEmpty(remoteObject.UnserializableValue)) - { - return remoteObject.UnserializableValue; - } - - return remoteObject.Description ?? string.Empty; - } - - private static string FormatStructuredValue(BrowserLogsProtocolValue value) - { - var builder = new StringBuilder(); - AppendStructuredValue(builder, value); - return builder.ToString(); - } - - private static void AppendStructuredValue(StringBuilder builder, BrowserLogsProtocolValue value) - { - switch (value) - { - case BrowserLogsProtocolArrayValue arrayValue: - builder.Append('['); - for (var i = 0; i < arrayValue.Items.Count; i++) - { - if (i > 0) - { - builder.Append(','); - } - - AppendStructuredValue(builder, arrayValue.Items[i]); - } - - builder.Append(']'); - break; - case BrowserLogsProtocolBooleanValue booleanValue: - builder.Append(booleanValue.Value ? "true" : "false"); - break; - case BrowserLogsProtocolNullValue: - builder.Append("null"); - break; - case BrowserLogsProtocolNumberValue numberValue: - builder.Append(numberValue.RawValue); - break; - case BrowserLogsProtocolObjectValue objectValue: - builder.Append('{'); - var needsComma = false; - foreach (var (propertyName, propertyValue) in objectValue.Properties) - { - if (needsComma) - { - builder.Append(','); - } - - needsComma = true; - AppendEscapedString(builder, propertyName); - builder.Append(':'); - AppendStructuredValue(builder, propertyValue); - } - - builder.Append('}'); - break; - case BrowserLogsProtocolStringValue stringValue: - AppendEscapedString(builder, stringValue.Value); - break; - } - } - - private static void AppendEscapedString(StringBuilder builder, string value) - { - builder.Append('"'); - - foreach (var character in value) - { - switch (character) - { - case '\\': - builder.Append("\\\\"); - break; - case '"': - builder.Append("\\\""); - break; - case '\b': - builder.Append("\\b"); - break; - case '\f': - builder.Append("\\f"); - break; - case '\n': - builder.Append("\\n"); - break; - case '\r': - builder.Append("\\r"); - break; - case '\t': - builder.Append("\\t"); - break; - default: - if (char.IsControl(character)) - { - builder.Append("\\u"); - builder.Append(((int)character).ToString("x4", CultureInfo.InvariantCulture)); - } - else - { - builder.Append(character); - } - - break; - } - } - - builder.Append('"'); - } - - private static string GetLocationSuffix(BrowserLogsSourceLocation details) - { - var url = details.Url; - if (string.IsNullOrEmpty(url)) - { - return string.Empty; - } - - var lineNumber = details.LineNumber + 1; - var columnNumber = details.ColumnNumber + 1; - - if (lineNumber > 0 && columnNumber > 0) - { - return $" ({url}:{lineNumber}:{columnNumber})"; - } - - return $" ({url})"; - } - - private sealed class BrowserNetworkRequestState - { - public bool? FromDiskCache { get; set; } - - public bool? FromServiceWorker { get; set; } - - public required string Method { get; set; } - - public required string ResourceType { get; set; } - - public double? StartTimestamp { get; set; } - - public int? StatusCode { get; set; } - - public string? StatusText { get; set; } - - public required string Url { get; set; } - } - } - - internal sealed class BrowserConnectionDiagnosticsLogger(string sessionId, ILogger resourceLogger) - { - private readonly ILogger _resourceLogger = resourceLogger; - private readonly string _sessionId = sessionId; - - public void LogSetupFailure(string stage, Exception exception) - { - _resourceLogger.LogError("[{SessionId}] {Stage} failed: {Reason}", _sessionId, stage, DescribeConnectionProblem(exception)); - } - - public void LogConnectionLost(Exception exception) - { - _resourceLogger.LogWarning("[{SessionId}] Tracked browser debug connection lost: {Reason}. Attempting to reconnect.", _sessionId, DescribeConnectionProblem(exception)); - } - - public void LogReconnectAttemptFailed(int attempt, Exception exception) - { - _resourceLogger.LogWarning("[{SessionId}] Reconnect attempt {Attempt} failed: {Reason}", _sessionId, attempt, DescribeConnectionProblem(exception)); - } - - public void LogReconnectFailed(Exception exception) - { - _resourceLogger.LogError("[{SessionId}] Unable to reconnect tracked browser debug connection. Closing the tracked browser session. Last error: {Reason}", _sessionId, DescribeConnectionProblem(exception)); - } - - internal static string DescribeConnectionProblem(Exception exception) - { - var messages = new List(); - - for (var current = exception; current is not null; current = current.InnerException) - { - var message = string.IsNullOrWhiteSpace(current.Message) - ? current.GetType().Name - : $"{current.GetType().Name}: {current.Message}"; - - if (!messages.Contains(message, StringComparer.Ordinal)) - { - messages.Add(message); - } - } - - return string.Join(" --> ", messages); - } - } - - private sealed class RunningSession : IBrowserLogsRunningSession - { - private static readonly TimeSpan s_browserEndpointTimeout = TimeSpan.FromSeconds(30); - private static readonly TimeSpan s_browserShutdownTimeout = TimeSpan.FromSeconds(5); - private static readonly TimeSpan s_connectionRecoveryDelay = TimeSpan.FromMilliseconds(200); - private static readonly TimeSpan s_connectionRecoveryTimeout = TimeSpan.FromSeconds(5); - - private readonly BrowserEventLogger _eventLogger; - private readonly BrowserConnectionDiagnosticsLogger _connectionDiagnostics; - private readonly ILogger _logger; - private readonly BrowserLogsResource _resource; - private readonly ILogger _resourceLogger; - private readonly string _resourceName; - private readonly string _sessionId; - private readonly CancellationTokenSource _stopCts = new(); - private readonly TimeProvider _timeProvider; - private readonly Uri _url; - private readonly TempDirectory _userDataDirectory; - - private string? _browserExecutable; - private Uri? _browserEndpoint; - private Task? _browserProcessTask; - private IAsyncDisposable? _browserProcessLifetime; - private ChromeDevToolsConnection? _connection; - private Task? _completion; - private int _cleanupState; - private int? _processId; - private string? _targetId; - private string? _targetSessionId; - - private RunningSession( - BrowserLogsResource resource, - string resourceName, - string sessionId, - Uri url, - TempDirectory userDataDirectory, - ILogger resourceLogger, - ILogger logger, - TimeProvider timeProvider) - { - _eventLogger = new BrowserEventLogger(sessionId, resourceLogger); - _connectionDiagnostics = new BrowserConnectionDiagnosticsLogger(sessionId, resourceLogger); - _logger = logger; - _resource = resource; - _resourceLogger = resourceLogger; - _resourceName = resourceName; - _sessionId = sessionId; - _timeProvider = timeProvider; - _url = url; - _userDataDirectory = userDataDirectory; - } - - public string SessionId => _sessionId; - - public string BrowserExecutable => _browserExecutable ?? throw new InvalidOperationException("Browser executable is not available before the session starts."); - - public int ProcessId => _processId ?? throw new InvalidOperationException("Browser process has not started."); - - public DateTime StartedAt { get; private set; } - - private Task Completion => _completion ?? throw new InvalidOperationException("Session has not been started."); - - public static async Task StartAsync( - BrowserLogsResource resource, - string resourceName, - string sessionId, - Uri url, - IFileSystemService fileSystemService, - ILogger resourceLogger, - ILogger logger, - TimeProvider timeProvider, - CancellationToken cancellationToken) - { - var userDataDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-browser-logs"); - var session = new RunningSession(resource, resourceName, sessionId, url, userDataDirectory, resourceLogger, logger, timeProvider); - - try - { - await session.InitializeAsync(cancellationToken).ConfigureAwait(false); - session._completion = session.MonitorAsync(); - return session; - } - catch - { - await session.CleanupAsync().ConfigureAwait(false); - throw; - } - } - - public void StartCompletionObserver(Func onCompleted) - { - _ = ObserveCompletionAsync(onCompleted); - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - _stopCts.Cancel(); - - if (_connection is not null) - { - try - { - await _connection.CloseBrowserAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to close tracked browser for resource '{ResourceName}' via CDP.", _resourceName); - } - } - - if (_browserProcessTask is { IsCompleted: false } browserProcessTask) - { - using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - waitCts.CancelAfter(s_browserShutdownTimeout); - - try - { - await browserProcessTask.WaitAsync(waitCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - await DisposeBrowserProcessAsync().ConfigureAwait(false); - } - } - - try - { - await Completion.ConfigureAwait(false); - } - catch - { - } - } - - private async Task InitializeAsync(CancellationToken cancellationToken) - { - _browserExecutable = ResolveBrowserExecutable(_resource.Browser); - if (_browserExecutable is null) - { - throw new InvalidOperationException($"Unable to locate browser '{_resource.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); - } - - var devToolsActivePortFilePath = GetDevToolsActivePortFilePath(); - await StartBrowserProcessAsync(cancellationToken).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Started tracked browser process '{BrowserExecutable}'.", _sessionId, _browserExecutable); - _resourceLogger.LogInformation("[{SessionId}] Waiting for tracked browser debug endpoint metadata in '{DevToolsActivePortFilePath}'.", _sessionId, devToolsActivePortFilePath); - - try - { - _browserEndpoint = await WaitForBrowserEndpointAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _connectionDiagnostics.LogSetupFailure("Discovering the tracked browser debug endpoint", ex); - throw; - } - - _resourceLogger.LogInformation("[{SessionId}] Discovered tracked browser debug endpoint '{Endpoint}'.", _sessionId, _browserEndpoint); - - try - { - await ConnectAsync(createTarget: true, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _connectionDiagnostics.LogSetupFailure("Setting up the tracked browser debug connection", ex); - throw; - } - - _resourceLogger.LogInformation("[{SessionId}] Tracking browser console logs for '{Url}'.", _sessionId, _url); - } - - private async Task StartBrowserProcessAsync(CancellationToken cancellationToken) - { - var processStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var browserExecutable = _browserExecutable ?? throw new InvalidOperationException("Browser executable was not resolved."); - var processSpec = new ProcessSpec(browserExecutable) - { - Arguments = BuildBrowserArguments(), - InheritEnv = true, - OnErrorData = error => _logger.LogTrace("[{SessionId}] Tracked browser stderr: {Line}", _sessionId, error), - OnOutputData = output => _logger.LogTrace("[{SessionId}] Tracked browser stdout: {Line}", _sessionId, output), - OnStart = processId => - { - _processId = processId; - processStarted.TrySetResult(processId); - }, - ThrowOnNonZeroReturnCode = false - }; - - var (browserProcessTask, browserProcessLifetime) = ProcessUtil.Run(processSpec); - _browserProcessTask = browserProcessTask; - _browserProcessLifetime = browserProcessLifetime; - StartedAt = _timeProvider.GetUtcNow().UtcDateTime; - - await processStarted.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - - private string BuildBrowserArguments() - { - return BuildCommandLine( - [ - $"--user-data-dir={_userDataDirectory.Path}", - "--remote-debugging-port=0", - "--no-first-run", - "--no-default-browser-check", - "--new-window", - "--allow-insecure-localhost", - "--ignore-certificate-errors", - "about:blank" - ]); - } - - private async Task ConnectAsync(bool createTarget, CancellationToken cancellationToken) - { - var browserEndpoint = _browserEndpoint ?? throw new InvalidOperationException("Browser debugging endpoint is not available."); - - await DisposeConnectionAsync().ConfigureAwait(false); - - _connection = await ExecuteConnectionStageAsync( - "Connecting to the tracked browser debug endpoint", - () => ChromeDevToolsConnection.ConnectAsync(browserEndpoint, HandleEventAsync, _logger, cancellationToken)).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Connected to the tracked browser debug endpoint.", _sessionId); - - if (createTarget) - { - var createTargetResult = await ExecuteConnectionStageAsync( - "Creating the tracked browser target", - () => _connection.CreateTargetAsync(cancellationToken)).ConfigureAwait(false); - _targetId = createTargetResult.TargetId - ?? throw new InvalidOperationException("Browser target creation did not return a target id."); - _resourceLogger.LogInformation("[{SessionId}] Created tracked browser target '{TargetId}'.", _sessionId, _targetId); - } - - if (_targetId is null) - { - throw new InvalidOperationException("Tracked browser target id is not available."); - } - - var attachToTargetResult = await ExecuteConnectionStageAsync( - "Attaching to the tracked browser target", - () => _connection.AttachToTargetAsync(_targetId, cancellationToken)).ConfigureAwait(false); - _targetSessionId = attachToTargetResult.SessionId - ?? throw new InvalidOperationException("Browser target attachment did not return a session id."); - _resourceLogger.LogInformation("[{SessionId}] Attached to the tracked browser target.", _sessionId); - - await ExecuteConnectionStageAsync( - "Enabling tracked browser instrumentation", - () => _connection.EnablePageInstrumentationAsync(_targetSessionId, cancellationToken)).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Enabled tracked browser logging.", _sessionId); - - if (createTarget) - { - await ExecuteConnectionStageAsync( - "Navigating the tracked browser target", - () => _connection.NavigateAsync(_targetSessionId, _url, cancellationToken)).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Navigated tracked browser to '{Url}'.", _sessionId, _url); - } - } - - private static async Task ExecuteConnectionStageAsync(string stage, Func> action) - { - try - { - return await action().ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - throw new InvalidOperationException($"{stage} failed.", ex); - } - } - - private static async Task ExecuteConnectionStageAsync(string stage, Func action) - { - try - { - await action().ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - throw new InvalidOperationException($"{stage} failed.", ex); - } - } - - private async Task MonitorAsync() - { - try - { - var browserProcessTask = _browserProcessTask ?? throw new InvalidOperationException("Browser process task is not available."); - - while (true) - { - var connection = _connection ?? throw new InvalidOperationException("Tracked browser debug connection is not available."); - var completedTask = await Task.WhenAny(browserProcessTask, connection.Completion).ConfigureAwait(false); - - if (completedTask == browserProcessTask) - { - var processResult = await browserProcessTask.ConfigureAwait(false); - if (!_stopCts.IsCancellationRequested) - { - _resourceLogger.LogInformation("[{SessionId}] Tracked browser exited with code {ExitCode}.", _sessionId, processResult.ExitCode); - } - - return new BrowserSessionResult(processResult.ExitCode, Error: null); - } - - Exception? connectionError = null; - try - { - await connection.Completion.ConfigureAwait(false); - } - catch (Exception ex) - { - connectionError = ex; - } - - if (_stopCts.IsCancellationRequested) - { - var processResult = await browserProcessTask.ConfigureAwait(false); - return new BrowserSessionResult(processResult.ExitCode, Error: null); - } - - connectionError ??= new InvalidOperationException("The tracked browser debug connection closed without reporting a reason."); - - if (await TryReconnectAsync(connectionError).ConfigureAwait(false)) - { - continue; - } - - await DisposeBrowserProcessAsync().ConfigureAwait(false); - - var exitResult = await browserProcessTask.ConfigureAwait(false); - return new BrowserSessionResult(exitResult.ExitCode, connectionError); - } - } - finally - { - await CleanupAsync().ConfigureAwait(false); - } - } - - private async Task TryReconnectAsync(Exception? connectionError) - { - if (_browserEndpoint is null || _targetId is null) - { - return false; - } - - connectionError ??= new InvalidOperationException("The tracked browser debug connection closed without reporting a reason."); - _connectionDiagnostics.LogConnectionLost(connectionError); - await DisposeConnectionAsync().ConfigureAwait(false); - - var reconnectDeadline = _timeProvider.GetUtcNow() + s_connectionRecoveryTimeout; - Exception? lastError = connectionError; - var attempt = 0; - - while (!_stopCts.IsCancellationRequested && _timeProvider.GetUtcNow() < reconnectDeadline) - { - if (_browserProcessTask?.IsCompleted == true) - { - return false; - } - - try - { - attempt++; - await ConnectAsync(createTarget: false, _stopCts.Token).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Reconnected tracked browser debug connection.", _sessionId); - return true; - } - catch (OperationCanceledException) when (_stopCts.IsCancellationRequested) - { - return false; - } - catch (Exception ex) - { - lastError = ex; - _connectionDiagnostics.LogReconnectAttemptFailed(attempt, ex); - await DisposeConnectionAsync().ConfigureAwait(false); - } - - try - { - await Task.Delay(s_connectionRecoveryDelay, _stopCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (_stopCts.IsCancellationRequested) - { - return false; - } - } - - if (lastError is not null) - { - _connectionDiagnostics.LogReconnectFailed(lastError); - _logger.LogDebug(lastError, "Timed out reconnecting tracked browser debug session for resource '{ResourceName}' and session '{SessionId}'.", _resourceName, _sessionId); - } - - return false; - } - - private ValueTask HandleEventAsync(BrowserLogsProtocolEvent protocolEvent) - { - if (!string.Equals(protocolEvent.SessionId, _targetSessionId, StringComparison.Ordinal)) - { - return ValueTask.CompletedTask; - } - - _eventLogger.HandleEvent(protocolEvent); - return ValueTask.CompletedTask; - } - - private async Task ObserveCompletionAsync(Func onCompleted) - { - try - { - var result = await Completion.ConfigureAwait(false); - await onCompleted(result.ExitCode, result.Error).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Tracked browser completion observer failed for resource '{ResourceName}' and session '{SessionId}'.", _resourceName, _sessionId); - } - } - - private async Task CleanupAsync() - { - if (Interlocked.Exchange(ref _cleanupState, 1) != 0) - { - return; - } - - await DisposeConnectionAsync().ConfigureAwait(false); - await DisposeBrowserProcessAsync().ConfigureAwait(false); - _stopCts.Dispose(); - _userDataDirectory.Dispose(); - } - - private async Task DisposeBrowserProcessAsync() - { - var browserProcessLifetime = _browserProcessLifetime; - _browserProcessLifetime = null; - - if (browserProcessLifetime is not null) - { - await browserProcessLifetime.DisposeAsync().ConfigureAwait(false); - } - } - - private async Task DisposeConnectionAsync() - { - var connection = _connection; - _connection = null; - - if (connection is not null) - { - await connection.DisposeAsync().ConfigureAwait(false); - } - } - - private async Task WaitForBrowserEndpointAsync(CancellationToken cancellationToken) - { - var devToolsActivePortFilePath = GetDevToolsActivePortFilePath(); - var timeoutAt = _timeProvider.GetUtcNow() + s_browserEndpointTimeout; - - while (_timeProvider.GetUtcNow() < timeoutAt) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - if (File.Exists(devToolsActivePortFilePath)) - { - var contents = await File.ReadAllTextAsync(devToolsActivePortFilePath, cancellationToken).ConfigureAwait(false); - if (TryParseBrowserDebugEndpoint(contents) is { } browserEndpoint) - { - return browserEndpoint; - } - } - } - catch (IOException) - { - } - catch (UnauthorizedAccessException) - { - } - - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false); - } - - throw new TimeoutException($"Timed out waiting for the tracked browser to write '{devToolsActivePortFilePath}'."); - } - - private string GetDevToolsActivePortFilePath() - { - return Path.Combine(_userDataDirectory.Path, "DevToolsActivePort"); - } - - private static string? ResolveBrowserExecutable(string browser) - { - if (Path.IsPathRooted(browser) && File.Exists(browser)) - { - return browser; - } - - foreach (var candidate in GetBrowserCandidates(browser)) - { - if (Path.IsPathRooted(candidate)) - { - if (File.Exists(candidate)) - { - return candidate; - } - } - else if (PathLookupHelper.FindFullPathFromPath(candidate) is { } resolvedPath) - { - return resolvedPath; - } - } - - return PathLookupHelper.FindFullPathFromPath(browser); - } - - private static IEnumerable GetBrowserCandidates(string browser) - { - if (OperatingSystem.IsMacOS()) - { - return browser.ToLowerInvariant() switch - { - "msedge" or "edge" => - [ - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", - "msedge" - ], - "chrome" or "google-chrome" => - [ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "google-chrome", - "chrome" - ], - _ => [browser] - }; - } - - if (OperatingSystem.IsWindows()) - { - var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); - - return browser.ToLowerInvariant() switch - { - "msedge" or "edge" => - [ - Path.Combine(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"), - Path.Combine(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"), - "msedge.exe" - ], - "chrome" or "google-chrome" => - [ - Path.Combine(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"), - Path.Combine(programFiles, "Google", "Chrome", "Application", "chrome.exe"), - "chrome.exe" - ], - _ => [browser] - }; - } - - return browser.ToLowerInvariant() switch - { - "msedge" or "edge" => ["microsoft-edge", "microsoft-edge-stable", "msedge"], - "chrome" or "google-chrome" => ["google-chrome", "google-chrome-stable", "chrome", "chromium-browser", "chromium"], - _ => [browser] - }; - } - - private static string BuildCommandLine(IReadOnlyList arguments) - { - var builder = new StringBuilder(); - - for (var i = 0; i < arguments.Count; i++) - { - if (i > 0) - { - builder.Append(' '); - } - - AppendCommandLineArgument(builder, arguments[i]); - } - - return builder.ToString(); - } - - // Adapted from dotnet/runtime PasteArguments.AppendArgument so ProcessSpec can safely represent Chromium flags. - private static void AppendCommandLineArgument(StringBuilder builder, string argument) - { - if (argument.Length != 0 && !argument.AsSpan().ContainsAny(' ', '\t', '"')) - { - builder.Append(argument); - return; - } - - builder.Append('"'); - - var index = 0; - while (index < argument.Length) - { - var character = argument[index++]; - if (character == '\\') - { - var backslashCount = 1; - while (index < argument.Length && argument[index] == '\\') - { - index++; - backslashCount++; - } - - if (index == argument.Length) - { - builder.Append('\\', backslashCount * 2); - } - else if (argument[index] == '"') - { - builder.Append('\\', backslashCount * 2 + 1); - builder.Append('"'); - index++; - } - else - { - builder.Append('\\', backslashCount); - } - - continue; - } - - if (character == '"') - { - builder.Append('\\'); - builder.Append('"'); - continue; - } - - builder.Append(character); - } - - builder.Append('"'); - } - - private sealed record BrowserSessionResult(int ExitCode, Exception? Error); - - private sealed class ChromeDevToolsConnection : IAsyncDisposable - { - private static readonly TimeSpan s_commandTimeout = TimeSpan.FromSeconds(10); - - private readonly CancellationTokenSource _disposeCts = new(); - private readonly Func _eventHandler; - private readonly ILogger _logger; - private readonly ConcurrentDictionary _pendingCommands = new(); - private readonly Task _receiveLoop; - private readonly SemaphoreSlim _sendLock = new(1, 1); - private readonly ClientWebSocket _webSocket; - private long _nextCommandId; - - private ChromeDevToolsConnection(ClientWebSocket webSocket, Func eventHandler, ILogger logger) - { - _eventHandler = eventHandler; - _logger = logger; - _webSocket = webSocket; - _receiveLoop = Task.Run(ReceiveLoopAsync); - } - - public Task Completion => _receiveLoop; - - public static async Task ConnectAsync( - Uri webSocketUri, - Func eventHandler, - ILogger logger, - CancellationToken cancellationToken) - { - var webSocket = new ClientWebSocket(); - webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(15); - await webSocket.ConnectAsync(webSocketUri, cancellationToken).ConfigureAwait(false); - return new ChromeDevToolsConnection(webSocket, eventHandler, logger); - } - - public Task CreateTargetAsync(CancellationToken cancellationToken) - { - return SendCommandAsync( - BrowserLogsProtocol.TargetCreateTargetMethod, - sessionId: null, - static writer => writer.WriteString("url", "about:blank"), - BrowserLogsProtocol.ParseCreateTargetResponse, - cancellationToken); - } - - public Task AttachToTargetAsync(string targetId, CancellationToken cancellationToken) - { - return SendCommandAsync( - BrowserLogsProtocol.TargetAttachToTargetMethod, - sessionId: null, - writer => - { - writer.WriteString("targetId", targetId); - writer.WriteBoolean("flatten", true); - }, - BrowserLogsProtocol.ParseAttachToTargetResponse, - cancellationToken); - } - - public async Task EnablePageInstrumentationAsync(string sessionId, CancellationToken cancellationToken) - { - await SendCommandAsync(BrowserLogsProtocol.RuntimeEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); - await SendCommandAsync(BrowserLogsProtocol.LogEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); - await SendCommandAsync(BrowserLogsProtocol.PageEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); - await SendCommandAsync(BrowserLogsProtocol.NetworkEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); - } - - public Task NavigateAsync(string sessionId, Uri url, CancellationToken cancellationToken) - { - return SendCommandAsync( - BrowserLogsProtocol.PageNavigateMethod, - sessionId, - writer => writer.WriteString("url", url.ToString()), - BrowserLogsProtocol.ParseCommandAckResponse, - cancellationToken); - } - - public Task CloseBrowserAsync(CancellationToken cancellationToken) - { - return SendCommandAsync( - BrowserLogsProtocol.BrowserCloseMethod, - sessionId: null, - writeParameters: null, - BrowserLogsProtocol.ParseCommandAckResponse, - cancellationToken); - } - - public async ValueTask DisposeAsync() - { - _disposeCts.Cancel(); - - try - { - if (_webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) - { - await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposed", CancellationToken.None).ConfigureAwait(false); - } - } - catch - { - _webSocket.Abort(); - } - finally - { - _webSocket.Dispose(); - } - - try - { - await _receiveLoop.ConfigureAwait(false); - } - catch - { - } - - _disposeCts.Dispose(); - _sendLock.Dispose(); - } - - private async Task SendCommandAsync( - string method, - string? sessionId, - Action? writeParameters, - ResponseParser parseResponse, - CancellationToken cancellationToken) - { - var commandId = Interlocked.Increment(ref _nextCommandId); - var pendingCommand = new PendingCommand(parseResponse); - _pendingCommands[commandId] = pendingCommand; - - try - { - using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token); - sendCts.CancelAfter(s_commandTimeout); - - using var registration = sendCts.Token.Register(static state => - { - ((IPendingCommand)state!).SetCanceled(); - }, pendingCommand); - - var payload = BrowserLogsProtocol.CreateCommandFrame(commandId, method, sessionId, writeParameters); - _logger.LogTrace("Tracked browser protocol -> {Frame}", BrowserLogsProtocol.DescribeFrame(payload)); - - var lockHeld = false; - try - { - await _sendLock.WaitAsync(sendCts.Token).ConfigureAwait(false); - lockHeld = true; - await _webSocket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, sendCts.Token).ConfigureAwait(false); - } - finally - { - if (lockHeld) - { - _sendLock.Release(); - } - } - - return await pendingCommand.Task.ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && !_disposeCts.IsCancellationRequested) - { - throw new TimeoutException($"Timed out waiting for a tracked browser protocol response to '{method}'."); - } - finally - { - _pendingCommands.TryRemove(commandId, out _); - } - } - - private async Task ReceiveLoopAsync() - { - var buffer = new byte[16 * 1024]; - using var messageBuffer = new MemoryStream(); - Exception? terminalException = null; - - try - { - while (!_disposeCts.IsCancellationRequested && _webSocket.State is WebSocketState.Open or WebSocketState.CloseSent) - { - var result = await _webSocket.ReceiveAsync(buffer, _disposeCts.Token).ConfigureAwait(false); - if (result.MessageType == WebSocketMessageType.Close) - { - terminalException = CreateUnexpectedConnectionClosureException(result); - break; - } - - messageBuffer.Write(buffer, 0, result.Count); - if (!result.EndOfMessage) - { - continue; - } - - var frame = messageBuffer.ToArray(); - messageBuffer.SetLength(0); - - _logger.LogTrace("Tracked browser protocol <- {Frame}", BrowserLogsProtocol.DescribeFrame(frame)); - - try - { - var header = BrowserLogsProtocol.ParseMessageHeader(frame); - if (header.Id is long commandId) - { - if (_pendingCommands.TryGetValue(commandId, out var pendingCommand)) - { - pendingCommand.SetResult(frame); - } - - continue; - } - - if (header.Method is not null && BrowserLogsProtocol.ParseEvent(header, frame) is { } protocolEvent) - { - await _eventHandler(protocolEvent).ConfigureAwait(false); - } - } - catch (Exception ex) - { - terminalException = new InvalidOperationException( - $"Tracked browser protocol receive loop failed while processing frame {BrowserLogsProtocol.DescribeFrame(frame)}.", - ex); - break; - } - } - } - catch (OperationCanceledException) when (_disposeCts.IsCancellationRequested) - { - } - catch (Exception ex) - { - terminalException = ex; - } - finally - { - terminalException ??= new InvalidOperationException("Browser debug connection closed."); - - foreach (var pendingCommand in _pendingCommands.Values) - { - pendingCommand.SetException(terminalException); - } - } - - if (!_disposeCts.IsCancellationRequested) - { - throw terminalException ?? new InvalidOperationException("Browser debug connection closed."); - } - } - - private static InvalidOperationException CreateUnexpectedConnectionClosureException(WebSocketReceiveResult result) - { - if (result.CloseStatus is { } closeStatus) - { - if (!string.IsNullOrWhiteSpace(result.CloseStatusDescription)) - { - return new InvalidOperationException($"Browser debug connection closed by the remote endpoint with status '{closeStatus}' ({(int)closeStatus}): {result.CloseStatusDescription}"); - } - - return new InvalidOperationException($"Browser debug connection closed by the remote endpoint with status '{closeStatus}' ({(int)closeStatus})."); - } - - return new InvalidOperationException("Browser debug connection closed by the remote endpoint without a close status."); - } - - private interface IPendingCommand - { - void SetCanceled(); - - void SetException(Exception exception); - - void SetResult(ReadOnlyMemory framePayload); - } - - private delegate TResult ResponseParser(ReadOnlySpan framePayload); - - private sealed class PendingCommand(ResponseParser parseResponse) : IPendingCommand - { - private readonly ResponseParser _parseResponse = parseResponse; - private readonly TaskCompletionSource _taskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public Task Task => _taskCompletionSource.Task; - - public void SetCanceled() - { - _taskCompletionSource.TrySetCanceled(); - } - - public void SetException(Exception exception) - { - _taskCompletionSource.TrySetException(exception); - } - - public void SetResult(ReadOnlyMemory framePayload) - { - try - { - _taskCompletionSource.TrySetResult(_parseResponse(framePayload.Span)); - } - catch (Exception ex) - { - _taskCompletionSource.TrySetException(ex); - } - } - } - } - } - - internal static Uri? TryParseBrowserDebugEndpoint(string activePortFileContents) - { - if (string.IsNullOrWhiteSpace(activePortFileContents)) - { - return null; - } - - using var reader = new StringReader(activePortFileContents); - var portLine = reader.ReadLine(); - var browserPathLine = reader.ReadLine(); - - if (!int.TryParse(portLine, NumberStyles.None, CultureInfo.InvariantCulture, out var port) || port <= 0) - { - return null; - } - - if (string.IsNullOrWhiteSpace(browserPathLine)) - { - return null; - } - - if (!browserPathLine.StartsWith("/", StringComparison.Ordinal)) - { - browserPathLine = $"/{browserPathLine}"; - } - - return Uri.TryCreate($"ws://127.0.0.1:{port}{browserPathLine}", UriKind.Absolute, out var browserEndpoint) - ? browserEndpoint - : null; - } - - private sealed class BrowserLogsRunningSessionFactory : IBrowserLogsRunningSessionFactory - { - private readonly IFileSystemService _fileSystemService; - private readonly ILogger _logger; - private readonly TimeProvider _timeProvider; - - public BrowserLogsRunningSessionFactory(IFileSystemService fileSystemService, ILogger logger, TimeProvider timeProvider) - { - _fileSystemService = fileSystemService; - _logger = logger; - _timeProvider = timeProvider; - } - - public async Task StartSessionAsync( - BrowserLogsResource resource, - string resourceName, - Uri url, - string sessionId, - ILogger resourceLogger, - CancellationToken cancellationToken) - { - return await RunningSession.StartAsync( - resource, - resourceName, - sessionId, - url, - _fileSystemService, - resourceLogger, - _logger, - _timeProvider, - cancellationToken).ConfigureAwait(false); - } - } - private sealed class ResourceSessionState { public SemaphoreSlim Lock { get; } = new(1, 1); diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index 040f2493d81..30614a4ec07 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -272,7 +272,7 @@ public async Task BrowserEventLogger_LogsSuccessfulNetworkRequests() { var resourceLoggerService = ConsoleLoggingTestHelpers.GetResourceLoggerService(); var resourceLogger = resourceLoggerService.GetLogger("web-browser-logs"); - var eventLogger = new BrowserLogsSessionManager.BrowserEventLogger("session-0001", resourceLogger); + var eventLogger = new BrowserEventLogger("session-0001", resourceLogger); var logs = await CaptureLogsAsync(resourceLoggerService, "web-browser-logs", () => { eventLogger.HandleEvent(ParseProtocolEvent(""" @@ -330,7 +330,7 @@ public async Task BrowserEventLogger_LogsFailedNetworkRequests() { var resourceLoggerService = ConsoleLoggingTestHelpers.GetResourceLoggerService(); var resourceLogger = resourceLoggerService.GetLogger("web-browser-logs"); - var eventLogger = new BrowserLogsSessionManager.BrowserEventLogger("session-0002", resourceLogger); + var eventLogger = new BrowserEventLogger("session-0002", resourceLogger); var logs = await CaptureLogsAsync(resourceLoggerService, "web-browser-logs", () => { eventLogger.HandleEvent(ParseProtocolEvent(""" diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index c895c1931d4..8b3683fec3d 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -13,7 +13,7 @@ public class BrowserLogsSessionManagerTests [Fact] public void TryParseBrowserDebugEndpoint_ReturnsBrowserWebSocketUri() { - var endpoint = BrowserLogsSessionManager.TryParseBrowserDebugEndpoint(""" + var endpoint = BrowserLogsDebugEndpointParser.TryParseBrowserDebugEndpoint(""" 51943 /devtools/browser/4c8404fb-06f8-45f0-9d89-112233445566 """); @@ -28,7 +28,7 @@ public void TryParseBrowserDebugEndpoint_ReturnsBrowserWebSocketUri() [InlineData("51943")] public void TryParseBrowserDebugEndpoint_ReturnsNullForInvalidMetadata(string metadata) { - var endpoint = BrowserLogsSessionManager.TryParseBrowserDebugEndpoint(metadata); + var endpoint = BrowserLogsDebugEndpointParser.TryParseBrowserDebugEndpoint(metadata); Assert.Null(endpoint); } @@ -38,7 +38,7 @@ public async Task BrowserConnectionDiagnosticsLogger_LogsConnectionProblems() { var resourceLoggerService = ConsoleLoggingTestHelpers.GetResourceLoggerService(); var resourceName = "web-browser-logs"; - var diagnostics = new BrowserLogsSessionManager.BrowserConnectionDiagnosticsLogger("session-0001", resourceLoggerService.GetLogger(resourceName)); + var diagnostics = new BrowserConnectionDiagnosticsLogger("session-0001", resourceLoggerService.GetLogger(resourceName)); var logs = await CaptureLogsAsync(resourceLoggerService, resourceName, targetLogCount: 4, () => { From 3fefe1797876bbdff0338615a323f4948b9ac579 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 19 Apr 2026 16:45:35 -0700 Subject: [PATCH 5/8] Address browser logs review feedback Remove the global certificate-bypass flag from tracked browser launches and make session-manager disposal wait for completion observers without publishing shutdown updates through torn-down services. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogsRunningSession.cs | 7 +- .../BrowserLogsSessionManager.cs | 28 +++-- .../BrowserLogsBuilderExtensionsTests.cs | 104 +++++++++++++++++- 3 files changed, 122 insertions(+), 17 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogsRunningSession.cs index d05ebc68bda..48c81c321a4 100644 --- a/src/Aspire.Hosting/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogsRunningSession.cs @@ -20,7 +20,7 @@ internal interface IBrowserLogsRunningSession DateTime StartedAt { get; } - void StartCompletionObserver(Func onCompleted); + Task StartCompletionObserver(Func onCompleted); Task StopAsync(CancellationToken cancellationToken); } @@ -161,9 +161,9 @@ public static async Task StartAsync( } } - public void StartCompletionObserver(Func onCompleted) + public Task StartCompletionObserver(Func onCompleted) { - _ = ObserveCompletionAsync(onCompleted); + return ObserveCompletionAsync(onCompleted); } public async Task StopAsync(CancellationToken cancellationToken) @@ -280,7 +280,6 @@ private string BuildBrowserArguments() "--no-default-browser-check", "--new-window", "--allow-insecure-localhost", - "--ignore-certificate-errors", "about:blank" ]); } diff --git a/src/Aspire.Hosting/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogsSessionManager.cs index d4a8218bea5..fbb971d999d 100644 --- a/src/Aspire.Hosting/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogsSessionManager.cs @@ -19,6 +19,7 @@ internal sealed class BrowserLogsSessionManager : IBrowserLogsSessionManager, IA private readonly ILogger _logger; private readonly IBrowserLogsRunningSessionFactory _sessionFactory; private readonly ConcurrentDictionary _resourceStates = new(StringComparer.Ordinal); + private int _disposing; public BrowserLogsSessionManager( IFileSystemService fileSystemService, @@ -111,18 +112,19 @@ await PublishResourceSnapshotAsync( } resourceState.LastBrowserExecutable = session.BrowserExecutable; + var completionObserver = session.StartCompletionObserver(async (exitCode, error) => + { + await HandleSessionCompletedAsync(resource, resourceName, resourceState, session.SessionId, exitCode, error).ConfigureAwait(false); + }); + resourceState.ActiveSessions[session.SessionId] = new ActiveBrowserSession( session.SessionId, session.BrowserExecutable, session.ProcessId, session.StartedAt, url, - session); - - session.StartCompletionObserver(async (exitCode, error) => - { - await HandleSessionCompletedAsync(resource, resourceName, resourceState, session.SessionId, exitCode, error).ConfigureAwait(false); - }); + session, + completionObserver); await PublishResourceSnapshotAsync( resource, @@ -142,7 +144,10 @@ await PublishResourceSnapshotAsync( public async ValueTask DisposeAsync() { + Interlocked.Exchange(ref _disposing, 1); + var sessionsToStop = new List(); + var completionObservers = new List(); foreach (var resourceState in _resourceStates.Values) { @@ -151,6 +156,7 @@ public async ValueTask DisposeAsync() try { sessionsToStop.AddRange(resourceState.ActiveSessions.Values.Select(static activeSession => activeSession.Session)); + completionObservers.AddRange(resourceState.ActiveSessions.Values.Select(static activeSession => activeSession.CompletionObserver)); } finally { @@ -163,6 +169,8 @@ public async ValueTask DisposeAsync() await session.StopAsync(CancellationToken.None).ConfigureAwait(false); } + await Task.WhenAll(completionObservers).ConfigureAwait(false); + foreach (var (_, resourceState) in _resourceStates) { resourceState.Lock.Dispose(); @@ -177,6 +185,11 @@ private async Task HandleSessionCompletedAsync( int exitCode, Exception? error) { + if (Volatile.Read(ref _disposing) != 0) + { + return; + } + await resourceState.Lock.WaitAsync(CancellationToken.None).ConfigureAwait(false); try @@ -336,7 +349,8 @@ private sealed record ActiveBrowserSession( int ProcessId, DateTime StartedAt, Uri TargetUrl, - IBrowserLogsRunningSession Session); + IBrowserLogsRunningSession Session, + Task CompletionObserver); private sealed record PendingBrowserSession( string SessionId, diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index 30614a4ec07..eee5e5cde66 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -267,6 +267,64 @@ public async Task WithBrowserLogs_CommandTracksMultipleSessionsWithUniqueIds() Assert.Equal(KnownResourceStates.Finished, allCompletedEvent.Snapshot.State?.Text); } + [Fact] + public async Task WithBrowserLogs_DisposeWaitsForCompletionObservers() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var sessionFactory = new FakeBrowserLogsRunningSessionFactory(); + + builder.Services.AddSingleton(sp => + new BrowserLogsSessionManager( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sessionFactory)); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(browser: "chrome"); + + var app = builder.Build(); + var disposed = false; + + try + { + await app.StartAsync(); + + var browserLogsResource = app.Services.GetRequiredService().Resources.OfType().Single(); + var result = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + Assert.True(result.Success); + + var session = Assert.Single(sessionFactory.Sessions); + session.PauseCompletionObserver(); + + var disposeTask = app.DisposeAsync().AsTask(); + + await session.CompletionObserverStarted.DefaultTimeout(); + Assert.False(disposeTask.IsCompleted); + + session.ResumeCompletionObserver(); + await disposeTask.DefaultTimeout(); + disposed = true; + } + finally + { + if (!disposed) + { + await app.DisposeAsync(); + } + } + } + [Fact] public async Task BrowserEventLogger_LogsSuccessfulNetworkRequests() { @@ -444,7 +502,9 @@ private sealed class FakeBrowserLogsRunningSession( int processId, DateTime startedAt) : IBrowserLogsRunningSession { - private Func? _onCompleted; + private TaskCompletionSource _completionObserverGate = CreateSignaledTaskCompletionSource(); + private readonly TaskCompletionSource<(int ExitCode, Exception? Error)> _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private Task? _completionObserverTask; public string SessionId { get; } = sessionId; @@ -456,21 +516,53 @@ private sealed class FakeBrowserLogsRunningSession( public int StopCallCount { get; private set; } - public void StartCompletionObserver(Func onCompleted) + public Task CompletionObserverStarted => CompletionObserverStartedSource.Task; + + private TaskCompletionSource CompletionObserverStartedSource { get; set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task StartCompletionObserver(Func onCompleted) { - _onCompleted = onCompleted; + _completionObserverTask = ObserveCompletionAsync(onCompleted); + return _completionObserverTask; } public Task StopAsync(CancellationToken cancellationToken) { StopCallCount++; + _completionSource.TrySetResult((0, null)); return Task.CompletedTask; } - public Task CompleteAsync(int exitCode, Exception? error = null) + public async Task CompleteAsync(int exitCode, Exception? error = null) + { + _completionSource.TrySetResult((exitCode, error)); + await (_completionObserverTask ?? Task.CompletedTask); + } + + public void PauseCompletionObserver() + { + CompletionObserverStartedSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + _completionObserverGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public void ResumeCompletionObserver() + { + _completionObserverGate.TrySetResult(null); + } + + private async Task ObserveCompletionAsync(Func onCompleted) + { + var (exitCode, error) = await _completionSource.Task; + CompletionObserverStartedSource.TrySetResult(null); + await _completionObserverGate.Task; + await onCompleted(exitCode, error); + } + + private static TaskCompletionSource CreateSignaledTaskCompletionSource() { - Assert.NotNull(_onCompleted); - return _onCompleted!(exitCode, error); + var source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + source.TrySetResult(null); + return source; } } } From ac013799bba667b0d619f107672b34aa70e6da8e Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 19 Apr 2026 21:23:16 -0700 Subject: [PATCH 6/8] Add browser logs configuration and session metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserTelemetry.AppHost/AppHost.cs | 3 +- .../BrowserTelemetry.AppHost.csproj | 1 + .../BrowserLogsBuilderExtensions.cs | 99 +++++++- .../BrowserLogsDevToolsConnection.cs | 10 + src/Aspire.Hosting/BrowserLogsProtocol.cs | 43 ++++ src/Aspire.Hosting/BrowserLogsResource.cs | 21 +- .../BrowserLogsRunningSession.cs | 239 ++++++++++++++++-- .../BrowserLogsSessionManager.cs | 99 +++++++- .../IBrowserLogsSessionManager.cs | 2 +- ...TwoPassScanningGeneratedAspire.verified.go | 40 ++- ...oPassScanningGeneratedAspire.verified.java | 124 ++++++++- ...TwoPassScanningGeneratedAspire.verified.py | 46 ++-- ...TwoPassScanningGeneratedAspire.verified.rs | 40 ++- .../AtsTypeScriptCodeGeneratorTests.cs | 1 + ...TwoPassScanningGeneratedAspire.verified.ts | 55 ++-- .../BrowserLogsBuilderExtensionsTests.cs | 216 +++++++++++++++- .../BrowserLogsSessionManagerTests.cs | 53 ++++ 17 files changed, 979 insertions(+), 113 deletions(-) diff --git a/playground/BrowserTelemetry/BrowserTelemetry.AppHost/AppHost.cs b/playground/BrowserTelemetry/BrowserTelemetry.AppHost/AppHost.cs index 9a17d171ca3..c1e848f402a 100644 --- a/playground/BrowserTelemetry/BrowserTelemetry.AppHost/AppHost.cs +++ b/playground/BrowserTelemetry/BrowserTelemetry.AppHost/AppHost.cs @@ -2,11 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. var builder = DistributedApplication.CreateBuilder(args); -var browser = OperatingSystem.IsWindows() ? "msedge" : "chrome"; builder.AddProject("web") .WithExternalHttpEndpoints() - .WithBrowserLogs(browser); + .WithBrowserLogs(); #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging diff --git a/playground/BrowserTelemetry/BrowserTelemetry.AppHost/BrowserTelemetry.AppHost.csproj b/playground/BrowserTelemetry/BrowserTelemetry.AppHost/BrowserTelemetry.AppHost.csproj index 97a89fa2689..24805d703bf 100644 --- a/playground/BrowserTelemetry/BrowserTelemetry.AppHost/BrowserTelemetry.AppHost.csproj +++ b/playground/BrowserTelemetry/BrowserTelemetry.AppHost/BrowserTelemetry.AppHost.csproj @@ -6,6 +6,7 @@ enable enable true + 80abf314-d3e8-43b1-b0a9-e7cbd8d5c5d2 diff --git a/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs index f00b85dc6fe..6f72bb46a74 100644 --- a/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs +++ b/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs @@ -1,10 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Immutable; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Resources; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Configuration; namespace Aspire.Hosting; @@ -14,10 +16,15 @@ namespace Aspire.Hosting; 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 ProfileConfigurationKey = "Profile"; + internal const string ProfilePropertyName = "Profile"; 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 LastSessionPropertyName = "Last session"; @@ -30,8 +37,13 @@ public static class BrowserLogsBuilderExtensions /// The type of resource being configured. /// The resource builder. /// - /// The browser to launch. Defaults to "msedge". Supported values include logical browser names such as - /// "msedge" and "chrome", or an explicit browser executable path. + /// The browser to launch. When not specified, the tracked browser uses the configured value from + /// Aspire:Hosting:BrowserLogs and falls back to "chrome". Supported values include logical browser + /// names such as "msedge" and "chrome", or an explicit browser executable path. + /// + /// + /// Optional Chromium profile directory name to use. When not specified, the tracked browser uses the configured + /// value from Aspire:Hosting:BrowserLogs if present. /// /// A reference to the original for further chaining. /// @@ -49,6 +61,12 @@ public static class BrowserLogsBuilderExtensions /// 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. /// + /// + /// Browser and profile settings can also be supplied from configuration using + /// Aspire:Hosting:BrowserLogs:Browser and Aspire:Hosting:BrowserLogs:Profile, or scoped to a + /// specific resource with Aspire:Hosting:BrowserLogs:{ResourceName}:Browser and + /// Aspire:Hosting:BrowserLogs:{ResourceName}:Profile. Explicit method arguments override configuration. + /// /// /// /// Add tracked browser logs for a web front end: @@ -61,16 +79,18 @@ public static class BrowserLogsBuilderExtensions /// /// [AspireExport(Description = "Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.")] - public static IResourceBuilder WithBrowserLogs(this IResourceBuilder builder, string browser = "msedge") + public static IResourceBuilder WithBrowserLogs(this IResourceBuilder builder, string? browser = null, string? profile = null) where T : IResourceWithEndpoints { ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(browser); + ValidateOptionalArgument(browser, nameof(browser)); + ValidateOptionalArgument(profile, nameof(profile)); builder.ApplicationBuilder.Services.TryAddSingleton(); var parentResource = builder.Resource; - var browserLogsResource = new BrowserLogsResource($"{parentResource.Name}-browser-logs", parentResource, browser); + var settings = ResolveSettings(builder.ApplicationBuilder.Configuration, parentResource.Name, browser, profile); + var browserLogsResource = new BrowserLogsResource($"{parentResource.Name}-browser-logs", parentResource, settings, browser, profile); builder.ApplicationBuilder.AddResource(browserLogsResource) .WithParentRelationship(parentResource) @@ -81,14 +101,7 @@ public static IResourceBuilder WithBrowserLogs(this IResourceBuilder bu ResourceType = BrowserResourceType, CreationTimeStamp = DateTime.UtcNow, State = KnownResourceStates.NotStarted, - Properties = - [ - new ResourcePropertySnapshot(CustomResourceKnownProperties.Source, parentResource.Name), - new ResourcePropertySnapshot(BrowserPropertyName, browser), - new ResourcePropertySnapshot(ActiveSessionCountPropertyName, 0), - new ResourcePropertySnapshot(ActiveSessionsPropertyName, "None"), - new ResourcePropertySnapshot(TotalSessionsLaunchedPropertyName, 0) - ] + Properties = CreateInitialProperties(parentResource.Name, settings) }) .WithCommand( OpenTrackedBrowserCommandName, @@ -97,9 +110,11 @@ public static IResourceBuilder WithBrowserLogs(this IResourceBuilder bu { try { + var configuration = context.ServiceProvider.GetRequiredService(); + var currentSettings = browserLogsResource.ResolveCurrentSettings(configuration); var url = ResolveBrowserUrl(parentResource); var sessionManager = context.ServiceProvider.GetRequiredService(); - await sessionManager.StartSessionAsync(browserLogsResource, context.ResourceName, url, context.CancellationToken).ConfigureAwait(false); + await sessionManager.StartSessionAsync(browserLogsResource, currentSettings, context.ResourceName, url, context.CancellationToken).ConfigureAwait(false); return CommandResults.Success(); } catch (Exception ex) @@ -147,6 +162,26 @@ public static IResourceBuilder WithBrowserLogs(this IResourceBuilder bu Task RefreshBrowserLogsResourceAsync(ResourceNotificationService notifications) => notifications.PublishUpdateAsync(browserLogsResource, snapshot => snapshot); + static ImmutableArray CreateInitialProperties(string resourceName, BrowserLogsSettings settings) + { + List properties = + [ + new(CustomResourceKnownProperties.Source, resourceName), + new(BrowserPropertyName, settings.Browser), + new(ActiveSessionCountPropertyName, 0), + new(ActiveSessionsPropertyName, "None"), + new(BrowserSessionsPropertyName, "[]"), + new(TotalSessionsLaunchedPropertyName, 0) + ]; + + if (settings.Profile is not null) + { + properties.Insert(2, new ResourcePropertySnapshot(ProfilePropertyName, settings.Profile)); + } + + return [.. properties]; + } + static Uri ResolveBrowserUrl(T resource) { EndpointAnnotation? endpointAnnotation = null; @@ -169,5 +204,41 @@ static Uri ResolveBrowserUrl(T resource) return new Uri(endpointReference.Url, UriKind.Absolute); } + + static void ValidateOptionalArgument(string? value, string paramName) + { + if (value is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value, paramName); + } + } + } + + internal static BrowserLogsSettings ResolveSettings(IConfiguration configuration, string resourceName, string? browser, string? profile) + { + var browserLogsSection = configuration.GetSection(BrowserLogsConfigurationSectionName); + var resourceSection = browserLogsSection.GetSection(resourceName); + + var resolvedBrowser = browser + ?? resourceSection[BrowserConfigurationKey] + ?? browserLogsSection[BrowserConfigurationKey] + ?? GetDefaultBrowser(); + var resolvedProfile = profile + ?? resourceSection[ProfileConfigurationKey] + ?? browserLogsSection[ProfileConfigurationKey]; + + if (string.IsNullOrWhiteSpace(resolvedBrowser)) + { + throw new InvalidOperationException("Tracked browser configuration resolved an empty browser value."); + } + + if (resolvedProfile is not null && string.IsNullOrWhiteSpace(resolvedProfile)) + { + throw new InvalidOperationException("Tracked browser configuration resolved an empty profile value."); + } + + return new BrowserLogsSettings(resolvedBrowser, resolvedProfile); } + + private static string GetDefaultBrowser() => "chrome"; } diff --git a/src/Aspire.Hosting/BrowserLogsDevToolsConnection.cs b/src/Aspire.Hosting/BrowserLogsDevToolsConnection.cs index efc31eff0a0..a369da52ab4 100644 --- a/src/Aspire.Hosting/BrowserLogsDevToolsConnection.cs +++ b/src/Aspire.Hosting/BrowserLogsDevToolsConnection.cs @@ -55,6 +55,16 @@ public Task CreateTargetAsync(CancellationToken c cancellationToken); } + public Task GetTargetsAsync(CancellationToken cancellationToken) + { + return SendCommandAsync( + BrowserLogsProtocol.TargetGetTargetsMethod, + sessionId: null, + writeParameters: null, + BrowserLogsProtocol.ParseGetTargetsResponse, + cancellationToken); + } + public Task AttachToTargetAsync(string targetId, CancellationToken cancellationToken) { return SendCommandAsync( diff --git a/src/Aspire.Hosting/BrowserLogsProtocol.cs b/src/Aspire.Hosting/BrowserLogsProtocol.cs index 72e81e01b89..f85cf6becdb 100644 --- a/src/Aspire.Hosting/BrowserLogsProtocol.cs +++ b/src/Aspire.Hosting/BrowserLogsProtocol.cs @@ -41,6 +41,7 @@ internal static class BrowserLogsProtocol internal const string RuntimeExceptionThrownMethod = "Runtime.exceptionThrown"; internal const string TargetAttachToTargetMethod = "Target.attachToTarget"; internal const string TargetCreateTargetMethod = "Target.createTarget"; + internal const string TargetGetTargetsMethod = "Target.getTargets"; internal static BrowserLogsProtocolMessageHeader ParseMessageHeader(ReadOnlySpan framePayload) { @@ -158,6 +159,14 @@ internal static BrowserLogsAttachToTargetResult ParseAttachToTargetResponse(Read return envelope.Result ?? throw new InvalidOperationException("Tracked browser target attachment did not return a result payload."); } + internal static BrowserLogsGetTargetsResult ParseGetTargetsResponse(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsGetTargetsResponseEnvelope); + ThrowIfProtocolError(envelope.Error); + + return envelope.Result ?? throw new InvalidOperationException("Tracked browser target discovery did not return a result payload."); + } + internal static BrowserLogsCommandAck ParseCommandAckResponse(ReadOnlySpan framePayload) { var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsCommandAckResponseEnvelope); @@ -345,6 +354,39 @@ internal sealed class BrowserLogsCreateTargetResult public string? TargetId { get; init; } } +internal sealed class BrowserLogsGetTargetsResponseEnvelope +{ + [JsonPropertyName("error")] + public BrowserLogsProtocolError? Error { get; init; } + + [JsonPropertyName("id")] + public long Id { get; init; } + + [JsonPropertyName("result")] + public BrowserLogsGetTargetsResult? Result { get; init; } +} + +internal sealed class BrowserLogsGetTargetsResult +{ + [JsonPropertyName("targetInfos")] + public BrowserLogsTargetInfo[]? TargetInfos { get; init; } +} + +internal sealed class BrowserLogsTargetInfo +{ + [JsonPropertyName("attached")] + public bool? Attached { get; init; } + + [JsonPropertyName("targetId")] + public string? TargetId { get; init; } + + [JsonPropertyName("type")] + public string? Type { get; init; } + + [JsonPropertyName("url")] + public string? Url { get; init; } +} + internal sealed class BrowserLogsExceptionDetails : BrowserLogsSourceLocation { [JsonPropertyName("exception")] @@ -706,6 +748,7 @@ private static BrowserLogsProtocolValue ReadValue(ref Utf8JsonReader reader, Jso [JsonSerializable(typeof(BrowserLogsCommandAckResponseEnvelope))] [JsonSerializable(typeof(BrowserLogsConsoleApiCalledEnvelope))] [JsonSerializable(typeof(BrowserLogsCreateTargetResponseEnvelope))] +[JsonSerializable(typeof(BrowserLogsGetTargetsResponseEnvelope))] [JsonSerializable(typeof(BrowserLogsExceptionThrownEnvelope))] [JsonSerializable(typeof(BrowserLogsLoadingFailedEnvelope))] [JsonSerializable(typeof(BrowserLogsLoadingFinishedEnvelope))] diff --git a/src/Aspire.Hosting/BrowserLogsResource.cs b/src/Aspire.Hosting/BrowserLogsResource.cs index 3235f6e362e..1de6b16b1bf 100644 --- a/src/Aspire.Hosting/BrowserLogsResource.cs +++ b/src/Aspire.Hosting/BrowserLogsResource.cs @@ -2,13 +2,30 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Configuration; namespace Aspire.Hosting; -internal sealed class BrowserLogsResource(string name, IResourceWithEndpoints parentResource, string browser) +internal readonly record struct BrowserLogsSettings(string Browser, string? Profile); + +internal sealed class BrowserLogsResource( + string name, + IResourceWithEndpoints parentResource, + BrowserLogsSettings initialSettings, + string? browserOverride, + string? profileOverride) : Resource(name) { public IResourceWithEndpoints ParentResource { get; } = parentResource; - public string Browser { get; } = browser; + public string Browser { get; } = initialSettings.Browser; + + public string? Profile { get; } = initialSettings.Profile; + + public string? BrowserOverride { get; } = browserOverride; + + public string? ProfileOverride { get; } = profileOverride; + + public BrowserLogsSettings ResolveCurrentSettings(IConfiguration configuration) => + BrowserLogsBuilderExtensions.ResolveSettings(configuration, ParentResource.Name, BrowserOverride, ProfileOverride); } diff --git a/src/Aspire.Hosting/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogsRunningSession.cs index 48c81c321a4..34ce99125da 100644 --- a/src/Aspire.Hosting/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogsRunningSession.cs @@ -16,10 +16,14 @@ internal interface IBrowserLogsRunningSession string BrowserExecutable { get; } + Uri BrowserDebugEndpoint { get; } + int ProcessId { get; } DateTime StartedAt { get; } + string TargetId { get; } + Task StartCompletionObserver(Func onCompleted); Task StopAsync(CancellationToken cancellationToken); @@ -28,7 +32,7 @@ internal interface IBrowserLogsRunningSession internal interface IBrowserLogsRunningSessionFactory { Task StartSessionAsync( - BrowserLogsResource resource, + BrowserLogsSettings settings, string resourceName, Uri url, string sessionId, @@ -50,7 +54,7 @@ public BrowserLogsRunningSessionFactory(IFileSystemService fileSystemService, IL } public async Task StartSessionAsync( - BrowserLogsResource resource, + BrowserLogsSettings settings, string resourceName, Uri url, string sessionId, @@ -58,7 +62,7 @@ public async Task StartSessionAsync( CancellationToken cancellationToken) { return await BrowserLogsRunningSession.StartAsync( - resource, + settings, resourceName, sessionId, url, @@ -81,15 +85,15 @@ internal sealed class BrowserLogsRunningSession : IBrowserLogsRunningSession private readonly BrowserEventLogger _eventLogger; private readonly BrowserConnectionDiagnosticsLogger _connectionDiagnostics; + private readonly IFileSystemService _fileSystemService; private readonly ILogger _logger; - private readonly BrowserLogsResource _resource; private readonly ILogger _resourceLogger; private readonly string _resourceName; + private readonly BrowserLogsSettings _settings; private readonly string _sessionId; private readonly CancellationTokenSource _stopCts = new(); private readonly TimeProvider _timeProvider; private readonly Uri _url; - private readonly TempDirectory _userDataDirectory; private string? _browserExecutable; private Uri? _browserEndpoint; @@ -101,41 +105,46 @@ internal sealed class BrowserLogsRunningSession : IBrowserLogsRunningSession private int? _processId; private string? _targetId; private string? _targetSessionId; + private BrowserLogsUserDataDirectory? _userDataDirectory; private BrowserLogsRunningSession( - BrowserLogsResource resource, + BrowserLogsSettings settings, string resourceName, string sessionId, Uri url, - TempDirectory userDataDirectory, + IFileSystemService fileSystemService, ILogger resourceLogger, ILogger logger, TimeProvider timeProvider) { _eventLogger = new BrowserEventLogger(sessionId, resourceLogger); _connectionDiagnostics = new BrowserConnectionDiagnosticsLogger(sessionId, resourceLogger); + _fileSystemService = fileSystemService; _logger = logger; - _resource = resource; _resourceLogger = resourceLogger; _resourceName = resourceName; + _settings = settings; _sessionId = sessionId; _timeProvider = timeProvider; _url = url; - _userDataDirectory = userDataDirectory; } public string SessionId => _sessionId; public string BrowserExecutable => _browserExecutable ?? throw new InvalidOperationException("Browser executable is not available before the session starts."); + public Uri BrowserDebugEndpoint => _browserEndpoint ?? throw new InvalidOperationException("Browser debugging endpoint is not available before the session starts."); + public int ProcessId => _processId ?? throw new InvalidOperationException("Browser process has not started."); public DateTime StartedAt { get; private set; } + public string TargetId => _targetId ?? throw new InvalidOperationException("Browser target id is not available before the session starts."); + private Task Completion => _completion ?? throw new InvalidOperationException("Session has not been started."); public static async Task StartAsync( - BrowserLogsResource resource, + BrowserLogsSettings settings, string resourceName, string sessionId, Uri url, @@ -145,8 +154,7 @@ public static async Task StartAsync( TimeProvider timeProvider, CancellationToken cancellationToken) { - var userDataDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-browser-logs"); - var session = new BrowserLogsRunningSession(resource, resourceName, sessionId, url, userDataDirectory, resourceLogger, logger, timeProvider); + var session = new BrowserLogsRunningSession(settings, resourceName, sessionId, url, fileSystemService, resourceLogger, logger, timeProvider); try { @@ -208,15 +216,20 @@ public async Task StopAsync(CancellationToken cancellationToken) private async Task InitializeAsync(CancellationToken cancellationToken) { - _browserExecutable = ResolveBrowserExecutable(_resource.Browser); + _browserExecutable = ResolveBrowserExecutable(_settings.Browser); if (_browserExecutable is null) { - throw new InvalidOperationException($"Unable to locate browser '{_resource.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); + throw new InvalidOperationException($"Unable to locate browser '{_settings.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); } + _userDataDirectory = CreateUserDataDirectory(_settings.Browser, _browserExecutable, _settings.Profile); var devToolsActivePortFilePath = GetDevToolsActivePortFilePath(); await StartBrowserProcessAsync(cancellationToken).ConfigureAwait(false); _resourceLogger.LogInformation("[{SessionId}] Started tracked browser process '{BrowserExecutable}'.", _sessionId, _browserExecutable); + if (_settings.Profile is not null) + { + _resourceLogger.LogInformation("[{SessionId}] Using tracked browser profile '{Profile}'.", _sessionId, _settings.Profile); + } _resourceLogger.LogInformation("[{SessionId}] Waiting for tracked browser debug endpoint metadata in '{DevToolsActivePortFilePath}'.", _sessionId, devToolsActivePortFilePath); try @@ -272,16 +285,46 @@ private async Task StartBrowserProcessAsync(CancellationToken cancellationToken) private string BuildBrowserArguments() { - return BuildCommandLine( + var userDataDirectory = _userDataDirectory ?? throw new InvalidOperationException("Browser user data directory was not initialized."); + List arguments = [ - $"--user-data-dir={_userDataDirectory.Path}", + $"--user-data-dir={userDataDirectory.Path}", "--remote-debugging-port=0", "--no-first-run", "--no-default-browser-check", "--new-window", - "--allow-insecure-localhost", - "about:blank" - ]); + "--allow-insecure-localhost" + ]; + + if (_settings.Profile is not null) + { + arguments.Add($"--profile-directory={_settings.Profile}"); + } + + arguments.Add("about:blank"); + + return BuildCommandLine(arguments); + } + + private async Task GetOrCreateTrackedTargetAsync(CancellationToken cancellationToken) + { + var targets = await ExecuteConnectionStageAsync( + "Discovering the tracked browser target", + () => _connection!.GetTargetsAsync(cancellationToken)).ConfigureAwait(false); + + if (TrySelectTrackedTargetId(targets.TargetInfos) is { } targetId) + { + _resourceLogger.LogInformation("[{SessionId}] Reusing tracked browser target '{TargetId}'.", _sessionId, targetId); + return targetId; + } + + var createTargetResult = await ExecuteConnectionStageAsync( + "Creating the tracked browser target", + () => _connection!.CreateTargetAsync(cancellationToken)).ConfigureAwait(false); + targetId = createTargetResult.TargetId + ?? throw new InvalidOperationException("Browser target creation did not return a target id."); + _resourceLogger.LogInformation("[{SessionId}] Created tracked browser target '{TargetId}'.", _sessionId, targetId); + return targetId; } private async Task ConnectAsync(bool createTarget, CancellationToken cancellationToken) @@ -297,12 +340,7 @@ private async Task ConnectAsync(bool createTarget, CancellationToken cancellatio if (createTarget) { - var createTargetResult = await ExecuteConnectionStageAsync( - "Creating the tracked browser target", - () => _connection.CreateTargetAsync(cancellationToken)).ConfigureAwait(false); - _targetId = createTargetResult.TargetId - ?? throw new InvalidOperationException("Browser target creation did not return a target id."); - _resourceLogger.LogInformation("[{SessionId}] Created tracked browser target '{TargetId}'.", _sessionId, _targetId); + _targetId = await GetOrCreateTrackedTargetAsync(cancellationToken).ConfigureAwait(false); } if (_targetId is null) @@ -510,7 +548,7 @@ private async Task CleanupAsync() await DisposeConnectionAsync().ConfigureAwait(false); await DisposeBrowserProcessAsync().ConfigureAwait(false); _stopCts.Dispose(); - _userDataDirectory.Dispose(); + _userDataDirectory?.Dispose(); } private async Task DisposeBrowserProcessAsync() @@ -572,7 +610,32 @@ private async Task WaitForBrowserEndpointAsync(CancellationToken cancellati private string GetDevToolsActivePortFilePath() { - return Path.Combine(_userDataDirectory.Path, "DevToolsActivePort"); + var userDataDirectory = _userDataDirectory ?? throw new InvalidOperationException("Browser user data directory was not initialized."); + return Path.Combine(userDataDirectory.Path, "DevToolsActivePort"); + } + + private BrowserLogsUserDataDirectory CreateUserDataDirectory(string browser, string browserExecutable, string? profile) + { + if (profile is null) + { + return BrowserLogsUserDataDirectory.CreateTemporary(_fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-browser-logs")); + } + + var userDataDirectory = TryResolveBrowserUserDataDirectory(browser, browserExecutable) + ?? throw new InvalidOperationException($"Unable to resolve the user data directory for browser '{browser}'. Specify a known browser such as 'msedge' or 'chrome' when using a browser profile."); + + if (!Directory.Exists(userDataDirectory)) + { + throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found for browser '{browser}'."); + } + + var profileDirectory = Path.Combine(userDataDirectory, profile); + if (!Directory.Exists(profileDirectory)) + { + throw new InvalidOperationException($"Browser profile '{profile}' was not found under '{userDataDirectory}'."); + } + + return BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory); } private static string? ResolveBrowserExecutable(string browser) @@ -600,6 +663,45 @@ private string GetDevToolsActivePortFilePath() return PathLookupHelper.FindFullPathFromPath(browser); } + internal static string? TryResolveBrowserUserDataDirectory(string browser, string browserExecutable) + { + var browserKind = GetBrowserKind(browser, browserExecutable); + if (browserKind == BrowserKind.Unknown) + { + return null; + } + + if (OperatingSystem.IsMacOS()) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return browserKind switch + { + BrowserKind.Edge => Path.Combine(home, "Library", "Application Support", "Microsoft Edge"), + BrowserKind.Chrome => Path.Combine(home, "Library", "Application Support", "Google", "Chrome"), + _ => null + }; + } + + if (OperatingSystem.IsWindows()) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return browserKind switch + { + BrowserKind.Edge => Path.Combine(localAppData, "Microsoft", "Edge", "User Data"), + BrowserKind.Chrome => Path.Combine(localAppData, "Google", "Chrome", "User Data"), + _ => null + }; + } + + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return browserKind switch + { + BrowserKind.Edge => Path.Combine(homeDirectory, ".config", "microsoft-edge"), + BrowserKind.Chrome => Path.Combine(homeDirectory, ".config", "google-chrome"), + _ => null + }; + } + private static IEnumerable GetBrowserCandidates(string browser) { if (OperatingSystem.IsMacOS()) @@ -652,6 +754,63 @@ private static IEnumerable GetBrowserCandidates(string browser) }; } + private static BrowserKind GetBrowserKind(string browser, string browserExecutable) + { + if (MatchesBrowser(browser, browserExecutable, "msedge", "edge", "microsoft-edge")) + { + return BrowserKind.Edge; + } + + if (MatchesBrowser(browser, browserExecutable, "chrome", "google-chrome", "chromium", "chromium-browser")) + { + return BrowserKind.Chrome; + } + + return BrowserKind.Unknown; + } + + internal static string? TrySelectTrackedTargetId(IReadOnlyList? targetInfos) + { + if (targetInfos is null) + { + return null; + } + + var preferredTarget = targetInfos.FirstOrDefault(static targetInfo => + string.Equals(targetInfo.Type, "page", StringComparison.Ordinal) && + targetInfo.Attached != true && + string.Equals(targetInfo.Url, "about:blank", StringComparison.Ordinal)); + + if (!string.IsNullOrWhiteSpace(preferredTarget?.TargetId)) + { + return preferredTarget.TargetId; + } + + return targetInfos.FirstOrDefault(static targetInfo => + string.Equals(targetInfo.Type, "page", StringComparison.Ordinal) && + targetInfo.Attached != true && + !string.IsNullOrWhiteSpace(targetInfo.TargetId)) + ?.TargetId; + } + + private static bool MatchesBrowser(string browser, string browserExecutable, params string[] names) + { + var browserLower = browser.ToLowerInvariant(); + var executableLower = browserExecutable.ToLowerInvariant(); + + foreach (var name in names) + { + if (browserLower == name || + Path.GetFileNameWithoutExtension(browserLower) == name || + executableLower.Contains(name, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + private static string BuildCommandLine(IReadOnlyList arguments) { var builder = new StringBuilder(); @@ -725,6 +884,32 @@ private static void AppendCommandLineArgument(StringBuilder builder, string argu } private sealed record BrowserSessionResult(int ExitCode, Exception? Error); + + private enum BrowserKind + { + Unknown, + Edge, + Chrome + } + + private sealed class BrowserLogsUserDataDirectory : IDisposable + { + private readonly TempDirectory? _temporaryDirectory; + + private BrowserLogsUserDataDirectory(string path, TempDirectory? temporaryDirectory) + { + Path = path; + _temporaryDirectory = temporaryDirectory; + } + + public string Path { get; } + + public static BrowserLogsUserDataDirectory CreatePersistent(string path) => new(path, temporaryDirectory: null); + + public static BrowserLogsUserDataDirectory CreateTemporary(TempDirectory temporaryDirectory) => new(temporaryDirectory.Path, temporaryDirectory); + + public void Dispose() => _temporaryDirectory?.Dispose(); + } } internal static class BrowserLogsDebugEndpointParser diff --git a/src/Aspire.Hosting/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogsSessionManager.cs index fbb971d999d..254528c39dd 100644 --- a/src/Aspire.Hosting/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogsSessionManager.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Collections.Immutable; +using System.Text.Json; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; using HealthStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus; @@ -13,6 +14,8 @@ namespace Aspire.Hosting; internal sealed class BrowserLogsSessionManager : IBrowserLogsSessionManager, IAsyncDisposable { + private static readonly JsonSerializerOptions s_browserSessionPropertyJsonOptions = new(JsonSerializerDefaults.Web); + private readonly ResourceLoggerService _resourceLoggerService; private readonly ResourceNotificationService _resourceNotificationService; private readonly TimeProvider _timeProvider; @@ -50,9 +53,10 @@ internal BrowserLogsSessionManager( _sessionFactory = sessionFactory; } - public async Task StartSessionAsync(BrowserLogsResource resource, string resourceName, Uri url, CancellationToken cancellationToken) + public async Task StartSessionAsync(BrowserLogsResource resource, BrowserLogsSettings settings, string resourceName, Uri url, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(settings.Browser); ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); ArgumentNullException.ThrowIfNull(url); @@ -65,9 +69,11 @@ public async Task StartSessionAsync(BrowserLogsResource resource, string resourc var sessionId = $"session-{sessionSequence:0000}"; resourceState.LastSessionId = sessionId; resourceState.LastTargetUrl = url.ToString(); + resourceState.LastBrowser = settings.Browser; + resourceState.LastProfile = settings.Profile; var resourceLogger = _resourceLoggerService.GetLogger(resourceName); - resourceLogger.LogInformation("[{SessionId}] Opening tracked browser for '{Url}' using '{Browser}'.", sessionId, url, resource.Browser); + resourceLogger.LogInformation("[{SessionId}] Opening tracked browser for '{Url}' using '{Browser}'.", sessionId, url, settings.Browser); var launchStartedAt = _timeProvider.GetUtcNow().UtcDateTime; var pendingSession = new PendingBrowserSession(sessionId, launchStartedAt, url); @@ -86,7 +92,7 @@ await PublishResourceSnapshotAsync( try { session = await _sessionFactory.StartSessionAsync( - resource, + settings, resourceName, url, sessionId, @@ -119,9 +125,13 @@ await PublishResourceSnapshotAsync( resourceState.ActiveSessions[session.SessionId] = new ActiveBrowserSession( session.SessionId, + settings.Browser, session.BrowserExecutable, + settings.Profile, + session.BrowserDebugEndpoint, session.ProcessId, session.StartedAt, + session.TargetId, url, session, completionObserver); @@ -247,7 +257,7 @@ private Task PublishResourceSnapshotAsync( StopTimeStamp = resourceState.ActiveSessions.Count > 0 || pendingSession is not null ? null : stopTimeStamp, ExitCode = resourceState.ActiveSessions.Count > 0 || pendingSession is not null ? null : exitCode, State = new ResourceStateSnapshot(stateText, stateStyle), - Properties = snapshot.Properties.SetResourcePropertyRange(propertyUpdates), + Properties = UpdateProperties(snapshot.Properties, resourceState, propertyUpdates), HealthReports = healthReports }); } @@ -288,6 +298,7 @@ private static IEnumerable GetPropertyUpdates(Resource { yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, resourceState.ActiveSessions.Count); yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.ActiveSessionsPropertyName, FormatActiveSessions(resourceState.ActiveSessions.Values)); + yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.BrowserSessionsPropertyName, FormatBrowserSessions(resourceState.ActiveSessions.Values)); yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.TotalSessionsLaunchedPropertyName, resourceState.TotalSessionsLaunched); if (resourceState.LastSessionId is not null) @@ -306,6 +317,36 @@ private static IEnumerable GetPropertyUpdates(Resource } } + private static ImmutableArray UpdateProperties( + ImmutableArray properties, + ResourceSessionState resourceState, + IEnumerable propertyUpdates) + { + if (resourceState.LastBrowser is not null) + { + properties = properties.SetResourceProperty(BrowserLogsBuilderExtensions.BrowserPropertyName, resourceState.LastBrowser); + } + + properties = resourceState.LastProfile is not null + ? properties.SetResourceProperty(BrowserLogsBuilderExtensions.ProfilePropertyName, resourceState.LastProfile) + : RemoveProperty(properties, BrowserLogsBuilderExtensions.ProfilePropertyName); + + return properties.SetResourcePropertyRange(propertyUpdates); + } + + private static ImmutableArray RemoveProperty(ImmutableArray properties, string name) + { + for (var i = 0; i < properties.Length; i++) + { + if (string.Equals(properties[i].Name, name, StringComparisons.ResourcePropertyName)) + { + return properties.RemoveAt(i); + } + } + + return properties; + } + private static DateTime? GetStartTimeStamp(ResourceSessionState resourceState, DateTime? fallbackStartTimeStamp) { if (resourceState.ActiveSessions.Count > 0) @@ -328,6 +369,36 @@ private static string FormatActiveSessions(IEnumerable ses : "None"; } + private static string FormatBrowserSessions(IEnumerable sessions) + { + var activeSessions = sessions + .OrderBy(static session => session.SessionId, StringComparer.Ordinal) + .Select(static session => new BrowserSessionPropertyValue( + session.SessionId, + session.Browser, + session.BrowserExecutable, + session.ProcessId, + session.Profile, + session.StartedAt, + session.TargetUrl.ToString(), + session.BrowserDebugEndpoint.ToString(), + GetPageDebugEndpoint(session.BrowserDebugEndpoint, session.TargetId), + session.TargetId)) + .ToArray(); + + return JsonSerializer.Serialize(activeSessions, s_browserSessionPropertyJsonOptions); + } + + private static string GetPageDebugEndpoint(Uri browserDebugEndpoint, string targetId) + { + var builder = new UriBuilder(browserDebugEndpoint) + { + Path = $"/devtools/page/{targetId}" + }; + + return builder.Uri.ToString(); + } + private sealed class ResourceSessionState { public SemaphoreSlim Lock { get; } = new(1, 1); @@ -341,17 +412,37 @@ private sealed class ResourceSessionState public string? LastTargetUrl { get; set; } public string? LastBrowserExecutable { get; set; } + + public string? LastBrowser { get; set; } + + public string? LastProfile { get; set; } } private sealed record ActiveBrowserSession( string SessionId, + string Browser, string BrowserExecutable, + string? Profile, + Uri BrowserDebugEndpoint, int ProcessId, DateTime StartedAt, + string TargetId, Uri TargetUrl, IBrowserLogsRunningSession Session, Task CompletionObserver); + private sealed record BrowserSessionPropertyValue( + string SessionId, + string Browser, + string BrowserExecutable, + int ProcessId, + string? Profile, + DateTime StartedAt, + string TargetUrl, + string CdpEndpoint, + string PageCdpEndpoint, + string TargetId); + private sealed record PendingBrowserSession( string SessionId, DateTime StartedAt, diff --git a/src/Aspire.Hosting/IBrowserLogsSessionManager.cs b/src/Aspire.Hosting/IBrowserLogsSessionManager.cs index 95469aa7c3b..6015f98c3d3 100644 --- a/src/Aspire.Hosting/IBrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/IBrowserLogsSessionManager.cs @@ -5,5 +5,5 @@ namespace Aspire.Hosting; internal interface IBrowserLogsSessionManager { - Task StartSessionAsync(BrowserLogsResource resource, string resourceName, Uri url, CancellationToken cancellationToken); + Task StartSessionAsync(BrowserLogsResource resource, BrowserLogsSettings settings, string resourceName, Uri url, CancellationToken cancellationToken); } diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index bb4d4f33bc6..2c6841c87aa 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -656,13 +656,16 @@ func NewCSharpAppResource(handle *Handle, client *AspireClient) *CSharpAppResour } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *CSharpAppResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { +func (s *CSharpAppResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } if browser != nil { reqArgs["browser"] = SerializeValue(browser) } + if profile != nil { + reqArgs["profile"] = SerializeValue(profile) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -3994,13 +3997,16 @@ func NewContainerResource(handle *Handle, client *AspireClient) *ContainerResour } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *ContainerResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { +func (s *ContainerResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } if browser != nil { reqArgs["browser"] = SerializeValue(browser) } + if profile != nil { + reqArgs["profile"] = SerializeValue(profile) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -6224,13 +6230,16 @@ func NewDotnetToolResource(handle *Handle, client *AspireClient) *DotnetToolReso } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *DotnetToolResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { +func (s *DotnetToolResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } if browser != nil { reqArgs["browser"] = SerializeValue(browser) } + if profile != nil { + reqArgs["profile"] = SerializeValue(profile) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -8480,13 +8489,16 @@ func NewExecutableResource(handle *Handle, client *AspireClient) *ExecutableReso } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *ExecutableResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { +func (s *ExecutableResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } if browser != nil { reqArgs["browser"] = SerializeValue(browser) } + if profile != nil { + reqArgs["profile"] = SerializeValue(profile) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -13699,13 +13711,16 @@ func NewProjectResource(handle *Handle, client *AspireClient) *ProjectResource { } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *ProjectResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { +func (s *ProjectResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } if browser != nil { reqArgs["browser"] = SerializeValue(browser) } + if profile != nil { + reqArgs["profile"] = SerializeValue(profile) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -15768,13 +15783,16 @@ func NewTestDatabaseResource(handle *Handle, client *AspireClient) *TestDatabase } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *TestDatabaseResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { +func (s *TestDatabaseResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } if browser != nil { reqArgs["browser"] = SerializeValue(browser) } + if profile != nil { + reqArgs["profile"] = SerializeValue(profile) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -17574,13 +17592,16 @@ func NewTestRedisResource(handle *Handle, client *AspireClient) *TestRedisResour } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *TestRedisResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { +func (s *TestRedisResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } if browser != nil { reqArgs["browser"] = SerializeValue(browser) } + if profile != nil { + reqArgs["profile"] = SerializeValue(profile) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -19605,13 +19626,16 @@ func NewTestVaultResource(handle *Handle, client *AspireClient) *TestVaultResour } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *TestVaultResource) WithBrowserLogs(browser *string) (*IResourceWithEndpoints, error) { +func (s *TestVaultResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } if browser != nil { reqArgs["browser"] = SerializeValue(browser) } + if profile != nil { + reqArgs["profile"] = SerializeValue(profile) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index dc20e11a9f0..2db6ed984e2 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -1426,17 +1426,27 @@ public class CSharpAppResource extends ResourceBuilderBase { super(handle, client); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public CSharpAppResource withBrowserLogs(WithBrowserLogsOptions options) { + var browser = options == null ? null : options.getBrowser(); + var profile = options == null ? null : options.getProfile(); + return withBrowserLogsImpl(browser, profile); + } + public CSharpAppResource withBrowserLogs() { return withBrowserLogs(null); } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - public CSharpAppResource withBrowserLogs(String browser) { + private CSharpAppResource withBrowserLogsImpl(String browser, String profile) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { reqArgs.put("browser", AspireClient.serializeValue(browser)); } + if (profile != null) { + reqArgs.put("profile", AspireClient.serializeValue(profile)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -5163,17 +5173,27 @@ public class ContainerResource extends ResourceBuilderBase { super(handle, client); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public ContainerResource withBrowserLogs(WithBrowserLogsOptions options) { + var browser = options == null ? null : options.getBrowser(); + var profile = options == null ? null : options.getProfile(); + return withBrowserLogsImpl(browser, profile); + } + public ContainerResource withBrowserLogs() { return withBrowserLogs(null); } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - public ContainerResource withBrowserLogs(String browser) { + private ContainerResource withBrowserLogsImpl(String browser, String profile) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { reqArgs.put("browser", AspireClient.serializeValue(browser)); } + if (profile != null) { + reqArgs.put("profile", AspireClient.serializeValue(profile)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -7485,17 +7505,27 @@ public class DotnetToolResource extends ResourceBuilderBase { super(handle, client); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public DotnetToolResource withBrowserLogs(WithBrowserLogsOptions options) { + var browser = options == null ? null : options.getBrowser(); + var profile = options == null ? null : options.getProfile(); + return withBrowserLogsImpl(browser, profile); + } + public DotnetToolResource withBrowserLogs() { return withBrowserLogs(null); } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - public DotnetToolResource withBrowserLogs(String browser) { + private DotnetToolResource withBrowserLogsImpl(String browser, String profile) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { reqArgs.put("browser", AspireClient.serializeValue(browser)); } + if (profile != null) { + reqArgs.put("profile", AspireClient.serializeValue(profile)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -9586,17 +9616,27 @@ public class ExecutableResource extends ResourceBuilderBase { super(handle, client); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public ExecutableResource withBrowserLogs(WithBrowserLogsOptions options) { + var browser = options == null ? null : options.getBrowser(); + var profile = options == null ? null : options.getProfile(); + return withBrowserLogsImpl(browser, profile); + } + public ExecutableResource withBrowserLogs() { return withBrowserLogs(null); } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - public ExecutableResource withBrowserLogs(String browser) { + private ExecutableResource withBrowserLogsImpl(String browser, String profile) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { reqArgs.put("browser", AspireClient.serializeValue(browser)); } + if (profile != null) { + reqArgs.put("profile", AspireClient.serializeValue(profile)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -15085,17 +15125,27 @@ public class ProjectResource extends ResourceBuilderBase { super(handle, client); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public ProjectResource withBrowserLogs(WithBrowserLogsOptions options) { + var browser = options == null ? null : options.getBrowser(); + var profile = options == null ? null : options.getProfile(); + return withBrowserLogsImpl(browser, profile); + } + public ProjectResource withBrowserLogs() { return withBrowserLogs(null); } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - public ProjectResource withBrowserLogs(String browser) { + private ProjectResource withBrowserLogsImpl(String browser, String profile) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { reqArgs.put("browser", AspireClient.serializeValue(browser)); } + if (profile != null) { + reqArgs.put("profile", AspireClient.serializeValue(profile)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -17507,17 +17557,27 @@ public class TestDatabaseResource extends ResourceBuilderBase { super(handle, client); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public TestDatabaseResource withBrowserLogs(WithBrowserLogsOptions options) { + var browser = options == null ? null : options.getBrowser(); + var profile = options == null ? null : options.getProfile(); + return withBrowserLogsImpl(browser, profile); + } + public TestDatabaseResource withBrowserLogs() { return withBrowserLogs(null); } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - public TestDatabaseResource withBrowserLogs(String browser) { + private TestDatabaseResource withBrowserLogsImpl(String browser, String profile) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { reqArgs.put("browser", AspireClient.serializeValue(browser)); } + if (profile != null) { + reqArgs.put("profile", AspireClient.serializeValue(profile)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -19416,17 +19476,27 @@ public class TestRedisResource extends ResourceBuilderBase { super(handle, client); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public TestRedisResource withBrowserLogs(WithBrowserLogsOptions options) { + var browser = options == null ? null : options.getBrowser(); + var profile = options == null ? null : options.getProfile(); + return withBrowserLogsImpl(browser, profile); + } + public TestRedisResource withBrowserLogs() { return withBrowserLogs(null); } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - public TestRedisResource withBrowserLogs(String browser) { + private TestRedisResource withBrowserLogsImpl(String browser, String profile) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { reqArgs.put("browser", AspireClient.serializeValue(browser)); } + if (profile != null) { + reqArgs.put("profile", AspireClient.serializeValue(profile)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -21478,17 +21548,27 @@ public class TestVaultResource extends ResourceBuilderBase { super(handle, client); } + /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ + public TestVaultResource withBrowserLogs(WithBrowserLogsOptions options) { + var browser = options == null ? null : options.getBrowser(); + var profile = options == null ? null : options.getProfile(); + return withBrowserLogsImpl(browser, profile); + } + public TestVaultResource withBrowserLogs() { return withBrowserLogs(null); } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - public TestVaultResource withBrowserLogs(String browser) { + private TestVaultResource withBrowserLogsImpl(String browser, String profile) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { reqArgs.put("browser", AspireClient.serializeValue(browser)); } + if (profile != null) { + reqArgs.put("profile", AspireClient.serializeValue(profile)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -23338,6 +23418,33 @@ public interface WireValueEnum { String getValue(); } +// ===== WithBrowserLogsOptions.java ===== +// WithBrowserLogsOptions.java - GENERATED CODE - DO NOT EDIT + +package aspire; + +import java.util.*; +import java.util.function.*; + +/** Options for WithBrowserLogs. */ +public final class WithBrowserLogsOptions { + private String browser; + private String profile; + + public String getBrowser() { return browser; } + public WithBrowserLogsOptions browser(String value) { + this.browser = value; + return this; + } + + public String getProfile() { return profile; } + public WithBrowserLogsOptions profile(String value) { + this.profile = value; + return this; + } + +} + // ===== WithContainerCertificatePathsOptions.java ===== // WithContainerCertificatePathsOptions.java - GENERATED CODE - DO NOT EDIT @@ -24159,6 +24266,7 @@ public WithVolumeOptions isReadOnly(Boolean value) { .modules/UrlDisplayLocation.java .modules/WaitBehavior.java .modules/WireValueEnum.java +.modules/WithBrowserLogsOptions.java .modules/WithContainerCertificatePathsOptions.java .modules/WithDataVolumeOptions.java .modules/WithDockerfileBaseImageOptions.java diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index fef3bb63f30..fe855e48b82 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1577,6 +1577,11 @@ class MergeRouteParameters(typing.TypedDict, total=False): middleware: str +class BrowserLogsParameters(typing.TypedDict, total=False): + browser: str + profile: str + + class BindMountParameters(typing.TypedDict, total=False): source: typing.Required[str] target: typing.Required[str] @@ -5830,7 +5835,7 @@ class AbstractResourceWithEndpoints(AbstractResource): """Abstract base class for AbstractResourceWithEndpoints interface.""" @abc.abstractmethod - def with_browser_logs(self, *, browser: str = "msedge") -> typing.Self: + def with_browser_logs(self, *, browser: str | None = None, profile: str | None = None) -> typing.Self: """Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.""" @abc.abstractmethod @@ -7150,7 +7155,7 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack class ContainerResourceKwargs(_BaseResourceKwargs, total=False): """ContainerResource options.""" - browser_logs: str | typing.Literal[True] + browser_logs: BrowserLogsParameters | typing.Literal[True] bind_mount: tuple[str, str] | BindMountParameters entrypoint: str image_tag: str @@ -7218,11 +7223,13 @@ class ContainerResource(_BaseResource, AbstractResourceWithEnvironment, Abstract def __repr__(self) -> str: return "ContainerResource(handle={self._handle.handle_id})" - def with_browser_logs(self, *, browser: str = "msedge") -> typing.Self: + def with_browser_logs(self, *, browser: str | None = None, profile: str | None = None) -> typing.Self: """Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} if browser is not None: rpc_args['browser'] = browser + if profile is not None: + rpc_args['profile'] = profile result = self._client.invoke_capability( 'Aspire.Hosting/withBrowserLogs', rpc_args, @@ -8013,15 +8020,16 @@ def with_env_vars(self, vars: typing.Mapping[str, str]) -> typing.Self: def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack[ContainerResourceKwargs]) -> None: if _browser_logs := kwargs.pop("browser_logs", None): - if _validate_type(_browser_logs, str): + if _validate_dict_types(_browser_logs, BrowserLogsParameters): rpc_args: dict[str, typing.Any] = {"builder": handle} - rpc_args["browser"] = typing.cast(str, _browser_logs) + rpc_args["browser"] = typing.cast(BrowserLogsParameters, _browser_logs).get("browser") + rpc_args["profile"] = typing.cast(BrowserLogsParameters, _browser_logs).get("profile") handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) elif _browser_logs is True: rpc_args: dict[str, typing.Any] = {"builder": handle} handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) else: - raise TypeError("Invalid type for option 'browser_logs'. Expected: str or Literal[True]") + raise TypeError("Invalid type for option 'browser_logs'. Expected: BrowserLogsParameters or Literal[True]") if _bind_mount := kwargs.pop("bind_mount", None): if _validate_tuple_types(_bind_mount, (str, str)): rpc_args: dict[str, typing.Any] = {"builder": handle} @@ -8586,7 +8594,7 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack class ProjectResourceKwargs(_BaseResourceKwargs, total=False): """ProjectResource options.""" - browser_logs: str | typing.Literal[True] + browser_logs: BrowserLogsParameters | typing.Literal[True] mcp_server: McpServerParameters | typing.Literal[True] otlp_exporter: OtlpProtocol | typing.Literal[True] replicas: int @@ -8638,11 +8646,13 @@ class ProjectResource(_BaseResource, AbstractResourceWithEnvironment, AbstractRe def __repr__(self) -> str: return "ProjectResource(handle={self._handle.handle_id})" - def with_browser_logs(self, *, browser: str = "msedge") -> typing.Self: + def with_browser_logs(self, *, browser: str | None = None, profile: str | None = None) -> typing.Self: """Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} if browser is not None: rpc_args['browser'] = browser + if profile is not None: + rpc_args['profile'] = profile result = self._client.invoke_capability( 'Aspire.Hosting/withBrowserLogs', rpc_args, @@ -9237,15 +9247,16 @@ def with_env_vars(self, vars: typing.Mapping[str, str]) -> typing.Self: def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack[ProjectResourceKwargs]) -> None: if _browser_logs := kwargs.pop("browser_logs", None): - if _validate_type(_browser_logs, str): + if _validate_dict_types(_browser_logs, BrowserLogsParameters): rpc_args: dict[str, typing.Any] = {"builder": handle} - rpc_args["browser"] = typing.cast(str, _browser_logs) + rpc_args["browser"] = typing.cast(BrowserLogsParameters, _browser_logs).get("browser") + rpc_args["profile"] = typing.cast(BrowserLogsParameters, _browser_logs).get("profile") handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) elif _browser_logs is True: rpc_args: dict[str, typing.Any] = {"builder": handle} handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) else: - raise TypeError("Invalid type for option 'browser_logs'. Expected: str or Literal[True]") + raise TypeError("Invalid type for option 'browser_logs'. Expected: BrowserLogsParameters or Literal[True]") if _mcp_server := kwargs.pop("mcp_server", None): if _validate_dict_types(_mcp_server, McpServerParameters): rpc_args: dict[str, typing.Any] = {"builder": handle} @@ -9679,7 +9690,7 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack class ExecutableResourceKwargs(_BaseResourceKwargs, total=False): """ExecutableResource options.""" - browser_logs: str | typing.Literal[True] + browser_logs: BrowserLogsParameters | typing.Literal[True] publish_as_docker_file: typing.Callable[[ContainerResource], None] | typing.Literal[True] executable_command: str working_dir: str @@ -9730,11 +9741,13 @@ class ExecutableResource(_BaseResource, AbstractResourceWithEnvironment, Abstrac def __repr__(self) -> str: return "ExecutableResource(handle={self._handle.handle_id})" - def with_browser_logs(self, *, browser: str = "msedge") -> typing.Self: + def with_browser_logs(self, *, browser: str | None = None, profile: str | None = None) -> typing.Self: """Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} if browser is not None: rpc_args['browser'] = browser + if profile is not None: + rpc_args['profile'] = profile result = self._client.invoke_capability( 'Aspire.Hosting/withBrowserLogs', rpc_args, @@ -10319,15 +10332,16 @@ def with_env_vars(self, vars: typing.Mapping[str, str]) -> typing.Self: def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack[ExecutableResourceKwargs]) -> None: if _browser_logs := kwargs.pop("browser_logs", None): - if _validate_type(_browser_logs, str): + if _validate_dict_types(_browser_logs, BrowserLogsParameters): rpc_args: dict[str, typing.Any] = {"builder": handle} - rpc_args["browser"] = typing.cast(str, _browser_logs) + rpc_args["browser"] = typing.cast(BrowserLogsParameters, _browser_logs).get("browser") + rpc_args["profile"] = typing.cast(BrowserLogsParameters, _browser_logs).get("profile") handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) elif _browser_logs is True: rpc_args: dict[str, typing.Any] = {"builder": handle} handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) else: - raise TypeError("Invalid type for option 'browser_logs'. Expected: str or Literal[True]") + raise TypeError("Invalid type for option 'browser_logs'. Expected: BrowserLogsParameters or Literal[True]") if _publish_as_docker_file := kwargs.pop("publish_as_docker_file", None): if _validate_type(_publish_as_docker_file, typing.Callable[[ContainerResource], None]): rpc_args: dict[str, typing.Any] = {"builder": handle} diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index ee88970f5e3..ea6cd9658af 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -1071,12 +1071,15 @@ impl CSharpAppResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = profile { + args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -3845,12 +3848,15 @@ impl ContainerResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = profile { + args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -5768,12 +5774,15 @@ impl DotnetToolResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = profile { + args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -7595,12 +7604,15 @@ impl ExecutableResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = profile { + args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -12358,12 +12370,15 @@ impl ProjectResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = profile { + args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -14160,12 +14175,15 @@ impl TestDatabaseResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = profile { + args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -15615,12 +15633,15 @@ impl TestRedisResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = profile { + args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -17243,12 +17264,15 @@ impl TestVaultResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { args.insert("browser".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = profile { + args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs index d15c324c2fa..ff1fefb6770 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs @@ -389,6 +389,7 @@ public void Scanner_HostingAssembly_WithBrowserLogsCapability() Assert.Equal("withBrowserLogs", withBrowserLogs.MethodName); Assert.Equal("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", withBrowserLogs.TargetTypeId); Assert.Contains(withBrowserLogs.Parameters, p => p.Name == "browser" && p.Type?.TypeId == "string" && p.IsOptional); + Assert.Contains(withBrowserLogs.Parameters, p => p.Name == "profile" && p.Type?.TypeId == "string" && p.IsOptional); Assert.True(withBrowserLogs.ReturnsBuilder); } diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index cf7048d7c60..5118d1a60c4 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -744,6 +744,7 @@ export interface WithBindMountOptions { export interface WithBrowserLogsOptions { browser?: string; + profile?: string; } export interface WithCommandOptions { @@ -10239,9 +10240,10 @@ class ContainerResourceImpl extends ResourceBuilderBase } /** @internal */ - private async _withBrowserLogsInternal(browser?: string): Promise { + private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; + if (profile !== undefined) rpcArgs.profile = profile; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -10252,7 +10254,8 @@ class ContainerResourceImpl extends ResourceBuilderBase /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ withBrowserLogs(options?: WithBrowserLogsOptions): ContainerResourcePromise { const browser = options?.browser; - return new ContainerResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + const profile = options?.profile; + return new ContainerResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile), this._client); } /** @internal */ @@ -12965,9 +12968,10 @@ class CSharpAppResourceImpl extends ResourceBuilderBase } /** @internal */ - private async _withBrowserLogsInternal(browser?: string): Promise { + private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; + if (profile !== undefined) rpcArgs.profile = profile; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -12978,7 +12982,8 @@ class CSharpAppResourceImpl extends ResourceBuilderBase /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ withBrowserLogs(options?: WithBrowserLogsOptions): CSharpAppResourcePromise { const browser = options?.browser; - return new CSharpAppResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + const profile = options?.profile; + return new CSharpAppResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile), this._client); } /** @internal */ @@ -15364,9 +15369,10 @@ class DotnetToolResourceImpl extends ResourceBuilderBase { + private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; + if (profile !== undefined) rpcArgs.profile = profile; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -15377,7 +15383,8 @@ class DotnetToolResourceImpl extends ResourceBuilderBase { + private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; + if (profile !== undefined) rpcArgs.profile = profile; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -17881,7 +17889,8 @@ class ExecutableResourceImpl extends ResourceBuilderBase imp } /** @internal */ - private async _withBrowserLogsInternal(browser?: string): Promise { + private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; + if (profile !== undefined) rpcArgs.profile = profile; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -22599,7 +22609,8 @@ class ProjectResourceImpl extends ResourceBuilderBase imp /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ withBrowserLogs(options?: WithBrowserLogsOptions): ProjectResourcePromise { const browser = options?.browser; - return new ProjectResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + const profile = options?.profile; + return new ProjectResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile), this._client); } /** @internal */ @@ -25005,9 +25016,10 @@ class TestDatabaseResourceImpl extends ResourceBuilderBase { + private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; + if (profile !== undefined) rpcArgs.profile = profile; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -25018,7 +25030,8 @@ class TestDatabaseResourceImpl extends ResourceBuilderBase } /** @internal */ - private async _withBrowserLogsInternal(browser?: string): Promise { + private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; + if (profile !== undefined) rpcArgs.profile = profile; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -27808,7 +27822,8 @@ class TestRedisResourceImpl extends ResourceBuilderBase /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ withBrowserLogs(options?: WithBrowserLogsOptions): TestRedisResourcePromise { const browser = options?.browser; - return new TestRedisResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + const profile = options?.profile; + return new TestRedisResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile), this._client); } /** @internal */ @@ -30863,9 +30878,10 @@ class TestVaultResourceImpl extends ResourceBuilderBase } /** @internal */ - private async _withBrowserLogsInternal(browser?: string): Promise { + private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; + if (profile !== undefined) rpcArgs.profile = profile; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -30876,7 +30892,8 @@ class TestVaultResourceImpl extends ResourceBuilderBase /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ withBrowserLogs(options?: WithBrowserLogsOptions): TestVaultResourcePromise { const browser = options?.browser; - return new TestVaultResourcePromiseImpl(this._withBrowserLogsInternal(browser), this._client); + const profile = options?.profile; + return new TestVaultResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile), this._client); } /** @internal */ @@ -35116,9 +35133,10 @@ class ResourceWithEndpointsImpl extends ResourceBuilderBase { + private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; + if (profile !== undefined) rpcArgs.profile = profile; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -35129,7 +35147,8 @@ class ResourceWithEndpointsImpl extends ResourceBuilderBase(out var relationships)); var parentRelationship = Assert.Single(relationships, relationship => relationship.Type == "Parent"); @@ -54,12 +56,96 @@ public void WithBrowserLogs_CreatesChildResource() Assert.NotNull(snapshot.CreationTimeStamp); Assert.Contains(snapshot.Properties, property => property.Name == CustomResourceKnownProperties.Source && Equals(property.Value, "web")); Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.BrowserPropertyName && Equals(property.Value, "chrome")); + Assert.DoesNotContain(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.ProfilePropertyName); Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName && Equals(property.Value, 0)); Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.ActiveSessionsPropertyName && Equals(property.Value, "None")); + Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.BrowserSessionsPropertyName && Equals(property.Value, "[]")); Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.TotalSessionsLaunchedPropertyName && Equals(property.Value, 0)); Assert.Empty(snapshot.HealthReports); } + [Fact] + public void WithBrowserLogs_UsesResourceSpecificConfigurationWhenArgumentsAreOmitted() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = "msedge"; + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.ProfileConfigurationKey}"] = "Default"; + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:web:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = "chrome"; + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:web:{BrowserLogsBuilderExtensions.ProfileConfigurationKey}"] = "Profile 1"; + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(); + + using var app = builder.Build(); + var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + + Assert.Equal("chrome", browserLogsResource.Browser); + Assert.Equal("Profile 1", browserLogsResource.Profile); + + var snapshot = browserLogsResource.Annotations.OfType().Single().InitialSnapshot; + Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.BrowserPropertyName && Equals(property.Value, "chrome")); + Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.ProfilePropertyName && Equals(property.Value, "Profile 1")); + } + + [Fact] + public void WithBrowserLogs_UsesPlatformDefaultBrowserWhenConfigurationIsMissing() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(); + + using var app = builder.Build(); + var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + + Assert.Equal("chrome", browserLogsResource.Browser); + Assert.Null(browserLogsResource.Profile); + } + + [Fact] + public void WithBrowserLogs_ExplicitArgumentsOverrideConfiguration() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = "chrome"; + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.ProfileConfigurationKey}"] = "Profile 1"; + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(browser: "msedge", profile: "Default"); + + using var app = builder.Build(); + var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + + Assert.Equal("msedge", browserLogsResource.Browser); + Assert.Equal("Default", browserLogsResource.Profile); + } + [Fact] public async Task WithBrowserLogs_CommandStartsTrackedSession() { @@ -90,9 +176,67 @@ public async Task WithBrowserLogs_CommandStartsTrackedSession() var call = Assert.Single(sessionManager.Calls); Assert.Same(browserLogsResource, call.Resource); Assert.Equal(browserLogsResource.Name, call.ResourceName); + Assert.Equal("chrome", call.Settings.Browser); + Assert.Null(call.Settings.Profile); Assert.Equal(new Uri("http://localhost:8080", UriKind.Absolute), call.Url); } + [Fact] + public async Task WithBrowserLogs_CommandUsesLatestConfiguredSettingsAndRefreshesProperties() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var sessionFactory = new FakeBrowserLogsRunningSessionFactory(); + + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = "chrome"; + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.ProfileConfigurationKey}"] = "Default"; + + builder.Services.AddSingleton(sp => + new BrowserLogsSessionManager( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sessionFactory)); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(); + + using var app = builder.Build(); + await app.StartAsync(); + + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = "msedge"; + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.ProfileConfigurationKey}"] = null; + + var browserLogsResource = app.Services.GetRequiredService().Resources.OfType().Single(); + var result = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + + Assert.True(result.Success); + + var launchSettings = Assert.Single(sessionFactory.Settings); + Assert.Equal("msedge", launchSettings.Browser); + Assert.Null(launchSettings.Profile); + + var runningEvent = await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserPropertyName, "msedge") && + !resourceEvent.Snapshot.Properties.Any(property => property.Name == BrowserLogsBuilderExtensions.ProfilePropertyName)).DefaultTimeout(); + + var session = Assert.Single(GetBrowserSessions(runningEvent.Snapshot)); + Assert.Equal("msedge", session.Browser); + Assert.Null(session.Profile); + } + [Fact] public async Task WithBrowserLogs_CommandFailsWhenEndpointIsMissing() { @@ -191,7 +335,7 @@ public async Task WithBrowserLogs_CommandTracksMultipleSessionsWithUniqueIds() Properties = [] }); - web.WithBrowserLogs(browser: "chrome"); + web.WithBrowserLogs(browser: "chrome", profile: "Default"); using var app = builder.Build(); await app.StartAsync(); @@ -216,6 +360,20 @@ public async Task WithBrowserLogs_CommandTracksMultipleSessionsWithUniqueIds() resourceEvent.Snapshot.HealthReports.Any(report => report.Name == "session-0001" && report.Status == HealthStatus.Healthy)).DefaultTimeout(); Assert.Single(firstRunningEvent.Snapshot.HealthReports); + Assert.Collection( + GetBrowserSessions(firstRunningEvent.Snapshot), + session => + { + Assert.Equal("session-0001", session.SessionId); + Assert.Equal("chrome", session.Browser); + Assert.Equal("/fake/browser-1", session.BrowserExecutable); + Assert.Equal(1001, session.ProcessId); + Assert.Equal("Default", session.Profile); + Assert.Equal("http://localhost:8080/", session.TargetUrl); + Assert.Equal("ws://127.0.0.1:9001/devtools/browser/browser-1", session.CdpEndpoint); + Assert.Equal("ws://127.0.0.1:9001/devtools/page/target-1", session.PageCdpEndpoint); + Assert.Equal("target-1", session.TargetId); + }); Assert.Equal(0, firstSession.StopCallCount); var secondResult = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); @@ -238,6 +396,21 @@ public async Task WithBrowserLogs_CommandTracksMultipleSessionsWithUniqueIds() resourceEvent.Snapshot.HealthReports.Any(report => report.Name == "session-0002" && report.Status == HealthStatus.Healthy)).DefaultTimeout(); Assert.Equal(2, secondRunningEvent.Snapshot.HealthReports.Length); + Assert.Collection( + GetBrowserSessions(secondRunningEvent.Snapshot), + session => + { + Assert.Equal("session-0001", session.SessionId); + Assert.Equal("ws://127.0.0.1:9001/devtools/browser/browser-1", session.CdpEndpoint); + Assert.Equal("ws://127.0.0.1:9001/devtools/page/target-1", session.PageCdpEndpoint); + }, + session => + { + Assert.Equal("session-0002", session.SessionId); + Assert.Equal("ws://127.0.0.1:9002/devtools/browser/browser-2", session.CdpEndpoint); + Assert.Equal("ws://127.0.0.1:9002/devtools/page/target-2", session.PageCdpEndpoint); + Assert.Equal("target-2", session.TargetId); + }); Assert.Equal(0, firstSession.StopCallCount); await firstSession.CompleteAsync(exitCode: 0); @@ -252,6 +425,9 @@ public async Task WithBrowserLogs_CommandTracksMultipleSessionsWithUniqueIds() resourceEvent.Snapshot.HealthReports[0].Name == "session-0002").DefaultTimeout(); Assert.Equal("session-0002", firstCompletedEvent.Snapshot.HealthReports[0].Name); + Assert.Collection( + GetBrowserSessions(firstCompletedEvent.Snapshot), + session => Assert.Equal("session-0002", session.SessionId)); await secondSession.CompleteAsync(exitCode: 0); @@ -265,6 +441,7 @@ public async Task WithBrowserLogs_CommandTracksMultipleSessionsWithUniqueIds() resourceEvent.Snapshot.HealthReports.IsEmpty).DefaultTimeout(); Assert.Equal(KnownResourceStates.Finished, allCompletedEvent.Snapshot.State?.Text); + Assert.Empty(GetBrowserSessions(allCompletedEvent.Snapshot)); } [Fact] @@ -429,6 +606,14 @@ private sealed class TestHttpResource(string name) : Resource(name), IResourceWi private static bool HasProperty(CustomResourceSnapshot snapshot, string name, object expectedValue) => snapshot.Properties.Any(property => property.Name == name && Equals(property.Value, expectedValue)); + private static IReadOnlyList GetBrowserSessions(CustomResourceSnapshot snapshot) + { + var property = snapshot.Properties.Single(property => property.Name == BrowserLogsBuilderExtensions.BrowserSessionsPropertyName); + var value = Assert.IsType(property.Value); + return JsonSerializer.Deserialize>(value, BrowserSessionPropertyJsonOptions) + ?? throw new InvalidOperationException("Expected browser session property JSON."); + } + private static BrowserLogsProtocolEvent ParseProtocolEvent(string json) { var payload = Encoding.UTF8.GetBytes(json); @@ -463,27 +648,30 @@ private sealed class FakeBrowserLogsSessionManager : IBrowserLogsSessionManager { public List Calls { get; } = []; - public Task StartSessionAsync(BrowserLogsResource resource, string resourceName, Uri url, CancellationToken cancellationToken) + public Task StartSessionAsync(BrowserLogsResource resource, BrowserLogsSettings settings, string resourceName, Uri url, CancellationToken cancellationToken) { - Calls.Add(new SessionStartCall(resource, resourceName, url)); + Calls.Add(new SessionStartCall(resource, settings, resourceName, url)); return Task.CompletedTask; } } - private sealed record SessionStartCall(BrowserLogsResource Resource, string ResourceName, Uri Url); + private sealed record SessionStartCall(BrowserLogsResource Resource, BrowserLogsSettings Settings, string ResourceName, Uri Url); private sealed class FakeBrowserLogsRunningSessionFactory : IBrowserLogsRunningSessionFactory { public List Sessions { get; } = []; + public List Settings { get; } = []; public Task StartSessionAsync( - BrowserLogsResource resource, + BrowserLogsSettings settings, string resourceName, Uri url, string sessionId, ILogger resourceLogger, CancellationToken cancellationToken) { + Settings.Add(settings); + var session = new FakeBrowserLogsRunningSession( sessionId, $"/fake/browser-{Sessions.Count + 1}", @@ -510,10 +698,14 @@ private sealed class FakeBrowserLogsRunningSession( public string BrowserExecutable { get; } = browserExecutable; + public Uri BrowserDebugEndpoint { get; } = new($"ws://127.0.0.1:{processId + 8000}/devtools/browser/browser-{processId - 1000}"); + public int ProcessId { get; } = processId; public DateTime StartedAt { get; } = startedAt; + public string TargetId { get; } = $"target-{processId - 1000}"; + public int StopCallCount { get; private set; } public Task CompletionObserverStarted => CompletionObserverStartedSource.Task; @@ -565,4 +757,18 @@ private async Task ObserveCompletionAsync(Func onComplete return source; } } + + private static JsonSerializerOptions BrowserSessionPropertyJsonOptions { get; } = new(JsonSerializerDefaults.Web); + + private sealed record BrowserSessionPropertyValue( + string SessionId, + string Browser, + string BrowserExecutable, + int ProcessId, + string? Profile, + DateTime StartedAt, + string TargetUrl, + string CdpEndpoint, + string PageCdpEndpoint, + string TargetId); } diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 8b3683fec3d..16f83eb8016 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -33,6 +33,59 @@ public void TryParseBrowserDebugEndpoint_ReturnsNullForInvalidMetadata(string me Assert.Null(endpoint); } + [Fact] + public void TryResolveBrowserUserDataDirectory_ReturnsExpectedPathForKnownBrowser() + { + var expectedPath = OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Google", "Chrome", "User Data") + : OperatingSystem.IsMacOS() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Google", "Chrome") + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "google-chrome"); + + var browserExecutable = OperatingSystem.IsWindows() + ? "chrome.exe" + : OperatingSystem.IsMacOS() + ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + : "google-chrome"; + + var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory("chrome", browserExecutable); + + Assert.Equal(expectedPath, userDataDirectory); + } + + [Fact] + public void TryResolveBrowserUserDataDirectory_ReturnsNullForUnknownBrowser() + { + var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory("custom-browser", "/opt/custom-browser"); + + Assert.Null(userDataDirectory); + } + + [Fact] + public void TrySelectTrackedTargetId_PrefersUnattachedBlankPage() + { + var targetId = BrowserLogsRunningSession.TrySelectTrackedTargetId( + [ + new BrowserLogsTargetInfo { TargetId = "restored-page", Type = "page", Url = "https://example.com", Attached = false }, + new BrowserLogsTargetInfo { TargetId = "service-worker", Type = "service_worker", Url = "https://example.com/sw.js", Attached = false }, + new BrowserLogsTargetInfo { TargetId = "launcher-page", Type = "page", Url = "about:blank", Attached = false } + ]); + + Assert.Equal("launcher-page", targetId); + } + + [Fact] + public void TrySelectTrackedTargetId_FallsBackToFirstUnattachedPage() + { + var targetId = BrowserLogsRunningSession.TrySelectTrackedTargetId( + [ + new BrowserLogsTargetInfo { TargetId = "attached-page", Type = "page", Url = "about:blank", Attached = true }, + new BrowserLogsTargetInfo { TargetId = "fallback-page", Type = "page", Url = "chrome://newtab/", Attached = false } + ]); + + Assert.Equal("fallback-page", targetId); + } + [Fact] public async Task BrowserConnectionDiagnosticsLogger_LogsConnectionProblems() { From 7b0f98a0b3ed91518f04d502243ffe50a117174c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 19 Apr 2026 21:49:28 -0700 Subject: [PATCH 7/8] Prefer installed browser fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogsBuilderExtensions.cs | 22 +++++++++-- .../BrowserLogsRunningSession.cs | 4 +- .../BrowserLogsBuilderExtensionsTests.cs | 39 ++++++++++++++++++- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs index 6f72bb46a74..32caa6d1fc3 100644 --- a/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs +++ b/src/Aspire.Hosting/BrowserLogsBuilderExtensions.cs @@ -38,8 +38,9 @@ public static class BrowserLogsBuilderExtensions /// The resource builder. /// /// The browser to launch. When not specified, the tracked browser uses the configured value from - /// Aspire:Hosting:BrowserLogs and falls back to "chrome". Supported values include logical browser - /// names such as "msedge" and "chrome", or an explicit browser executable path. + /// Aspire:Hosting:BrowserLogs and otherwise prefers an installed "chrome" browser, then an installed + /// "msedge" browser, before finally falling back to "chrome". Supported values include logical + /// browser names such as "msedge" and "chrome", or an explicit browser executable path. /// /// /// Optional Chromium profile directory name to use. When not specified, the tracked browser uses the configured @@ -240,5 +241,20 @@ internal static BrowserLogsSettings ResolveSettings(IConfiguration configuration return new BrowserLogsSettings(resolvedBrowser, resolvedProfile); } - private static string GetDefaultBrowser() => "chrome"; + internal static string GetDefaultBrowser(Func resolveBrowserExecutable) + { + if (resolveBrowserExecutable("chrome") is not null) + { + return "chrome"; + } + + if (resolveBrowserExecutable("msedge") is not null) + { + return "msedge"; + } + + return "chrome"; + } + + private static string GetDefaultBrowser() => GetDefaultBrowser(BrowserLogsRunningSession.TryResolveBrowserExecutable); } diff --git a/src/Aspire.Hosting/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogsRunningSession.cs index 34ce99125da..61023a949fd 100644 --- a/src/Aspire.Hosting/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogsRunningSession.cs @@ -216,7 +216,7 @@ public async Task StopAsync(CancellationToken cancellationToken) private async Task InitializeAsync(CancellationToken cancellationToken) { - _browserExecutable = ResolveBrowserExecutable(_settings.Browser); + _browserExecutable = TryResolveBrowserExecutable(_settings.Browser); if (_browserExecutable is null) { throw new InvalidOperationException($"Unable to locate browser '{_settings.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); @@ -638,7 +638,7 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(string browser, str return BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory); } - private static string? ResolveBrowserExecutable(string browser) + internal static string? TryResolveBrowserExecutable(string browser) { if (Path.IsPathRooted(browser) && File.Exists(browser)) { diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index 39a61d6c8ae..73ad1858672 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -97,7 +97,42 @@ public void WithBrowserLogs_UsesResourceSpecificConfigurationWhenArgumentsAreOmi } [Fact] - public void WithBrowserLogs_UsesPlatformDefaultBrowserWhenConfigurationIsMissing() + public void GetDefaultBrowser_PrefersChromeWhenInstalled() + { + var browser = BrowserLogsBuilderExtensions.GetDefaultBrowser(browser => + browser switch + { + "chrome" => "/resolved/chrome", + "msedge" => "/resolved/edge", + _ => null + }); + + Assert.Equal("chrome", browser); + } + + [Fact] + public void GetDefaultBrowser_FallsBackToEdgeWhenChromeIsMissing() + { + var browser = BrowserLogsBuilderExtensions.GetDefaultBrowser(browser => + browser switch + { + "msedge" => "/resolved/edge", + _ => null + }); + + Assert.Equal("msedge", browser); + } + + [Fact] + public void GetDefaultBrowser_FallsBackToChromeWhenKnownBrowsersAreMissing() + { + var browser = BrowserLogsBuilderExtensions.GetDefaultBrowser(static _ => null); + + Assert.Equal("chrome", browser); + } + + [Fact] + public void WithBrowserLogs_UsesDetectedDefaultBrowserWhenConfigurationIsMissing() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); @@ -116,7 +151,7 @@ public void WithBrowserLogs_UsesPlatformDefaultBrowserWhenConfigurationIsMissing using var app = builder.Build(); var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); - Assert.Equal("chrome", browserLogsResource.Browser); + Assert.Equal(BrowserLogsBuilderExtensions.GetDefaultBrowser(BrowserLogsRunningSession.TryResolveBrowserExecutable), browserLogsResource.Browser); Assert.Null(browserLogsResource.Profile); } From e079f404d2ff962ae5f3fd675ec041117e3d52f5 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 19 Apr 2026 22:40:19 -0700 Subject: [PATCH 8/8] Fix browser logs review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogsRunningSession.cs | 5 +- .../BrowserLogsSessionManager.cs | 12 +- .../BrowserLogsBuilderExtensionsTests.cs | 152 ++++++++++++++++++ .../BrowserLogsSessionManagerTests.cs | 15 ++ 4 files changed, 179 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogsRunningSession.cs index 61023a949fd..1d230ebde2f 100644 --- a/src/Aspire.Hosting/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogsRunningSession.cs @@ -697,7 +697,10 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(string browser, str return browserKind switch { BrowserKind.Edge => Path.Combine(homeDirectory, ".config", "microsoft-edge"), - BrowserKind.Chrome => Path.Combine(homeDirectory, ".config", "google-chrome"), + BrowserKind.Chrome => Path.Combine( + homeDirectory, + ".config", + MatchesBrowser(browser, browserExecutable, "chromium", "chromium-browser") ? "chromium" : "google-chrome"), _ => null }; } diff --git a/src/Aspire.Hosting/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogsSessionManager.cs index 254528c39dd..6d639994e49 100644 --- a/src/Aspire.Hosting/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogsSessionManager.cs @@ -70,6 +70,7 @@ public async Task StartSessionAsync(BrowserLogsResource resource, BrowserLogsSet resourceState.LastSessionId = sessionId; resourceState.LastTargetUrl = url.ToString(); resourceState.LastBrowser = settings.Browser; + resourceState.LastBrowserExecutable = BrowserLogsRunningSession.TryResolveBrowserExecutable(settings.Browser); resourceState.LastProfile = settings.Profile; var resourceLogger = _resourceLoggerService.GetLogger(resourceName); @@ -322,10 +323,13 @@ private static ImmutableArray UpdateProperties( ResourceSessionState resourceState, IEnumerable propertyUpdates) { - if (resourceState.LastBrowser is not null) - { - properties = properties.SetResourceProperty(BrowserLogsBuilderExtensions.BrowserPropertyName, resourceState.LastBrowser); - } + properties = resourceState.LastBrowser is not null + ? properties.SetResourceProperty(BrowserLogsBuilderExtensions.BrowserPropertyName, resourceState.LastBrowser) + : RemoveProperty(properties, BrowserLogsBuilderExtensions.BrowserPropertyName); + + properties = resourceState.LastBrowserExecutable is not null + ? properties.SetResourceProperty(BrowserLogsBuilderExtensions.BrowserExecutablePropertyName, resourceState.LastBrowserExecutable) + : RemoveProperty(properties, BrowserLogsBuilderExtensions.BrowserExecutablePropertyName); properties = resourceState.LastProfile is not null ? properties.SetResourceProperty(BrowserLogsBuilderExtensions.ProfilePropertyName, resourceState.LastProfile) diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index 73ad1858672..36e99cb1821 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -272,6 +272,151 @@ public async Task WithBrowserLogs_CommandUsesLatestConfiguredSettingsAndRefreshe Assert.Null(session.Profile); } + [Fact] + public async Task WithBrowserLogs_CommandRefreshesBrowserExecutablePropertyWhenRelaunchFails() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var sessionFactory = new FakeBrowserLogsRunningSessionFactory(); + + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = "chrome"; + + builder.Services.AddSingleton(sp => + new BrowserLogsSessionManager( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sessionFactory)); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(); + + using var app = builder.Build(); + await app.StartAsync(); + + var browserLogsResource = app.Services.GetRequiredService().Resources.OfType().Single(); + + var firstResult = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + Assert.True(firstResult.Success); + + await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserExecutablePropertyName, "/fake/browser-1")).DefaultTimeout(); + + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + var tempBrowserPath = Path.Combine(tempDirectory.FullName, OperatingSystem.IsWindows() ? "tracked-browser.exe" : "tracked-browser"); + await File.WriteAllTextAsync(tempBrowserPath, string.Empty); + + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = tempBrowserPath; + sessionFactory.NextStartException = new InvalidOperationException("Launch failed."); + + var secondResult = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + + Assert.False(secondResult.Success); + Assert.Equal("Launch failed.", secondResult.Message); + + var failedEvent = await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserPropertyName, tempBrowserPath) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserExecutablePropertyName, tempBrowserPath) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 1)).DefaultTimeout(); + + Assert.Collection( + GetBrowserSessions(failedEvent.Snapshot), + session => + { + Assert.Equal("session-0001", session.SessionId); + Assert.Equal("/fake/browser-1", session.BrowserExecutable); + }); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Fact] + public async Task WithBrowserLogs_CommandRemovesStaleBrowserExecutablePropertyWhenBrowserCannotBeResolved() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var sessionFactory = new FakeBrowserLogsRunningSessionFactory(); + + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = "chrome"; + + builder.Services.AddSingleton(sp => + new BrowserLogsSessionManager( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sessionFactory)); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(); + + using var app = builder.Build(); + await app.StartAsync(); + + var browserLogsResource = app.Services.GetRequiredService().Resources.OfType().Single(); + + var firstResult = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + Assert.True(firstResult.Success); + + await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserExecutablePropertyName, "/fake/browser-1")).DefaultTimeout(); + + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = "missing-browser"; + sessionFactory.NextStartException = new InvalidOperationException("Launch failed."); + + var secondResult = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + + Assert.False(secondResult.Success); + + var failedEvent = await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserPropertyName, "missing-browser") && + !resourceEvent.Snapshot.Properties.Any(property => property.Name == BrowserLogsBuilderExtensions.BrowserExecutablePropertyName) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 1)).DefaultTimeout(); + + Assert.Collection( + GetBrowserSessions(failedEvent.Snapshot), + session => + { + Assert.Equal("session-0001", session.SessionId); + Assert.Equal("/fake/browser-1", session.BrowserExecutable); + }); + } + [Fact] public async Task WithBrowserLogs_CommandFailsWhenEndpointIsMissing() { @@ -696,6 +841,7 @@ private sealed class FakeBrowserLogsRunningSessionFactory : IBrowserLogsRunningS { public List Sessions { get; } = []; public List Settings { get; } = []; + public Exception? NextStartException { get; set; } public Task StartSessionAsync( BrowserLogsSettings settings, @@ -707,6 +853,12 @@ public Task StartSessionAsync( { Settings.Add(settings); + if (NextStartException is { } exception) + { + NextStartException = null; + return Task.FromException(exception); + } + var session = new FakeBrowserLogsRunningSession( sessionId, $"/fake/browser-{Sessions.Count + 1}", diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 16f83eb8016..8219f3a3253 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -61,6 +61,21 @@ public void TryResolveBrowserUserDataDirectory_ReturnsNullForUnknownBrowser() Assert.Null(userDataDirectory); } + [Fact] + public void TryResolveBrowserUserDataDirectory_UsesChromiumPathOnLinux() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + var expectedPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "chromium"); + + var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory("chrome", "/usr/bin/chromium"); + + Assert.Equal(expectedPath, userDataDirectory); + } + [Fact] public void TrySelectTrackedTargetId_PrefersUnattachedBlankPage() {