File: BrowserLogsSessionManagerTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Browsers.Tests\Aspire.Hosting.Browsers.Tests.csproj (Aspire.Hosting.Browsers.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Net;
using System.Net.Sockets;
using System.Text;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests.Utils;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
 
#pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only
#pragma warning disable ASPIREBROWSERLOGS001 // Type is for evaluation purposes only
 
namespace Aspire.Hosting.Browsers.Tests;
 
[Trait("Partition", "2")]
public class BrowserLogsSessionManagerTests
{
    [Fact]
    public void BrowserHostIdentity_NormalizesTrailingDirectorySeparators()
    {
        WithTempUserDataDirectory(userDataDirectory =>
        {
            var executablePath = Path.Combine(userDataDirectory, "browser");
            var identity = new BrowserHostIdentity(executablePath, userDataDirectory);
            var identityWithTrailingSeparator = new BrowserHostIdentity(executablePath, userDataDirectory + Path.DirectorySeparatorChar);
 
            Assert.Equal(identity, identityWithTrailingSeparator);
            Assert.Equal(identity.GetHashCode(), identityWithTrailingSeparator.GetHashCode());
        });
    }
 
    [Fact]
    public void BrowserHostIdentity_DefaultValueDoesNotThrowWhenHashed()
    {
        var exception = Record.Exception(() => default(BrowserHostIdentity).GetHashCode());
 
        Assert.Null(exception);
    }
 
    [Fact]
    public async Task BrowserHostLease_ReleasesOnlyOnce()
    {
        var releaseCount = 0;
        var lease = new BrowserHostLease(new TestBrowserHost(), _ =>
        {
            releaseCount++;
            return ValueTask.CompletedTask;
        });
 
        await lease.DisposeAsync();
        await lease.DisposeAsync();
 
        Assert.Equal(1, releaseCount);
    }
 
    [Fact]
    public async Task BrowserHostRegistry_ReusesHostUntilFinalLeaseReleasesIt()
    {
        var userDataDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var browserExecutable = Path.Combine(userDataDirectory.FullName, "browser");
            File.WriteAllText(browserExecutable, string.Empty);
            var createdHosts = new List<TestBrowserHost>();
            await using var registry = new BrowserHostRegistry(
 
                NullLogger<BrowserLogsSessionManager>.Instance,
                TimeProvider.System,
                createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile),
                createHostAsync: (configuration, identity, _, _) =>
                {
                    var host = new TestBrowserHost(identity, configuration.Profile);
                    createdHosts.Add(host);
                    return Task.FromResult<IBrowserHost>(host);
                });
            var configuration = new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared, AppHostKey: null);
 
            var firstLease = await registry.AcquireAsync(configuration, CancellationToken.None);
            var secondLease = await registry.AcquireAsync(configuration, CancellationToken.None);
 
            Assert.Single(createdHosts);
            Assert.Same(firstLease.Host, secondLease.Host);
 
            await firstLease.DisposeAsync();
            Assert.False(createdHosts[0].Disposed);
 
            await secondLease.DisposeAsync();
            Assert.True(createdHosts[0].Disposed);
        }
        finally
        {
            userDataDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task BrowserHostRegistry_LateLeaseReleaseAfterRegistryDisposeNoOps()
    {
        var userDataDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var browserExecutable = Path.Combine(userDataDirectory.FullName, "browser");
            File.WriteAllText(browserExecutable, string.Empty);
            var createdHosts = new List<TestBrowserHost>();
            var registry = new BrowserHostRegistry(
 
                NullLogger<BrowserLogsSessionManager>.Instance,
                TimeProvider.System,
                createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile),
                createHostAsync: (configuration, identity, _, _) =>
                {
                    var host = new TestBrowserHost(identity, configuration.Profile);
                    createdHosts.Add(host);
                    return Task.FromResult<IBrowserHost>(host);
                });
            var configuration = new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared, AppHostKey: null);
 
            var lease = await registry.AcquireAsync(configuration, CancellationToken.None);
 
            await registry.DisposeAsync();
            await lease.DisposeAsync();
 
            Assert.True(createdHosts[0].Disposed);
        }
        finally
        {
            userDataDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task BrowserHostRegistry_RejectsDifferentProfileForSharedHost()
    {
        var userDataDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var browserExecutable = Path.Combine(userDataDirectory.FullName, "browser");
            File.WriteAllText(browserExecutable, string.Empty);
            await using var registry = new BrowserHostRegistry(
 
                NullLogger<BrowserLogsSessionManager>.Instance,
                TimeProvider.System,
                createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile),
                createHostAsync: (configuration, identity, _, _) => Task.FromResult<IBrowserHost>(new TestBrowserHost(identity, configuration.Profile)));
 
            var firstLease = await registry.AcquireAsync(
                new BrowserConfiguration(browserExecutable, Profile: "Profile 1", BrowserUserDataMode.Shared, AppHostKey: null),
                CancellationToken.None);
 
            var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
                registry.AcquireAsync(
                    new BrowserConfiguration(browserExecutable, Profile: "Profile 2", BrowserUserDataMode.Shared, AppHostKey: null),
                    CancellationToken.None));
 
            await firstLease.DisposeAsync();
            Assert.Contains("with profile 'Profile 1'", exception.Message);
            Assert.Contains("The requested profile is 'Profile 2'", exception.Message);
        }
        finally
        {
            userDataDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task BrowserHostRegistry_AdoptsValidatedSharedEndpointMetadataWhenAdoptionIsEnabled()
    {
        var userDataDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var browserExecutable = Path.Combine(userDataDirectory.FullName, "browser");
            File.WriteAllText(browserExecutable, string.Empty);
            var identity = new BrowserHostIdentity(browserExecutable, userDataDirectory.FullName);
            var browserEndpoint = StartBrowserVersionEndpoint(out var serverTask);
 
            await BrowserEndpointDiscovery.WriteAsync(
                identity,
                profileDirectoryName: null,
                browserEndpoint,
                Environment.ProcessId,
                CancellationToken.None);
 
            await using var registry = new BrowserHostRegistry(
 
                NullLogger<BrowserLogsSessionManager>.Instance,
                TimeProvider.System,
                createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile),
                createHostAsync: null,
                enableEndpointMetadataAdoption: true);
 
            var lease = await registry.AcquireAsync(
                new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared, AppHostKey: null),
                CancellationToken.None);
 
            await serverTask.WaitAsync(TimeSpan.FromSeconds(5));
            Assert.Equal(BrowserHostOwnership.Adopted, lease.Host.Ownership);
            Assert.Equal(identity, lease.Host.Identity);
            Assert.Equal(browserEndpoint, lease.Host.DebugEndpoint);
            Assert.Null(lease.Host.ProcessId);
 
            await lease.DisposeAsync();
        }
        finally
        {
            userDataDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void BrowserPageSession_MapsTargetLifecycleEventsToCompletion()
    {
        var closed = BrowserPageSession.TryGetPageCompletion(
            new BrowserLogsDetachedFromTargetEvent(
                SessionId: null,
                new BrowserLogsDetachedFromTargetParameters
                {
                    SessionId = "target-session-1",
                    TargetId = "target-1"
                }),
            targetId: "target-1",
            targetSessionId: "target-session-1");
        var crashed = BrowserPageSession.TryGetPageCompletion(
            new BrowserLogsTargetCrashedEvent(
                SessionId: null,
                new BrowserLogsTargetCrashedParameters
                {
                    TargetId = "target-1",
                    Status = "crashed",
                    ErrorCode = 1337
                }),
            targetId: "target-1",
            targetSessionId: "target-session-1");
        var unrelated = BrowserPageSession.TryGetPageCompletion(
            new BrowserLogsInspectorDetachedEvent(
                SessionId: "other-session",
                new BrowserLogsInspectorDetachedParameters
                {
                    Reason = "target_closed"
                }),
            targetId: "target-1",
            targetSessionId: "target-session-1");
 
        Assert.Equal(BrowserPageSessionCompletionKind.PageClosed, closed?.CompletionKind);
        Assert.Null(closed?.Error);
        Assert.Equal(BrowserPageSessionCompletionKind.PageCrashed, crashed?.CompletionKind);
        Assert.Contains("1337", crashed?.Error?.Message);
        Assert.Null(unrelated);
    }
 
    [Fact]
    public async Task BrowserEndpointDiscovery_DeletesStaleMetadataBeforeProfileCompatibilityCheck()
    {
        var userDataDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var identity = new BrowserHostIdentity(
                Path.Combine(userDataDirectory.FullName, "browser"),
                userDataDirectory.FullName);
            var metadataPath = BrowserEndpointDiscovery.GetEndpointMetadataFilePath(userDataDirectory.FullName);
            var discovery = new BrowserEndpointDiscovery(NullLogger<BrowserLogsSessionManager>.Instance);
 
            await BrowserEndpointDiscovery.WriteAsync(
                identity,
                profileDirectoryName: "Profile 1",
                new Uri("ws://127.0.0.1:9/devtools/browser/stale"),
                processId: int.MaxValue,
                CancellationToken.None);
 
            var metadata = await discovery.TryReadAndValidateAsync(identity, profileDirectoryName: "Default", CancellationToken.None);
 
            Assert.Null(metadata);
            Assert.False(File.Exists(metadataPath));
        }
        finally
        {
            userDataDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task BrowserEndpointDiscovery_DoesNotEscapeNonAsciiMetadata()
    {
        var userDataDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var identity = new BrowserHostIdentity(
                Path.Combine(userDataDirectory.FullName, "bröwser"),
                userDataDirectory.FullName);
            var metadataPath = BrowserEndpointDiscovery.GetEndpointMetadataFilePath(userDataDirectory.FullName);
 
            await BrowserEndpointDiscovery.WriteAsync(
                identity,
                profileDirectoryName: "Pröfile 1",
                new Uri("ws://127.0.0.1:9/devtools/browser/stale"),
                processId: int.MaxValue,
                CancellationToken.None);
 
            var metadata = await File.ReadAllTextAsync(metadataPath);
 
            Assert.Contains("bröwser", metadata);
            Assert.Contains("Pröfile 1", metadata);
            Assert.DoesNotContain("\\u00f6", metadata);
        }
        finally
        {
            userDataDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task BrowserEndpointDiscovery_DeletesMalformedEndpointMetadata()
    {
        var userDataDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var identity = new BrowserHostIdentity(
                Path.Combine(userDataDirectory.FullName, "browser"),
                userDataDirectory.FullName);
            var metadataPath = BrowserEndpointDiscovery.GetEndpointMetadataFilePath(userDataDirectory.FullName);
            var discovery = new BrowserEndpointDiscovery(NullLogger<BrowserLogsSessionManager>.Instance);
            await File.WriteAllTextAsync(
                metadataPath,
                $$"""
                {
                  "schemaVersion": 1,
                  "endpoint": "ws://127.0.0.1:9/devtools/browser/stale",
                  "processId": {{Environment.ProcessId}},
                  "userDataRootPath": {{System.Text.Json.JsonSerializer.Serialize(userDataDirectory.FullName)}}
                }
                """);
 
            var metadata = await discovery.TryReadAndValidateAsync(identity, profileDirectoryName: null, CancellationToken.None);
 
            Assert.Null(metadata);
            Assert.False(File.Exists(metadataPath));
        }
        finally
        {
            userDataDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task BrowserEndpointDiscovery_ThrowsForLiveEndpointWithDifferentProfile()
    {
        var userDataDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var identity = new BrowserHostIdentity(
                Path.Combine(userDataDirectory.FullName, "browser"),
                userDataDirectory.FullName);
            var discovery = new BrowserEndpointDiscovery(NullLogger<BrowserLogsSessionManager>.Instance);
            var browserEndpoint = StartBrowserVersionEndpoint(out var serverTask);
 
            await BrowserEndpointDiscovery.WriteAsync(
                identity,
                profileDirectoryName: "Profile 1",
                browserEndpoint,
                Environment.ProcessId,
                CancellationToken.None);
 
            var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
                discovery.TryReadAndValidateAsync(identity, profileDirectoryName: "Default", CancellationToken.None));
 
            await serverTask.WaitAsync(TimeSpan.FromSeconds(5));
            Assert.Contains("with profile 'Profile 1'", exception.Message);
            Assert.Contains("The requested profile is 'Default'", exception.Message);
        }
        finally
        {
            userDataDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void ResolveProfileDirectory_MatchesDirectoryNameCaseInsensitively()
    {
        WithTempUserDataDirectory(userDataDirectory =>
        {
            Directory.CreateDirectory(Path.Combine(userDataDirectory, "Profile 1"));
 
            var profileDirectory = ChromiumBrowserResolver.ResolveProfileDirectory(userDataDirectory, "profile 1");
 
            Assert.Equal("Profile 1", profileDirectory);
        });
    }
 
    [Fact]
    public void ResolveProfileDirectory_MatchesProfileDisplayNameFromLocalState()
    {
        WithTempUserDataDirectory(userDataDirectory =>
        {
            Directory.CreateDirectory(Path.Combine(userDataDirectory, "Default"));
            Directory.CreateDirectory(Path.Combine(userDataDirectory, "Profile 1"));
            File.WriteAllText(
                Path.Combine(userDataDirectory, "Local State"),
                """
                {
                  "profile": {
                    "info_cache": {
                      "Default": {
                        "name": "Profile 1"
                      },
                      "Profile 1": {
                        "name": "Profile 2"
                      }
                    }
                  }
                }
                """);
 
            var profileDirectory = ChromiumBrowserResolver.ResolveProfileDirectory(userDataDirectory, "Profile 2");
 
            Assert.Equal("Profile 1", profileDirectory);
        });
    }
 
    [Fact]
    public void GetAvailableProfiles_UsesLocalStateAndLikelyProfileDirectories()
    {
        WithTempUserDataDirectory(userDataDirectory =>
        {
            Directory.CreateDirectory(Path.Combine(userDataDirectory, "Default"));
            Directory.CreateDirectory(Path.Combine(userDataDirectory, "Profile 1"));
            Directory.CreateDirectory(Path.Combine(userDataDirectory, "Profile 2"));
            Directory.CreateDirectory(Path.Combine(userDataDirectory, "System Profile"));
            File.WriteAllText(
                Path.Combine(userDataDirectory, "Local State"),
                """
                {
                  "profile": {
                    "info_cache": {
                      "Default": {
                        "name": "Personal",
                        "shortcut_name": "Personal shortcut"
                      },
                      "Profile 1": {
                        "name": "Work"
                      },
                      "Missing": {
                        "name": "Stale"
                      }
                    }
                  }
                }
                """);
 
            var profiles = ChromiumBrowserResolver.GetAvailableProfiles(userDataDirectory);
 
            Assert.Collection(
                profiles,
                profile =>
                {
                    Assert.Equal("Default", profile.DirectoryName);
                    Assert.Equal("Personal", profile.DisplayName);
                    Assert.Equal("Personal shortcut", profile.ShortcutName);
                },
                profile =>
                {
                    Assert.Equal("Profile 1", profile.DirectoryName);
                    Assert.Equal("Work", profile.DisplayName);
                    Assert.Null(profile.ShortcutName);
                },
                profile =>
                {
                    Assert.Equal("Profile 2", profile.DirectoryName);
                    Assert.Null(profile.DisplayName);
                    Assert.Null(profile.ShortcutName);
                });
        });
    }
 
    [Fact]
    public void GetAvailableProfiles_FallsBackToDirectoriesWhenLocalStateIsInvalid()
    {
        WithTempUserDataDirectory(userDataDirectory =>
        {
            Directory.CreateDirectory(Path.Combine(userDataDirectory, "Default"));
            Directory.CreateDirectory(Path.Combine(userDataDirectory, "Profile 1"));
            File.WriteAllText(Path.Combine(userDataDirectory, "Local State"), "{");
 
            var profiles = ChromiumBrowserResolver.GetAvailableProfiles(userDataDirectory);
 
            Assert.Collection(
                profiles,
                profile =>
                {
                    Assert.Equal("Default", profile.DirectoryName);
                    Assert.Null(profile.DisplayName);
                    Assert.Null(profile.ShortcutName);
                },
                profile =>
                {
                    Assert.Equal("Profile 1", profile.DirectoryName);
                    Assert.Null(profile.DisplayName);
                    Assert.Null(profile.ShortcutName);
                });
        });
    }
 
    [Fact]
    public void ResolveProfileDirectory_ThrowsWhenDisplayNameIsAmbiguous()
    {
        WithTempUserDataDirectory(userDataDirectory =>
        {
            Directory.CreateDirectory(Path.Combine(userDataDirectory, "Default"));
            Directory.CreateDirectory(Path.Combine(userDataDirectory, "Profile 1"));
            File.WriteAllText(
                Path.Combine(userDataDirectory, "Local State"),
                """
                {
                  "profile": {
                    "info_cache": {
                      "Default": {
                        "name": "Shared profile"
                      },
                      "Profile 1": {
                        "name": "Shared profile"
                      }
                    }
                  }
                }
                """);
 
            var exception = Assert.Throws<InvalidOperationException>(() => ChromiumBrowserResolver.ResolveProfileDirectory(userDataDirectory, "Shared profile"));
 
            Assert.Contains("matched multiple Chromium profiles", exception.Message);
        });
    }
 
    [Fact]
    public async Task StartSessionAsync_ThrowsWhenManagerIsDisposing()
    {
        var sessionFactory = new ThrowIfCalledSessionFactory();
        var manager = new BrowserLogsSessionManager(
            ConsoleLoggingTestHelpers.GetResourceLoggerService(),
            ResourceNotificationServiceTestHelpers.Create(),
            TimeProvider.System,
            NullLogger<BrowserLogsSessionManager>.Instance,
            artifactWriter: null,
            sessionFactory: sessionFactory);
        var resource = new BrowserLogsResource(
            "web-browser-logs",
            new TestResourceWithEndpoints("web"),
            new BrowserConfiguration("chrome", null, BrowserUserDataMode.Isolated, AppHostKey: "test-apphost"),
            new BrowserConfigurationExplicitValues());
 
        await manager.DisposeAsync();
 
        await Assert.ThrowsAsync<ObjectDisposedException>(() => manager.StartSessionAsync(
            resource,
            new BrowserConfiguration("chrome", null, BrowserUserDataMode.Isolated, AppHostKey: "test-apphost"),
            resource.Name,
            new Uri("https://localhost"),
            CancellationToken.None));
 
        Assert.False(sessionFactory.WasCalled);
    }
 
    private static void WithTempUserDataDirectory(Action<string> action)
    {
        var userDataDirectory = Directory.CreateTempSubdirectory();
        try
        {
            action(userDataDirectory.FullName);
        }
        finally
        {
            userDataDirectory.Delete(recursive: true);
        }
    }
 
    private static Uri StartBrowserVersionEndpoint(out Task serverTask)
    {
        var listener = new TcpListener(IPAddress.Loopback, port: 0);
        listener.Start();
 
        var browserEndpoint = new Uri($"ws://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}/devtools/browser/test");
        serverTask = Task.Run(async () =>
        {
            try
            {
                using var client = await listener.AcceptTcpClientAsync().ConfigureAwait(false);
                await using var stream = client.GetStream();
                var buffer = new byte[4096];
                await stream.ReadAsync(buffer).ConfigureAwait(false);
 
                var body = $$"""{"webSocketDebuggerUrl":"{{browserEndpoint}}"}""";
                var response = Encoding.UTF8.GetBytes(
                    "HTTP/1.1 200 OK\r\n" +
                    "Content-Type: application/json\r\n" +
                    $"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n" +
                    "Connection: close\r\n" +
                    "\r\n" +
                    body);
                await stream.WriteAsync(response).ConfigureAwait(false);
            }
            finally
            {
                listener.Stop();
            }
        });
 
        return browserEndpoint;
    }
 
    private sealed class ThrowIfCalledSessionFactory : IBrowserLogsRunningSessionFactory
    {
        public bool WasCalled { get; private set; }
 
        public Task<IBrowserLogsRunningSession> StartSessionAsync(
            BrowserConfiguration configuration,
            string resourceName,
            Uri url,
            string sessionId,
            ILogger resourceLogger,
            CancellationToken cancellationToken)
        {
            WasCalled = true;
            throw new InvalidOperationException("StartSessionAsync should not be called after disposal.");
        }
    }
 
    private sealed class TestBrowserHost : IBrowserHost
    {
        public TestBrowserHost()
            : this(
                new BrowserHostIdentity(
                    Path.Combine(AppContext.BaseDirectory, "browser"),
                    Path.Combine(AppContext.BaseDirectory, "user-data")),
                profileDirectoryName: null)
        {
        }
 
        public TestBrowserHost(BrowserHostIdentity identity, string? profileDirectoryName)
        {
            Identity = identity;
            ProfileDirectoryName = profileDirectoryName;
        }
 
        public BrowserHostIdentity Identity { get; }
 
        public string? ProfileDirectoryName { get; }
 
        public bool Disposed { get; private set; }
 
        public BrowserHostOwnership Ownership => BrowserHostOwnership.Owned;
 
        public Uri DebugEndpoint { get; } = new("ws://127.0.0.1/devtools/browser/test");
 
        public int? ProcessId => 1;
 
        public string BrowserDisplayName => "Test";
 
        public Task Termination { get; } = Task.CompletedTask;
 
        public Task<IBrowserLogsCdpConnection> CreateCdpConnectionAsync(
            Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler,
            ILogger<BrowserLogsSessionManager> logger,
            CancellationToken cancellationToken) =>
            throw new NotSupportedException();
 
        public Task<IBrowserPageSession> CreatePageSessionAsync(
            string sessionId,
            Uri url,
            BrowserConnectionDiagnosticsLogger connectionDiagnostics,
            Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler,
            CancellationToken cancellationToken) =>
            throw new NotSupportedException();
 
        public ValueTask DisposeAsync()
        {
            Disposed = true;
            return ValueTask.CompletedTask;
        }
    }
 
    private sealed class TestResourceWithEndpoints(string name) : Resource(name), IResourceWithEndpoints;
}