// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Data; using System.Text.RegularExpressions; using System.Diagnostics; using System.Text.Json; using System.Threading.Tasks; using Ignitor; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using Xunit; using Xunit.Abstractions; namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { public class ComponentHubReliabilityTest : IClassFixture, IDisposable { private static readonly TimeSpan DefaultLatencyTimeout = TimeSpan.FromSeconds(Debugger.IsAttached ? 60 : 10); private readonly AspNetSiteServerFixture _serverFixture; public ComponentHubReliabilityTest(AspNetSiteServerFixture serverFixture, ITestOutputHelper output) { _serverFixture = serverFixture; Output = output; serverFixture.BuildWebHostMethod = TestServer.Program.BuildWebHost; CreateDefaultConfiguration(); } public BlazorClient Client { get; set; } public ITestOutputHelper Output { get; set; } private IList Batches { get; set; } = new List(); private IList Errors { get; set; } = new List(); private ConcurrentQueue Logs { get; set; } = new ConcurrentQueue(); public TestSink TestSink { get; set; } private void CreateDefaultConfiguration() { Client = new BlazorClient() { DefaultLatencyTimeout = DefaultLatencyTimeout }; Client.RenderBatchReceived += (id, data) => Batches.Add(new Batch(id, data)); Client.OnCircuitError += (error) => Errors.Add(error); Client.LoggerProvider = new XunitLoggerProvider(Output); Client.FormatError = (error) => { var logs = string.Join(Environment.NewLine, Logs); return new Exception(error + Environment.NewLine + logs); }; _ = _serverFixture.RootUri; // this is needed for the side-effects of getting the URI. TestSink = _serverFixture.Host.Services.GetRequiredService(); TestSink.MessageLogged += LogMessages; } private void LogMessages(WriteContext context) { var log = new LogMessage(context.LogLevel, context.Message, context.Exception); Logs.Enqueue(log); Output.WriteLine(log.ToString()); } [Fact] public async Task CannotStartMultipleCircuits() { // Arrange var expectedError = "The circuit host '.*?' has already been initialized."; var rootUri = _serverFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, prerendered: false), "Couldn't connect to the app"); Assert.Single(Batches); // Act await Client.ExpectCircuitErrorAndDisconnect(() => Client.HubConnection.SendAsync( "StartCircuit", baseUri, baseUri + "/home")); // Assert var actualError = Assert.Single(Errors); Assert.Matches(expectedError, actualError); Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); } [Fact] public async Task CannotStartCircuitWithNullData() { // Arrange var expectedError = "The uris provided are invalid."; var rootUri = _serverFixture.RootUri; var uri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(uri, prerendered: false, connectAutomatically: false), "Couldn't connect to the app"); // Act await Client.ExpectCircuitErrorAndDisconnect(() => Client.HubConnection.SendAsync("StartCircuit", null, null)); // Assert var actualError = Assert.Single(Errors); Assert.Matches(expectedError, actualError); Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); } // This is a hand-chosen example of something that will cause an exception in creating the circuit host. // We want to test this case so that we know what happens when creating the circuit host blows up. [Fact] public async Task StartCircuitCausesInitializationError() { // Arrange var expectedError = "The circuit failed to initialize."; var rootUri = _serverFixture.RootUri; var uri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(uri, prerendered: false, connectAutomatically: false), "Couldn't connect to the app"); // Act // // These are valid URIs by the BaseUri doesn't contain the Uri - so it fails to initialize. await Client.ExpectCircuitErrorAndDisconnect(() => Client.HubConnection.SendAsync("StartCircuit", uri, "http://example.com")); // Assert var actualError = Assert.Single(Errors); Assert.Matches(expectedError, actualError); Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); } [Fact] public async Task CannotInvokeJSInteropBeforeInitialization() { // Arrange var expectedError = "Circuit not initialized."; var rootUri = _serverFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, prerendered: false, connectAutomatically: false)); Assert.Empty(Batches); // Act await Client.ExpectCircuitErrorAndDisconnect(() => Client.HubConnection.SendAsync( "BeginInvokeDotNetFromJS", "", "", "", 0, "")); // Assert var actualError = Assert.Single(Errors); Assert.Equal(expectedError, actualError); Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); Assert.Contains(Logs, l => (l.LogLevel, l.Message) == (LogLevel.Debug, "Call to 'BeginInvokeDotNetFromJS' received before the circuit host initialization")); } [Fact] public async Task CannotInvokeJSInteropCallbackCompletionsBeforeInitialization() { // Arrange var expectedError = "Circuit not initialized."; var rootUri = _serverFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, prerendered: false, connectAutomatically: false)); Assert.Empty(Batches); // Act await Client.ExpectCircuitErrorAndDisconnect(() => Client.HubConnection.SendAsync( "EndInvokeJSFromDotNet", 3, true, "[]")); // Assert var actualError = Assert.Single(Errors); Assert.Equal(expectedError, actualError); Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); Assert.Contains(Logs, l => (l.LogLevel, l.Message) == (LogLevel.Debug, "Call to 'EndInvokeJSFromDotNet' received before the circuit host initialization")); } [Fact] public async Task CannotDispatchBrowserEventsBeforeInitialization() { // Arrange var expectedError = "Circuit not initialized."; var rootUri = _serverFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, prerendered: false, connectAutomatically: false)); Assert.Empty(Batches); // Act await Client.ExpectCircuitErrorAndDisconnect(() => Client.HubConnection.SendAsync( "DispatchBrowserEvent", "", "")); // Assert var actualError = Assert.Single(Errors); Assert.Equal(expectedError, actualError); Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); Assert.Contains(Logs, l => (l.LogLevel, l.Message) == (LogLevel.Debug, "Call to 'DispatchBrowserEvent' received before the circuit host initialization")); } private async Task GoToTestComponent(IList batches) { var rootUri = _serverFixture.RootUri; Assert.True(await Client.ConnectAsync(new Uri(rootUri, "/subdir"), prerendered: false), "Couldn't connect to the app"); Assert.Single(batches); await Client.SelectAsync("test-selector-select", "BasicTestApp.CounterComponent"); Assert.Equal(2, batches.Count); } [Fact] public async Task DispatchingAnInvalidEventArgument_DoesNotProduceWarnings() { // Arrange var expectedError = $"There was an unhandled exception on the current circuit, so this circuit will be terminated. For more details turn on " + $"detailed exceptions in 'CircuitOptions.DetailedErrors'. Bad input data."; var eventDescriptor = Serialize(new WebEventDescriptor() { BrowserRendererId = 0, EventHandlerId = 3, EventArgsType = "mouse", }); await GoToTestComponent(Batches); Assert.Equal(2, Batches.Count); // Act await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync( "DispatchBrowserEvent", eventDescriptor, "{sadfadsf]")); // Assert var actualError = Assert.Single(Errors); Assert.Equal(expectedError, actualError); Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); Assert.Contains(Logs, l => (l.LogLevel, l.Exception?.Message) == (LogLevel.Debug, "There was an error parsing the event arguments. EventId: '3'.")); } [Fact] public async Task DispatchingAnInvalidEvent_DoesNotTriggerWarnings() { // Arrange var expectedError = $"There was an unhandled exception on the current circuit, so this circuit will be terminated. For more details turn on " + $"detailed exceptions in 'CircuitOptions.DetailedErrors'. Failed to dispatch event."; var eventDescriptor = Serialize(new WebEventDescriptor() { BrowserRendererId = 0, EventHandlerId = 1990, EventArgsType = "mouse", }); var eventArgs = new UIMouseEventArgs { Type = "click", Detail = 1, ScreenX = 47, ScreenY = 258, ClientX = 47, ClientY = 155, }; await GoToTestComponent(Batches); Assert.Equal(2, Batches.Count); // Act await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync( "DispatchBrowserEvent", eventDescriptor, Serialize(eventArgs))); // Assert var actualError = Assert.Single(Errors); Assert.Equal(expectedError, actualError); Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); Assert.Contains(Logs, l => (l.LogLevel, l.Message, l.Exception?.Message) == (LogLevel.Debug, "There was an error dispatching the event '1990' to the application.", "There is no event handler associated with this event. EventId: '1990'. (Parameter 'eventHandlerId')")); } [Fact] public async Task DispatchingAnInvalidRenderAcknowledgement_DoesNotTriggerWarnings() { // Arrange var expectedError = $"There was an unhandled exception on the current circuit, so this circuit will be terminated. For more details turn on " + $"detailed exceptions in 'CircuitOptions.DetailedErrors'. Failed to complete render batch '1846'."; await GoToTestComponent(Batches); Assert.Equal(2, Batches.Count); Client.ConfirmRenderBatch = false; await Client.ClickAsync("counter"); // Act await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync( "OnRenderCompleted", 1846, null)); // Assert var actualError = Assert.Single(Errors); Assert.Equal(expectedError, actualError); Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); Assert.Contains(Logs, l => (l.LogLevel, l.Message, l.Exception?.Message) == (LogLevel.Debug, $"Failed to complete render batch '1846' in circuit host '{Client.CircuitId}'.", "Received an acknowledgement for batch with id '1846' when the last batch produced was '4'.")); } [Fact] public async Task CannotInvokeOnRenderCompletedBeforeInitialization() { // Arrange var expectedError = "Circuit not initialized."; var rootUri = _serverFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, prerendered: false, connectAutomatically: false)); Assert.Empty(Batches); // Act await Client.ExpectCircuitErrorAndDisconnect(() => Client.HubConnection.SendAsync( "OnRenderCompleted", 5, null)); // Assert var actualError = Assert.Single(Errors); Assert.Equal(expectedError, actualError); Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); Assert.Contains(Logs, l => (l.LogLevel, l.Message) == (LogLevel.Debug, "Call to 'OnRenderCompleted' received before the circuit host initialization")); } [Fact] public async Task CannotInvokeOnLocationChangedBeforeInitialization() { // Arrange var expectedError = "Circuit not initialized."; var rootUri = _serverFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, prerendered: false, connectAutomatically: false)); Assert.Empty(Batches); // Act await Client.ExpectCircuitErrorAndDisconnect(() => Client.HubConnection.SendAsync( "OnLocationChanged", baseUri.AbsoluteUri, false)); // Assert var actualError = Assert.Single(Errors); Assert.Equal(expectedError, actualError); Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); Assert.Contains(Logs, l => (l.LogLevel, l.Message) == (LogLevel.Debug, "Call to 'OnLocationChanged' received before the circuit host initialization")); } [Fact] public async Task OnLocationChanged_ReportsDebugForExceptionInValidation() { // Arrange var expectedError = "There was an unhandled exception on the current circuit, so this circuit will be terminated. " + "For more details turn on detailed exceptions in 'CircuitOptions.DetailedErrors'. " + "Location change to 'http://example.com' failed."; var rootUri = _serverFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, prerendered: false), "Couldn't connect to the app"); Assert.Single(Batches); // Act await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync( "OnLocationChanged", "http://example.com", false)); // Assert var actualError = Assert.Single(Errors); Assert.Equal(expectedError, actualError); Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); Assert.Contains(Logs, l => { return (l.LogLevel, l.Message) == (LogLevel.Debug, $"Location change to 'http://example.com' in circuit '{Client.CircuitId}' failed."); }); } [Fact] public async Task OnLocationChanged_ReportsErrorForExceptionInUserCode() { // Arrange var expectedError = "There was an unhandled exception on the current circuit, so this circuit will be terminated. " + "For more details turn on detailed exceptions in 'CircuitOptions.DetailedErrors'. " + "Location change failed."; var rootUri = _serverFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, prerendered: false), "Couldn't connect to the app"); Assert.Single(Batches); await Client.SelectAsync("test-selector-select", "BasicTestApp.NavigationFailureComponent"); // Act await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync( "OnLocationChanged", new Uri(baseUri, "/test").AbsoluteUri, false)); // Assert var actualError = Assert.Single(Errors); Assert.Equal(expectedError, actualError); Assert.Contains(Logs, l => { return (l.LogLevel, l.Message) == (LogLevel.Error, $"Location change to '{new Uri(_serverFixture.RootUri,"/test")}' in circuit '{Client.CircuitId}' failed."); }); } [Theory] [InlineData("constructor-throw")] [InlineData("attach-throw")] [InlineData("setparameters-sync-throw")] [InlineData("setparameters-async-throw")] [InlineData("render-throw")] [InlineData("afterrender-sync-throw")] [InlineData("afterrender-async-throw")] public async Task ComponentLifecycleMethodThrowsExceptionTerminatesTheCircuit(string id) { // Arrange var expectedError = "Unhandled exception in circuit .*"; var rootUri = _serverFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, prerendered: false), "Couldn't connect to the app"); Assert.Single(Batches); await Client.SelectAsync("test-selector-select", "BasicTestApp.ReliabilityComponent"); // Act await Client.ExpectCircuitError(async () => { await Client.ClickAsync(id, expectRenderBatch: false); }); // Now if you try to click again, you will get *forcibly* disconnected for trying to talk to // a circuit that's gone. await Client.ExpectCircuitErrorAndDisconnect(async () => { await Assert.ThrowsAsync(async () => await Client.ClickAsync(id, expectRenderBatch: false)); }); // Checking logs at the end to avoid race condition. Assert.Contains( Logs, e => LogLevel.Error == e.LogLevel && Regex.IsMatch(e.Message, expectedError)); } [Fact] public async Task ComponentDisposeMethodThrowsExceptionTerminatesTheCircuit() { // Arrange var expectedError = "Unhandled exception in circuit .*"; var rootUri = _serverFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, prerendered: false), "Couldn't connect to the app"); Assert.Single(Batches); await Client.SelectAsync("test-selector-select", "BasicTestApp.ReliabilityComponent"); // Act - show then hide await Client.ClickAsync("dispose-throw"); await Client.ExpectCircuitError(async () => { await Client.ClickAsync("dispose-throw", expectRenderBatch: false); }); // Now if you try to click again, you will get *forcibly* disconnected for trying to talk to // a circuit that's gone. await Client.ExpectCircuitErrorAndDisconnect(async () => { await Assert.ThrowsAsync(async () => await Client.ClickAsync("dispose-throw", expectRenderBatch: false)); }); // Checking logs at the end to avoid race condition. Assert.Contains( Logs, e => LogLevel.Error == e.LogLevel && Regex.IsMatch(e.Message, expectedError)); } public void Dispose() { TestSink.MessageLogged -= LogMessages; } private string Serialize(T browserEventDescriptor) => JsonSerializer.Serialize(browserEventDescriptor, TestJsonSerializerOptionsProvider.Options); [DebuggerDisplay("{LogLevel.ToString(),nq} - {Message ?? \"null\",nq} - {Exception?.Message,nq}")] private class LogMessage { public LogMessage(LogLevel logLevel, string message, Exception exception) { LogLevel = logLevel; Message = message; Exception = exception; } public LogLevel LogLevel { get; set; } public string Message { get; set; } public Exception Exception { get; set; } public override string ToString() { return $"{LogLevel}: {Message}{(Exception != null ? Environment.NewLine : "")}{Exception}"; } } private class Batch { public Batch(int id, byte[] data) { Id = id; Data = data; } public int Id { get; } public byte[] Data { get; } } } }