File: BrowserPageSessionTests.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 Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Hosting.Browsers.Tests;
 
[Trait("Partition", "2")]
public class BrowserPageSessionTests
{
    [Fact]
    public void TrySelectReusableStartupPageTargetId_PrefersUnattachedBlankPage()
    {
        var targetId = BrowserPageSession.TrySelectReusableStartupPageTargetId(
        [
            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 TrySelectReusableStartupPageTargetId_FallsBackToFirstUnattachedPage()
    {
        var targetId = BrowserPageSession.TrySelectReusableStartupPageTargetId(
        [
            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 StartAsync_ReusesStartupTargetAttachesInstrumentsNavigatesAndRoutesEvents()
    {
        var host = new TestBrowserHost();
        var connection = new FakeBrowserLogsCdpConnection
        {
            TargetInfos =
            [
                new BrowserLogsTargetInfo { TargetId = "startup-target", Type = "page", Url = "about:blank", Attached = false }
            ]
        };
        var routedEvents = new List<BrowserLogsCdpProtocolEvent>();
 
        var session = await BrowserPageSession.StartAsync(
            host,
            "session-0001",
            new Uri("https://localhost:5001/"),
            new BrowserConnectionDiagnosticsLogger("session-0001", NullLogger.Instance),
            CreateConnectionFactory(connection),
            protocolEvent =>
            {
                routedEvents.Add(protocolEvent);
                return ValueTask.CompletedTask;
            },
            NullLogger<BrowserLogsSessionManager>.Instance,
            TimeProvider.System,
            reuseInitialBlankTarget: true,
            CancellationToken.None);
 
        Assert.Equal("startup-target", session.TargetId);
        Assert.Equal("target-session-1", session.TargetSessionId);
        Assert.Equal(
            new[]
            {
                "EnableTargetDiscovery",
                "GetTargets",
                "Attach:startup-target",
                "EnablePageInstrumentation:target-session-1",
                "Navigate:target-session-1:https://localhost:5001/"
            },
            connection.Calls);
 
        var unrelatedEvent = new BrowserLogsConsoleApiCalledEvent("other-session", new BrowserLogsRuntimeConsoleApiCalledParameters { Type = "log" });
        var routedEvent = new BrowserLogsConsoleApiCalledEvent("target-session-1", new BrowserLogsRuntimeConsoleApiCalledParameters { Type = "log" });
        await connection.RaiseEventAsync(unrelatedEvent);
        await connection.RaiseEventAsync(routedEvent);
 
        var capturedEvent = Assert.Single(routedEvents);
        Assert.Same(routedEvent, capturedEvent);
 
        await connection.RaiseEventAsync(new BrowserLogsTargetDestroyedEvent(
            SessionId: null,
            new BrowserLogsTargetDestroyedParameters { TargetId = "startup-target" }));
 
        var result = await session.Completion.DefaultTimeout();
        Assert.Equal(BrowserPageSessionCompletionKind.PageClosed, result.CompletionKind);
        Assert.Null(result.Error);
        Assert.True(connection.Disposed);
 
        await session.DisposeAsync();
    }
 
    [Fact]
    public async Task DisposeAsync_ClosesTrackedTarget()
    {
        var host = new TestBrowserHost();
        var connection = new FakeBrowserLogsCdpConnection
        {
            CreatedTargetId = "created-target"
        };
 
        var session = await BrowserPageSession.StartAsync(
            host,
            "session-0001",
            new Uri("https://localhost:5001/"),
            new BrowserConnectionDiagnosticsLogger("session-0001", NullLogger.Instance),
            CreateConnectionFactory(connection),
            static _ => ValueTask.CompletedTask,
            NullLogger<BrowserLogsSessionManager>.Instance,
            TimeProvider.System,
            reuseInitialBlankTarget: false,
            CancellationToken.None);
 
        await session.DisposeAsync();
 
        Assert.Equal(
            new[]
            {
                "EnableTargetDiscovery",
                "CreateTarget",
                "Attach:created-target",
                "EnablePageInstrumentation:target-session-1",
                "Navigate:target-session-1:https://localhost:5001/",
                "CloseTarget:created-target"
            },
            connection.Calls);
        Assert.True(connection.Disposed);
        var result = await session.Completion.DefaultTimeout();
        Assert.Equal(BrowserPageSessionCompletionKind.Stopped, result.CompletionKind);
    }
 
    [Fact]
    public async Task CaptureScreenshotAsync_UsesCurrentTargetSession()
    {
        var host = new TestBrowserHost();
        var connection = new FakeBrowserLogsCdpConnection
        {
            CreatedTargetId = "created-target",
            ScreenshotData = "image-data"
        };
 
        var session = await BrowserPageSession.StartAsync(
            host,
            "session-0001",
            new Uri("https://localhost:5001/"),
            new BrowserConnectionDiagnosticsLogger("session-0001", NullLogger.Instance),
            CreateConnectionFactory(connection),
            static _ => ValueTask.CompletedTask,
            NullLogger<BrowserLogsSessionManager>.Instance,
            TimeProvider.System,
            reuseInitialBlankTarget: false,
            CancellationToken.None);
 
        var result = await session.CaptureScreenshotAsync(CancellationToken.None);
 
        Assert.Equal("image-data", result.Data);
        Assert.Contains("CaptureScreenshot:target-session-1", connection.Calls);
 
        await session.DisposeAsync();
    }
 
    [Fact]
    public async Task CaptureScreenshotAsync_IsCanceledWhenSessionIsDisposed()
    {
        var host = new TestBrowserHost();
        var connection = new FakeBrowserLogsCdpConnection
        {
            CreatedTargetId = "created-target",
            WaitForScreenshotCancellation = true
        };
 
        var session = await BrowserPageSession.StartAsync(
            host,
            "session-0001",
            new Uri("https://localhost:5001/"),
            new BrowserConnectionDiagnosticsLogger("session-0001", NullLogger.Instance),
            CreateConnectionFactory(connection),
            static _ => ValueTask.CompletedTask,
            NullLogger<BrowserLogsSessionManager>.Instance,
            TimeProvider.System,
            reuseInitialBlankTarget: false,
            CancellationToken.None);
 
        var captureTask = session.CaptureScreenshotAsync(CancellationToken.None);
        await connection.ScreenshotCaptureStarted.Task.DefaultTimeout();
 
        var disposeTask = session.DisposeAsync().AsTask();
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await captureTask.DefaultTimeout());
        await disposeTask.DefaultTimeout();
        Assert.True(connection.Disposed);
    }
 
    [Fact]
    public async Task MonitorAsync_ReconnectsToExistingTargetAfterConnectionLoss()
    {
        var host = new TestBrowserHost();
        var firstConnection = new FakeBrowserLogsCdpConnection
        {
            CreatedTargetId = "target-1",
            AttachSessionId = "target-session-1"
        };
        var secondConnection = new FakeBrowserLogsCdpConnection
        {
            AttachSessionId = "target-session-2"
        };
        var routedEvents = new List<BrowserLogsCdpProtocolEvent>();
        var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 26, 0, 0, 0, TimeSpan.Zero));
 
        var session = await BrowserPageSession.StartAsync(
            host,
            "session-0001",
            new Uri("https://localhost:5001/"),
            new BrowserConnectionDiagnosticsLogger("session-0001", NullLogger.Instance),
            CreateConnectionFactory(firstConnection, secondConnection),
            protocolEvent =>
            {
                routedEvents.Add(protocolEvent);
                return ValueTask.CompletedTask;
            },
            NullLogger<BrowserLogsSessionManager>.Instance,
            timeProvider,
            reuseInitialBlankTarget: false,
            CancellationToken.None);
 
        Assert.Equal("target-1", session.TargetId);
        Assert.Equal("target-session-1", session.TargetSessionId);
 
        firstConnection.FailCompletion(new InvalidOperationException("Socket reset."));
 
        await secondConnection.InstrumentationEnabled.DefaultTimeout();
 
        Assert.True(firstConnection.Disposed);
        Assert.Equal("target-1", session.TargetId);
        Assert.Equal("target-session-2", session.TargetSessionId);
        Assert.Equal(
            new[]
            {
                "EnableTargetDiscovery",
                "Attach:target-1",
                "EnablePageInstrumentation:target-session-2"
            },
            secondConnection.Calls);
 
        var routedEvent = new BrowserLogsConsoleApiCalledEvent("target-session-2", new BrowserLogsRuntimeConsoleApiCalledParameters { Type = "log" });
        await secondConnection.RaiseEventAsync(routedEvent);
 
        Assert.Same(routedEvent, Assert.Single(routedEvents));
 
        await secondConnection.RaiseEventAsync(new BrowserLogsTargetDestroyedEvent(
            SessionId: null,
            new BrowserLogsTargetDestroyedParameters { TargetId = "target-1" }));
 
        var result = await session.Completion.DefaultTimeout();
        Assert.Equal(BrowserPageSessionCompletionKind.PageClosed, result.CompletionKind);
        Assert.Null(result.Error);
        Assert.True(secondConnection.Disposed);
 
        await session.DisposeAsync();
    }
 
    [Fact]
    public async Task MonitorAsync_CompletesWithConnectionLostWhenReconnectTimesOut()
    {
        var host = new TestBrowserHost();
        var firstConnection = new FakeBrowserLogsCdpConnection
        {
            CreatedTargetId = "target-1",
            AttachSessionId = "target-session-1"
        };
        var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 26, 0, 0, 0, TimeSpan.Zero));
 
        // All reconnect attempts after the first connection will fail.
        var reconnectAttempts = 0;
        BrowserLogsCdpConnectionFactory connectionFactory = (eventHandler, logger, cancellationToken) =>
        {
            if (reconnectAttempts == 0)
            {
                reconnectAttempts++;
                firstConnection.SetEventHandler(eventHandler);
                return Task.FromResult<IBrowserLogsCdpConnection>(firstConnection);
            }
 
            reconnectAttempts++;
            throw new InvalidOperationException("Simulated connection failure.");
        };
 
        var session = await BrowserPageSession.StartAsync(
            host,
            "session-0001",
            new Uri("https://localhost:5001/"),
            new BrowserConnectionDiagnosticsLogger("session-0001", NullLogger.Instance),
            connectionFactory,
            static _ => ValueTask.CompletedTask,
            NullLogger<BrowserLogsSessionManager>.Instance,
            timeProvider,
            reuseInitialBlankTarget: false,
            CancellationToken.None);
 
        Assert.Equal("target-1", session.TargetId);
 
        // Trigger connection loss to start the reconnect loop.
        firstConnection.FailCompletion(new InvalidOperationException("Socket reset."));
 
        // Advance time in a concurrent task so that each Task.Delay timer in the reconnect loop fires,
        // allowing the loop to iterate and eventually exceed the 5-second recovery deadline.
        _ = Task.Run(async () =>
        {
            while (!session.Completion.IsCompleted)
            {
                await Task.Delay(10);
                timeProvider.Advance(TimeSpan.FromSeconds(1));
            }
        });
 
        var result = await session.Completion.DefaultTimeout();
        Assert.Equal(BrowserPageSessionCompletionKind.ConnectionLost, result.CompletionKind);
        Assert.NotNull(result.Error);
        Assert.True(firstConnection.Disposed);
        Assert.True(reconnectAttempts > 1, $"Expected multiple reconnect attempts but got {reconnectAttempts}.");
 
        await session.DisposeAsync();
    }
 
    private static BrowserLogsCdpConnectionFactory CreateConnectionFactory(params FakeBrowserLogsCdpConnection[] connections)
    {
        var nextConnectionIndex = 0;
        return (eventHandler, _, _) =>
        {
            Assert.True(nextConnectionIndex < connections.Length);
            var connection = connections[nextConnectionIndex++];
            connection.SetEventHandler(eventHandler);
            return Task.FromResult<IBrowserLogsCdpConnection>(connection);
        };
    }
 
    private sealed class TestBrowserHost : IBrowserHost
    {
        private readonly TaskCompletionSource _terminationSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
 
        public BrowserHostIdentity Identity { get; } = new(
            Path.Combine(AppContext.BaseDirectory, "browser"),
            Path.Combine(AppContext.BaseDirectory, "user-data"));
 
        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 => _terminationSource.Task;
 
        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()
        {
            _terminationSource.TrySetResult();
            return ValueTask.CompletedTask;
        }
    }
 
    private sealed class FakeBrowserLogsCdpConnection : IBrowserLogsCdpConnection
    {
        private readonly TaskCompletionSource _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
        private readonly TaskCompletionSource _instrumentationEnabled = new(TaskCreationOptions.RunContinuationsAsynchronously);
        private Func<BrowserLogsCdpProtocolEvent, ValueTask>? _eventHandler;
 
        public List<string> Calls { get; } = [];
 
        public string CreatedTargetId { get; init; } = "target-1";
 
        public string AttachSessionId { get; init; } = "target-session-1";
 
        public string ScreenshotData { get; init; } = Convert.ToBase64String([0x89, 0x50, 0x4e, 0x47]);
 
        public bool WaitForScreenshotCancellation { get; init; }
 
        public TaskCompletionSource ScreenshotCaptureStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
 
        public bool Disposed { get; private set; }
 
        public BrowserLogsTargetInfo[]? TargetInfos { get; init; }
 
        public Task Completion => _completionSource.Task;
 
        public Task InstrumentationEnabled => _instrumentationEnabled.Task;
 
        public Task<BrowserLogsCreateTargetResult> CreateTargetAsync(CancellationToken cancellationToken)
        {
            Calls.Add("CreateTarget");
            return Task.FromResult(new BrowserLogsCreateTargetResult { TargetId = CreatedTargetId });
        }
 
        public Task<BrowserLogsGetTargetsResult> GetTargetsAsync(CancellationToken cancellationToken)
        {
            Calls.Add("GetTargets");
            return Task.FromResult(new BrowserLogsGetTargetsResult { TargetInfos = TargetInfos });
        }
 
        public Task<BrowserLogsAttachToTargetResult> AttachToTargetAsync(string targetId, CancellationToken cancellationToken)
        {
            Calls.Add($"Attach:{targetId}");
            return Task.FromResult(new BrowserLogsAttachToTargetResult { SessionId = AttachSessionId });
        }
 
        public Task<BrowserLogsCommandAck> CloseTargetAsync(string targetId, CancellationToken cancellationToken)
        {
            Calls.Add($"CloseTarget:{targetId}");
            return Task.FromResult(BrowserLogsCommandAck.Instance);
        }
 
        public Task<BrowserLogsCommandAck> EnableTargetDiscoveryAsync(CancellationToken cancellationToken)
        {
            Calls.Add("EnableTargetDiscovery");
            return Task.FromResult(BrowserLogsCommandAck.Instance);
        }
 
        public Task EnablePageInstrumentationAsync(string sessionId, CancellationToken cancellationToken)
        {
            Calls.Add($"EnablePageInstrumentation:{sessionId}");
            _instrumentationEnabled.TrySetResult();
            return Task.CompletedTask;
        }
 
        public Task<BrowserLogsCommandAck> NavigateAsync(string sessionId, Uri url, CancellationToken cancellationToken)
        {
            Calls.Add($"Navigate:{sessionId}:{url}");
            return Task.FromResult(BrowserLogsCommandAck.Instance);
        }
 
        public async Task<BrowserLogsCaptureScreenshotResult> CaptureScreenshotAsync(string sessionId, CancellationToken cancellationToken)
        {
            Calls.Add($"CaptureScreenshot:{sessionId}");
            ScreenshotCaptureStarted.TrySetResult();
 
            if (WaitForScreenshotCancellation)
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
            }
 
            return new BrowserLogsCaptureScreenshotResult { Data = ScreenshotData };
        }
 
        public void SetEventHandler(Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler)
        {
            _eventHandler = eventHandler;
        }
 
        public ValueTask RaiseEventAsync(BrowserLogsCdpProtocolEvent protocolEvent)
        {
            return _eventHandler is null
                ? throw new InvalidOperationException("The fake connection is not connected.")
                : _eventHandler(protocolEvent);
        }
 
        public void FailCompletion(Exception exception)
        {
            _completionSource.TrySetException(exception);
        }
 
        public ValueTask DisposeAsync()
        {
            Disposed = true;
            return ValueTask.CompletedTask;
        }
    }
}