diff --git a/Lombiq.Tests.UI.Samples/Readme.md b/Lombiq.Tests.UI.Samples/Readme.md index bdecd399d..6d0db0d4e 100644 --- a/Lombiq.Tests.UI.Samples/Readme.md +++ b/Lombiq.Tests.UI.Samples/Readme.md @@ -30,6 +30,7 @@ For general details about and on using the Toolbox see the [root Readme](../Read - [Interactive mode](Tests/InteractiveModeTests.cs) - [Security scanning](Tests/SecurityScanningTests.cs) - [Testing remote apps](Tests/RemoteTests.cs) +- [Duplicated SQL query detector](Tests/DuplicatedSqlQueryDetectorTests.cs) ## Adding new tutorials diff --git a/Lombiq.Tests.UI.Samples/Tests/DuplicatedSqlQueryDetectorTests.cs b/Lombiq.Tests.UI.Samples/Tests/DuplicatedSqlQueryDetectorTests.cs new file mode 100644 index 000000000..6e92cdb14 --- /dev/null +++ b/Lombiq.Tests.UI.Samples/Tests/DuplicatedSqlQueryDetectorTests.cs @@ -0,0 +1,115 @@ +using Lombiq.Tests.UI.Extensions; +using Lombiq.Tests.UI.Services; +using Lombiq.Tests.UI.Services.Counters.Configuration; +using Shouldly; +using System; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace Lombiq.Tests.UI.Samples.Tests; + +// Some times you may want to detect duplicated SQL queries. This can be useful if you want to make sure that your code +// does not execute the same query multiple times, wasting time and computing resources. +public class DuplicatedSqlQueryDetectorTests : UITestBase +{ + public DuplicatedSqlQueryDetectorTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + // This test will fail because the app will read the same command result more times than the configured threshold + // during the Admin page rendering. + [Fact] + public Task PageWithTooManyReadsOnDbDataReaderShouldThrow() => + Should.ThrowAsync(() => + ExecuteTestAfterSetupAsync( + context => context.SignInDirectlyAndGoToDashboardAsync(), + configuration => ConfigureAsync( + configuration, + commandExcludingParametersThreshold: 3, + commandIncludingParametersThreshold: 2, + readerReadThreshold: 0))); + + // This test will fail because the app will execute the same SQL query having same parameter set more times than + // expected during the Admin page rendering. + [Fact] + public Task PageWithTooManySqlQueriesExecutedHavingTheSameParameterSetShouldThrow() => + Should.ThrowAsync(() => + ExecuteTestAfterSetupAsync( + context => context.SignInDirectlyAndGoToDashboardAsync(), + configuration => ConfigureAsync( + configuration, + commandExcludingParametersThreshold: 3, + commandIncludingParametersThreshold: 0, + readerReadThreshold: 2))); + + // This test will fail because the app will execute the same SQL query more times than expected during the Admin + // page rendering. + [Fact] + public Task PageWithTooManySqlQueriesExecutedShouldThrow() => + Should.ThrowAsync(() => + ExecuteTestAfterSetupAsync( + context => context.SignInDirectlyAndGoToDashboardAsync(), + configuration => ConfigureAsync( + configuration, + commandExcludingParametersThreshold: 0, + commandIncludingParametersThreshold: 2, + readerReadThreshold: 2))); + + // This test will pass because not any of the Admin page was loaded where the SQL queries are under monitoring. + [Fact] + public Task PageWithoutDuplicatedSqlQueriesShouldPass() => + ExecuteTestAfterSetupAsync( + context => context.GoToHomePageAsync(onlyIfNotAlreadyThere: false), + configuration => ConfigureAsync(configuration)); + + // This test will pass because counter thresholds are exactly matching with the counter values captured during + // navigating to the Admin dashboard page. + [Fact] + public Task PageWithMatchingCounterThresholdsShouldPass() => + ExecuteTestAfterSetupAsync( + context => context.SignInDirectlyAndGoToDashboardAsync(), + configuration => ConfigureAsync( + configuration, + commandExcludingParametersThreshold: 3, + commandIncludingParametersThreshold: 2, + readerReadThreshold: 2)); + + // We configure the test to throw an exception if a certain counter threshold is exceeded, but only in case of Admin + // pages. + private static Task ConfigureAsync( + OrchardCoreUITestExecutorConfiguration configuration, + int commandExcludingParametersThreshold = 0, + int commandIncludingParametersThreshold = 0, + int readerReadThreshold = 0) + { + // The test is guaranteed to fail so we don't want to retry it needlessly. + configuration.MaxRetryCount = 0; + + var adminCounterConfiguration = new CounterConfiguration + { + ExcludeFilter = OrchardCoreUITestExecutorConfiguration.DefaultCounterExcludeFilter, + SessionThreshold = + { + // Let's enable and configure the counter thresholds for ORM sessions. + IsEnabled = true, + DbCommandExcludingParametersExecutionThreshold = commandExcludingParametersThreshold, + DbCommandIncludingParametersExecutionCountThreshold = commandIncludingParametersThreshold, + DbReaderReadThreshold = readerReadThreshold, + }, + }; + + // Apply the configuration to the Admin pages only. + configuration.CounterConfiguration.AfterSetup.Add( + new RelativeUrlConfigurationKey(new Uri("/Admin", UriKind.Relative), exactMatch: false), + adminCounterConfiguration); + + // Enable the counter subsystem. + configuration.CounterConfiguration.IsEnabled = true; + + return Task.CompletedTask; + } +} + +// END OF TRAINING SECTION: Duplicated SQL query detector. diff --git a/Lombiq.Tests.UI.Samples/Tests/RemoteTests.cs b/Lombiq.Tests.UI.Samples/Tests/RemoteTests.cs index 85673c3e2..ee8c3f7e6 100644 --- a/Lombiq.Tests.UI.Samples/Tests/RemoteTests.cs +++ b/Lombiq.Tests.UI.Samples/Tests/RemoteTests.cs @@ -43,3 +43,4 @@ public Task ExampleDotComShouldWork() => } // END OF TRAINING SECTION: Remote tests. +// NEXT STATION: Head over to DuplicatedSqlQueryDetectorTests.cs. diff --git a/Lombiq.Tests.UI/Exceptions/CounterThresholdException.cs b/Lombiq.Tests.UI/Exceptions/CounterThresholdException.cs new file mode 100644 index 000000000..c838b0502 --- /dev/null +++ b/Lombiq.Tests.UI/Exceptions/CounterThresholdException.cs @@ -0,0 +1,68 @@ +using Lombiq.Tests.UI.Services.Counters; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Lombiq.Tests.UI.Exceptions; + +public class CounterThresholdException : Exception +{ + public CounterThresholdException() + { + } + + public CounterThresholdException(string message) + : this(probe: null, counter: null, value: null, message) + { + } + + public CounterThresholdException(string message, Exception innerException) + : this(probe: null, counter: null, value: null, message, innerException) + { + } + + public CounterThresholdException( + ICounterProbe probe, + ICounterKey counter, + ICounterValue value) + : this(probe, counter, value, message: null, innerException: null) + { + } + + public CounterThresholdException( + ICounterProbe probe, + ICounterKey counter, + ICounterValue value, + string message) + : this(probe, counter, value, message, innerException: null) + { + } + + public CounterThresholdException( + ICounterProbe probe, + ICounterKey counter, + ICounterValue value, + string message, + Exception innerException) + : base(FormatMessage(probe, counter, value, message), innerException) + { + } + + private static string FormatMessage( + ICounterProbe probe, + ICounterKey counter, + ICounterValue value, + string message) + { + var builder = new StringBuilder() + .AppendLine() + .AppendLine("A counter value has crossed the configured threshold level. Details:"); + + if (probe is not null) builder.AppendLine(probe.DumpHeadline()); + counter?.Dump().ForEach(line => builder.AppendLine(line)); + value?.Dump().ForEach(line => builder.AppendLine(line)); + if (!string.IsNullOrEmpty(message)) builder.AppendLine(message); + + return builder.ToString(); + } +} diff --git a/Lombiq.Tests.UI/Extensions/NavigationUITestContextExtensions.cs b/Lombiq.Tests.UI/Extensions/NavigationUITestContextExtensions.cs index d2b57094a..ee254ee9d 100644 --- a/Lombiq.Tests.UI/Extensions/NavigationUITestContextExtensions.cs +++ b/Lombiq.Tests.UI/Extensions/NavigationUITestContextExtensions.cs @@ -3,6 +3,7 @@ using Lombiq.Tests.UI.Helpers; using Lombiq.Tests.UI.Pages; using Lombiq.Tests.UI.Services; +using Lombiq.Tests.UI.Services.Counters; using Newtonsoft.Json; using OpenQA.Selenium; using OpenQA.Selenium.Interactions; @@ -57,7 +58,10 @@ public static Task GoToAbsoluteUrlAsync(this UITestContext context, Uri absolute await context.Configuration.Events.BeforeNavigation .InvokeAsync(eventHandler => eventHandler(context, absoluteUri)); - context.Driver.Navigate().GoToUrl(absoluteUri); + using (new NavigationProbe(context.CounterDataCollector, absoluteUri)) + { + context.Driver.Navigate().GoToUrl(absoluteUri); + } await context.Configuration.Events.AfterNavigation .InvokeAsync(eventHandler => eventHandler(context, absoluteUri)); diff --git a/Lombiq.Tests.UI/Models/UITestContextParameters.cs b/Lombiq.Tests.UI/Models/UITestContextParameters.cs new file mode 100644 index 000000000..ca4ca43c5 --- /dev/null +++ b/Lombiq.Tests.UI/Models/UITestContextParameters.cs @@ -0,0 +1,16 @@ +using Lombiq.Tests.UI.SecurityScanning; +using Lombiq.Tests.UI.Services; + +namespace Lombiq.Tests.UI.Models; + +internal sealed record UITestContextParameters +{ + public string Id { get; init; } + public UITestManifest TestManifest { get; init; } + public OrchardCoreUITestExecutorConfiguration Configuration { get; init; } + public IWebApplicationInstance Application { get; init; } + public AtataScope Scope { get; init; } + public RunningContextContainer RunningContextContainer { get; init; } + public ZapManager ZapManager { get; init; } + public CounterDataCollector CounterDataCollector { get; init; } +} diff --git a/Lombiq.Tests.UI/OrchardCoreUITestBase.cs b/Lombiq.Tests.UI/OrchardCoreUITestBase.cs index 778ad6744..1de79cbf0 100644 --- a/Lombiq.Tests.UI/OrchardCoreUITestBase.cs +++ b/Lombiq.Tests.UI/OrchardCoreUITestBase.cs @@ -334,8 +334,12 @@ protected virtual async Task ExecuteTestAsync( if (changeConfigurationAsync != null) await changeConfigurationAsync(configuration); await ExecuteOrchardCoreTestAsync( - (configuration, contextId) => - new OrchardCoreInstance(configuration.OrchardCoreConfiguration, contextId, configuration.TestOutputHelper), + (configuration, contextId, counterDataCollector) => + new OrchardCoreInstance( + configuration.OrchardCoreConfiguration, + contextId, + configuration.TestOutputHelper, + counterDataCollector), testManifest, configuration); } diff --git a/Lombiq.Tests.UI/RemoteUITestBase.cs b/Lombiq.Tests.UI/RemoteUITestBase.cs index e4984f0d9..4f4d17a77 100644 --- a/Lombiq.Tests.UI/RemoteUITestBase.cs +++ b/Lombiq.Tests.UI/RemoteUITestBase.cs @@ -71,6 +71,6 @@ async Task BaseUriVisitingTest(UITestContext context) if (changeConfigurationAsync != null) await changeConfigurationAsync(configuration); - await ExecuteOrchardCoreTestAsync((_, _) => new RemoteInstance(baseUri), testManifest, configuration); + await ExecuteOrchardCoreTestAsync((_, _, _) => new RemoteInstance(baseUri), testManifest, configuration); } } diff --git a/Lombiq.Tests.UI/Services/CounterDataCollector.cs b/Lombiq.Tests.UI/Services/CounterDataCollector.cs new file mode 100644 index 000000000..6eef50df0 --- /dev/null +++ b/Lombiq.Tests.UI/Services/CounterDataCollector.cs @@ -0,0 +1,90 @@ +using Lombiq.Tests.UI.Services.Counters; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Xunit.Abstractions; + +namespace Lombiq.Tests.UI.Services; + +public sealed class CounterDataCollector : CounterProbeBase, ICounterDataCollector +{ + private readonly ITestOutputHelper _testOutputHelper; + private readonly ConcurrentBag _probes = []; + private readonly ConcurrentBag _postponedCounterExceptions = []; + public override bool IsAttached => true; + public Action AssertCounterData { get; set; } + public string Phase { get; set; } + public bool IsEnabled { get; set; } + + public CounterDataCollector(ITestOutputHelper testOutputHelper) => + _testOutputHelper = testOutputHelper; + + public void AttachProbe(ICounterProbe probe) + { + if (!IsEnabled) + { + return; + } + + probe.CaptureCompleted = ProbeCaptureCompleted; + _probes.Add(probe); + } + + public void Reset() + { + _probes.Clear(); + _postponedCounterExceptions.Clear(); + Clear(); + } + + public override void Increment(ICounterKey counter) + { + if (!IsEnabled) + { + return; + } + + _probes.Where(probe => probe.IsAttached) + .ForEach(probe => probe.Increment(counter)); + base.Increment(counter); + } + + public override string DumpHeadline() => $"{nameof(CounterDataCollector)}, Phase = {Phase}"; + + public override IEnumerable Dump() + { + var lines = new List + { + DumpHeadline(), + }; + + lines.AddRange(DumpSummary().Select(line => $"\t{line}")); + + return lines; + } + + public void AssertCounter(ICounterProbe probe) => AssertCounterData?.Invoke(this, probe); + + public void AssertCounter() + { + if (!IsEnabled) + { + return; + } + + if (!_postponedCounterExceptions.IsEmpty) + { + throw new AggregateException( + "There were exceptions out of the test execution context.", + _postponedCounterExceptions); + } + + AssertCounter(this); + } + + public void PostponeCounterException(Exception exception) => _postponedCounterExceptions.Add(exception); + + private void ProbeCaptureCompleted(ICounterProbe probe) => + probe.Dump().ForEach(_testOutputHelper.WriteLine); +} diff --git a/Lombiq.Tests.UI/Services/Counters/Configuration/CounterConfiguration.cs b/Lombiq.Tests.UI/Services/Counters/Configuration/CounterConfiguration.cs new file mode 100644 index 000000000..23520089c --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Configuration/CounterConfiguration.cs @@ -0,0 +1,34 @@ +using System; + +namespace Lombiq.Tests.UI.Services.Counters.Configuration; + +public class CounterConfiguration +{ + /// + /// Gets or sets the counter assertion method. + /// + public Action AssertCounterData { get; set; } + + /// + /// Gets or sets the exclude filter. Can be used to exclude counted values before assertion. + /// + public Func ExcludeFilter { get; set; } + + /// + /// Gets or sets threshold configuration used under navigation requests. See: + /// . + /// See: . + /// + public CounterThresholdConfiguration NavigationThreshold { get; set; } = new(); + + /// + /// Gets or sets threshold configuration used per lifetime. See: + /// . + /// + public CounterThresholdConfiguration SessionThreshold { get; set; } = new(); + + /// + /// Gets or sets threshold configuration used per page load. See: . + /// + public CounterThresholdConfiguration PageLoadThreshold { get; set; } = new(); +} diff --git a/Lombiq.Tests.UI/Services/Counters/Configuration/CounterConfigurations.cs b/Lombiq.Tests.UI/Services/Counters/Configuration/CounterConfigurations.cs new file mode 100644 index 000000000..bf9beee5c --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Configuration/CounterConfigurations.cs @@ -0,0 +1,19 @@ +namespace Lombiq.Tests.UI.Services.Counters.Configuration; + +public class CounterConfigurations +{ + /// + /// Gets or sets a value indicating whether the whole counter infrastructure is enabled. + /// + public bool IsEnabled { get; set; } + + /// + /// Gets the counter configuration used in the setup phase of the web application. + /// + public PhaseCounterConfiguration Setup { get; } = new(); + + /// + /// Gets the counter configuration used in the running phase of the web application. + /// + public RunningPhaseCounterConfiguration AfterSetup { get; } = []; +} diff --git a/Lombiq.Tests.UI/Services/Counters/Configuration/CounterThresholdConfiguration.cs b/Lombiq.Tests.UI/Services/Counters/Configuration/CounterThresholdConfiguration.cs new file mode 100644 index 000000000..d4230a4f2 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Configuration/CounterThresholdConfiguration.cs @@ -0,0 +1,42 @@ +namespace Lombiq.Tests.UI.Services.Counters.Configuration; + +public class CounterThresholdConfiguration +{ + /// + /// Gets or sets a value indicating whether the current threshold configuration and checking is enabled. + /// + public bool IsEnabled { get; set; } + + /// + /// Gets or sets the threshold for the count of executions, with the + /// query only counted as a duplicate if both its text () and + /// parameters () match. See + /// for counting using only the command text. + /// + public int DbCommandIncludingParametersExecutionCountThreshold { get; set; } = 1; + + /// + /// Gets or sets the threshold for the count of executions, with the + /// query counted as a duplicate if its text () matches. + /// Parameters () are not taken into account. See + /// for counting using also the parameters. + /// + public int DbCommandExcludingParametersExecutionThreshold { get; set; } = 1; + + /// + /// Gets or sets the threshold of readings of s. Uses + /// and + /// for counting. + /// + /// + /// + /// Use this to set the maximum number of reads allowed on a instance. + /// The counter infrastructure counts the and + /// calls, also the + /// calls are counted on + /// instance returned by the + /// . + /// + /// + public int DbReaderReadThreshold { get; set; } +} diff --git a/Lombiq.Tests.UI/Services/Counters/Configuration/PhaseCounterConfiguration.cs b/Lombiq.Tests.UI/Services/Counters/Configuration/PhaseCounterConfiguration.cs new file mode 100644 index 000000000..35e5a8da0 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Configuration/PhaseCounterConfiguration.cs @@ -0,0 +1,14 @@ +namespace Lombiq.Tests.UI.Services.Counters.Configuration; + +public class PhaseCounterConfiguration : CounterConfiguration +{ + /// + /// Gets or sets threshold configuration used under the app phase (setup, running) lifetime. + /// + public CounterThresholdConfiguration PhaseThreshold { get; set; } = new CounterThresholdConfiguration + { + DbCommandIncludingParametersExecutionCountThreshold = 22, + DbCommandExcludingParametersExecutionThreshold = 44, + DbReaderReadThreshold = 11, + }; +} diff --git a/Lombiq.Tests.UI/Services/Counters/Configuration/RelativeUrlConfigurationKey.cs b/Lombiq.Tests.UI/Services/Counters/Configuration/RelativeUrlConfigurationKey.cs new file mode 100644 index 000000000..3a59811a5 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Configuration/RelativeUrlConfigurationKey.cs @@ -0,0 +1,22 @@ +using Lombiq.Tests.UI.Services.Counters.Extensions; +using System; + +namespace Lombiq.Tests.UI.Services.Counters.Configuration; + +public sealed class RelativeUrlConfigurationKey : IRelativeUrlConfigurationKey +{ + public Uri Url { get; private init; } + public bool ExactMatch { get; private init; } + + public RelativeUrlConfigurationKey(Uri url, bool exactMatch = true) + { + Url = url; + ExactMatch = exactMatch; + } + + public bool Equals(ICounterConfigurationKey other) => + this.EqualsWith(other as IRelativeUrlConfigurationKey); + + public override int GetHashCode() => + (Url, ExactMatch).GetHashCode(); +} diff --git a/Lombiq.Tests.UI/Services/Counters/Configuration/RunningPhaseCounterConfiguration.cs b/Lombiq.Tests.UI/Services/Counters/Configuration/RunningPhaseCounterConfiguration.cs new file mode 100644 index 000000000..64bcd9740 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Configuration/RunningPhaseCounterConfiguration.cs @@ -0,0 +1,44 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Lombiq.Tests.UI.Services.Counters.Configuration; + +public class RunningPhaseCounterConfiguration : PhaseCounterConfiguration, + IDictionary +{ + private readonly IDictionary _configurations = + new ConcurrentDictionary(); + + public CounterConfiguration this[ICounterConfigurationKey key] + { + get => _configurations[key]; + set => _configurations[key] = value; + } + + public ICollection Keys => _configurations.Keys; + + public ICollection Values => _configurations.Values; + + public int Count => _configurations.Count; + + public bool IsReadOnly => false; + + public void Add(ICounterConfigurationKey key, CounterConfiguration value) => _configurations.Add(key, value); + public void Add(KeyValuePair item) => _configurations.Add(item); + public void Clear() => _configurations.Clear(); + public bool Contains(KeyValuePair item) => + _configurations.Contains(item); + public bool ContainsKey(ICounterConfigurationKey key) => _configurations.ContainsKey(key); + public void CopyTo(KeyValuePair[] array, int arrayIndex) => + _configurations.CopyTo(array, arrayIndex); + public IEnumerator> GetEnumerator() => + _configurations.GetEnumerator(); + public bool Remove(ICounterConfigurationKey key) => _configurations.Remove(key); + public bool Remove(KeyValuePair item) => + _configurations.Remove(item); + public bool TryGetValue(ICounterConfigurationKey key, [MaybeNullWhen(false)] out CounterConfiguration value) => + _configurations.TryGetValue(key, out value); + IEnumerator IEnumerable.GetEnumerator() => _configurations.GetEnumerator(); +} diff --git a/Lombiq.Tests.UI/Services/Counters/CounterKey.cs b/Lombiq.Tests.UI/Services/Counters/CounterKey.cs new file mode 100644 index 000000000..97e89484a --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/CounterKey.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace Lombiq.Tests.UI.Services.Counters; + +// The Equals must be implemented in consumer classes. +#pragma warning disable S4035 // Classes implementing "IEquatable" should be sealed +public abstract class CounterKey : ICounterKey +#pragma warning restore S4035 // Classes implementing "IEquatable" should be sealed +{ + public abstract string DisplayName { get; } + public abstract bool Equals(ICounterKey other); + protected abstract int HashCode(); + public override bool Equals(object obj) => Equals(obj as ICounterKey); + public override int GetHashCode() => HashCode(); + public abstract IEnumerable Dump(); +} diff --git a/Lombiq.Tests.UI/Services/Counters/CounterProbe.cs b/Lombiq.Tests.UI/Services/Counters/CounterProbe.cs new file mode 100644 index 000000000..f75207816 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/CounterProbe.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Lombiq.Tests.UI.Services.Counters; + +public abstract class CounterProbe : CounterProbeBase, IDisposable +{ + private bool _disposed; + + public override bool IsAttached => !_disposed; + public ICounterDataCollector CounterDataCollector { get; init; } + + public override IEnumerable Dump() + { + var lines = new List + { + DumpHeadline(), + }; + + lines.AddRange( + Counters.SelectMany(entry => + entry.Key.Dump() + .Concat(entry.Value.Dump().Select(line => $"\t{line}"))) + .Concat(DumpSummary()) + .Select(line => $"\t{line}")); + + return lines; + } + + protected CounterProbe(ICounterDataCollector counterDataCollector) + { + CounterDataCollector = counterDataCollector; + CounterDataCollector.AttachProbe(this); + } + + protected virtual void OnAssertData() => + CounterDataCollector.AssertCounter(this); + + protected virtual void OnDisposing() + { + } + + protected virtual void OnDisposed() + { + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + OnDisposing(); + try { OnAssertData(); } + finally + { + if (disposing) + { + OnDisposed(); + } + + _disposed = true; + OnCaptureCompleted(); + } + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/Lombiq.Tests.UI/Services/Counters/CounterProbeBase.cs b/Lombiq.Tests.UI/Services/Counters/CounterProbeBase.cs new file mode 100644 index 000000000..3ca8091a6 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/CounterProbeBase.cs @@ -0,0 +1,77 @@ +using Lombiq.Tests.UI.Services.Counters.Value; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace Lombiq.Tests.UI.Services.Counters; + +public abstract class CounterProbeBase : ICounterProbe +{ + private readonly ConcurrentDictionary _counters = new(); + + public abstract bool IsAttached { get; } + public Action CaptureCompleted { get; set; } + public IDictionary Counters => _counters; + + protected void Clear() => _counters.Clear(); + + public virtual void Increment(ICounterKey counter) => + _counters.AddOrUpdate( + counter, + new IntegerCounterValue { Value = 1 }, + (_, current) => TryConvertAndUpdate(current, current => current.Value++)); + + public abstract string DumpHeadline(); + + public abstract IEnumerable Dump(); + + public virtual IEnumerable DumpSummary() + { + if (!Counters.Any()) + { + return []; + } + + var lines = new List + { + "Summary:", + }; + + lines.AddRange( + Counters.GroupBy(entry => entry.Key.GetType()) + .SelectMany(keyGroup => + { + var keyGroupLines = new List + { + keyGroup.First().Key.DisplayName, + }; + + var integerCounter = keyGroup.Select(entry => entry.Value) + .OfType() + .Select(value => value.Value) + .Max(); + keyGroupLines.Add($"\tmaximum: {integerCounter.ToTechnicalString()}"); // #spell-check-ignore-line + + return keyGroupLines.Select(line => $"\t{line}"); + })); + + return lines; + } + + protected virtual void OnCaptureCompleted() => + CaptureCompleted?.Invoke(this); + + private static TCounter TryConvertAndUpdate(ICounterValue counter, Action update) + { + if (counter is not TCounter value) + { + throw new ArgumentException( + $"The type of ${nameof(counter)} is not compatible with ${typeof(TCounter).Name}."); + } + + update.Invoke(value); + + return value; + } +} diff --git a/Lombiq.Tests.UI/Services/Counters/Data/CounterDbCommandParameter.cs b/Lombiq.Tests.UI/Services/Counters/Data/CounterDbCommandParameter.cs new file mode 100644 index 000000000..f9c7a5e9a --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Data/CounterDbCommandParameter.cs @@ -0,0 +1,7 @@ +namespace Lombiq.Tests.UI.Services.Counters.Data; + +public class CounterDbCommandParameter(string name, object value) +{ + public string Name { get; set; } = name; + public object Value { get; set; } = value; +} diff --git a/Lombiq.Tests.UI/Services/Counters/Data/DbCommandCounterKey.cs b/Lombiq.Tests.UI/Services/Counters/Data/DbCommandCounterKey.cs new file mode 100644 index 000000000..e07ec02cd --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Data/DbCommandCounterKey.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Lombiq.Tests.UI.Services.Counters.Data; + +public abstract class DbCommandCounterKey : CounterKey +{ + private readonly List _parameters = []; + public string CommandText { get; private set; } + public IEnumerable Parameters => _parameters; + + protected DbCommandCounterKey(string commandText, IEnumerable parameters) + { + _parameters.AddRange(parameters); + CommandText = commandText; + } + + public override bool Equals(ICounterKey other) + { + if (ReferenceEquals(this, other)) return true; + + return other is DbCommandCounterKey otherKey + && other.GetType() == GetType() + && GetType() == otherKey.GetType() + && string.Equals(CommandText, otherKey.CommandText, StringComparison.OrdinalIgnoreCase) + && Parameters.Any() + && Parameters + .Select(param => (param.Name, param.Value)) + .SequenceEqual(otherKey.Parameters.Select(param => (param.Name, param.Value))); + } + + public override IEnumerable Dump() + { + var lines = new List + { + DisplayName, + $"\tQuery: {CommandText}", + }; + + if (Parameters.Any()) + { + var commandParams = Parameters.Select((parameter, index) => + string.Create( + CultureInfo.InvariantCulture, + $"[{index}]{parameter.Name ?? string.Empty} = {parameter.Value?.ToString() ?? "(null)"}")) + .Join(", "); + lines.Add($"\t\tParameters: {commandParams}"); + } + + return lines; + } + + protected override int HashCode() => StringComparer.Ordinal.GetHashCode(CommandText.ToUpperInvariant()); + public override string ToString() => $"[{GetType().Name}] {CommandText}"; +} diff --git a/Lombiq.Tests.UI/Services/Counters/Data/DbCommandExecuteCounterKey.cs b/Lombiq.Tests.UI/Services/Counters/Data/DbCommandExecuteCounterKey.cs new file mode 100644 index 000000000..68f3e4602 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Data/DbCommandExecuteCounterKey.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; + +namespace Lombiq.Tests.UI.Services.Counters.Data; + +public sealed class DbCommandExecuteCounterKey : DbCommandCounterKey +{ + public override string DisplayName => "Database command with parameters execute counter"; + + public DbCommandExecuteCounterKey(string commandText, IEnumerable parameters) + : base(commandText, parameters) + { + } + + public DbCommandExecuteCounterKey(string commandText, params CounterDbCommandParameter[] parameters) + : this(commandText, parameters.AsEnumerable()) + { + } + + public static DbCommandExecuteCounterKey CreateFrom(DbCommand dbCommand) => + new( + dbCommand.CommandText, + dbCommand.Parameters + .OfType() + .Select(parameter => new CounterDbCommandParameter(parameter.ParameterName, parameter.Value))); +} diff --git a/Lombiq.Tests.UI/Services/Counters/Data/DbCommandTextExecuteCounterKey.cs b/Lombiq.Tests.UI/Services/Counters/Data/DbCommandTextExecuteCounterKey.cs new file mode 100644 index 000000000..e8993b6e5 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Data/DbCommandTextExecuteCounterKey.cs @@ -0,0 +1,26 @@ +using System; +using System.Data.Common; + +namespace Lombiq.Tests.UI.Services.Counters.Data; + +public sealed class DbCommandTextExecuteCounterKey : DbCommandCounterKey +{ + public override string DisplayName => "Database command execute counter"; + + public DbCommandTextExecuteCounterKey(string commandText) + : base(commandText, []) + { + } + + public static DbCommandTextExecuteCounterKey CreateFrom(DbCommand dbCommand) => + new(dbCommand.CommandText); + + public override bool Equals(ICounterKey other) + { + if (ReferenceEquals(this, other)) return true; + + return other is DbCommandTextExecuteCounterKey otherKey + && GetType() == otherKey.GetType() + && string.Equals(CommandText, otherKey.CommandText, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Lombiq.Tests.UI/Services/Counters/Data/DbReaderReadCounterKey.cs b/Lombiq.Tests.UI/Services/Counters/Data/DbReaderReadCounterKey.cs new file mode 100644 index 000000000..fd4996360 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Data/DbReaderReadCounterKey.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; + +namespace Lombiq.Tests.UI.Services.Counters.Data; + +public class DbReaderReadCounterKey : DbCommandCounterKey +{ + public override string DisplayName => "Database reader read counter"; + + public DbReaderReadCounterKey(string commandText, IEnumerable parameters) + : base(commandText, parameters) + { + } + + public DbReaderReadCounterKey(string commandText, params CounterDbCommandParameter[] parameters) + : this(commandText, parameters.AsEnumerable()) + { + } + + public static DbReaderReadCounterKey CreateFrom(DbCommand dbCommand) => + new( + dbCommand.CommandText, + dbCommand.Parameters + .OfType() + .Select(parameter => new CounterDbCommandParameter(parameter.ParameterName, parameter.Value))); +} diff --git a/Lombiq.Tests.UI/Services/Counters/Data/ProbedDbCommand.cs b/Lombiq.Tests.UI/Services/Counters/Data/ProbedDbCommand.cs new file mode 100644 index 000000000..7984a32e9 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Data/ProbedDbCommand.cs @@ -0,0 +1,132 @@ +using Lombiq.Tests.UI.Services.Counters.Extensions; +using System; +using System.ComponentModel; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace Lombiq.Tests.UI.Services.Counters.Data; + +// This is required to avoid the component being visible in VS Toolbox. +[DesignerCategory("")] +public class ProbedDbCommand : DbCommand +{ + private readonly ICounterDataCollector _counterDataCollector; + private DbConnection _dbConnection; + + internal DbCommand ProbedCommand { get; private set; } + + // The ProbedDbCommand scope is performance counting. +#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities + public override string CommandText + { + get => ProbedCommand.CommandText; + set => ProbedCommand.CommandText = value; + } +#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities + + public override int CommandTimeout + { + get => ProbedCommand.CommandTimeout; + set => ProbedCommand.CommandTimeout = value; + } + + public override CommandType CommandType + { + get => ProbedCommand.CommandType; + set => ProbedCommand.CommandType = value; + } + + protected override DbConnection DbConnection + { + get => _dbConnection; + set + { + _dbConnection = value; + UnwrapAndAssignConnection(value); + } + } + + protected override DbParameterCollection DbParameterCollection => ProbedCommand.Parameters; + + protected override DbTransaction DbTransaction + { + get => ProbedCommand.Transaction; + set => ProbedCommand.Transaction = value; + } + + public override bool DesignTimeVisible + { + get => ProbedCommand.DesignTimeVisible; + set => ProbedCommand.DesignTimeVisible = value; + } + + public override UpdateRowSource UpdatedRowSource + { + get => ProbedCommand.UpdatedRowSource; + set => ProbedCommand.UpdatedRowSource = value; + } + + public ProbedDbCommand(DbCommand command, DbConnection connection, ICounterDataCollector counterDataCollector) + { + _counterDataCollector = counterDataCollector ?? throw new ArgumentNullException(nameof(counterDataCollector)); + ProbedCommand = command ?? throw new ArgumentNullException(nameof(command)); + + if (connection != null) + { + _dbConnection = connection; + UnwrapAndAssignConnection(connection); + } + } + + public override int ExecuteNonQuery() => + _counterDataCollector.DbCommandExecuteNonQuery(ProbedCommand); + + public override Task ExecuteNonQueryAsync(CancellationToken cancellationToken) => + _counterDataCollector.DbCommandExecuteNonQueryAsync(ProbedCommand, cancellationToken); + + public override object ExecuteScalar() => + _counterDataCollector.DbCommandExecuteScalar(ProbedCommand); + + public override Task ExecuteScalarAsync(CancellationToken cancellationToken) => + _counterDataCollector.DbCommandExecuteScalarAsync(ProbedCommand, cancellationToken); + + public override void Cancel() => ProbedCommand.Cancel(); + + public override void Prepare() => ProbedCommand.Prepare(); + + protected override DbParameter CreateDbParameter() => ProbedCommand.CreateParameter(); + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => + new ProbedDbDataReader( + _counterDataCollector.DbCommandExecuteDbDataReader(ProbedCommand, behavior), + behavior, + this, + _counterDataCollector); + + protected override async Task ExecuteDbDataReaderAsync( + CommandBehavior behavior, + CancellationToken cancellationToken) => + new ProbedDbDataReader( + await _counterDataCollector.DbCommandExecuteDbDataReaderAsync(ProbedCommand, behavior, cancellationToken), + behavior, + this, + _counterDataCollector); + + private void UnwrapAndAssignConnection(DbConnection value) => + ProbedCommand.Connection = value is ProbedDbConnection probedConnection + ? probedConnection.ProbedConnection + : value; + + protected override void Dispose(bool disposing) + { + if (disposing && ProbedCommand != null) + { + ProbedCommand.Dispose(); + } + + ProbedCommand = null; + base.Dispose(disposing); + } +} diff --git a/Lombiq.Tests.UI/Services/Counters/Data/ProbedDbConnection.cs b/Lombiq.Tests.UI/Services/Counters/Data/ProbedDbConnection.cs new file mode 100644 index 000000000..20af572ef --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Data/ProbedDbConnection.cs @@ -0,0 +1,91 @@ +using System; +using System.ComponentModel; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using System.Transactions; + +namespace Lombiq.Tests.UI.Services.Counters.Data; + +// This is required to avoid the component being visible in VS Toolbox. +[DesignerCategory("")] +public class ProbedDbConnection : DbConnection +{ + private readonly ICounterDataCollector _counterDataCollector; + + internal DbConnection ProbedConnection { get; private set; } + + public override string ConnectionString + { + get => ProbedConnection.ConnectionString; + set => ProbedConnection.ConnectionString = value; + } + + public override int ConnectionTimeout => ProbedConnection.ConnectionTimeout; + + public override string Database => ProbedConnection.Database; + + public override string DataSource => ProbedConnection.DataSource; + + public override string ServerVersion => ProbedConnection.ServerVersion; + + public override ConnectionState State => ProbedConnection.State; + + protected override bool CanRaiseEvents => true; + + public ProbedDbConnection(DbConnection connection, ICounterDataCollector counterDataCollector) + { + _counterDataCollector = counterDataCollector ?? throw new ArgumentNullException(nameof(counterDataCollector)); + ProbedConnection = connection ?? throw new ArgumentNullException(nameof(connection)); + ProbedConnection.StateChange += StateChangeHandler; + } + + public override void ChangeDatabase(string databaseName) => + ProbedConnection.ChangeDatabase(databaseName); + + public override void Close() => + ProbedConnection.Close(); + + public override void Open() => + ProbedConnection.Open(); + + public override Task OpenAsync(CancellationToken cancellationToken) => + ProbedConnection.OpenAsync(cancellationToken); + + protected override DbTransaction BeginDbTransaction(System.Data.IsolationLevel isolationLevel) => + ProbedConnection.BeginTransaction(isolationLevel); + + protected virtual DbCommand CreateDbCommand(DbCommand original) => + new ProbedDbCommand(original, this, _counterDataCollector); + + protected override DbCommand CreateDbCommand() => + CreateDbCommand(ProbedConnection.CreateCommand()); + + private void StateChangeHandler(object sender, StateChangeEventArgs stateChangeEventArguments) => + OnStateChange(stateChangeEventArguments); + + public override void EnlistTransaction(Transaction transaction) => + ProbedConnection.EnlistTransaction(transaction); + + public override DataTable GetSchema() => + ProbedConnection.GetSchema(); + + public override DataTable GetSchema(string collectionName) => + ProbedConnection.GetSchema(collectionName); + + public override DataTable GetSchema(string collectionName, string[] restrictionValues) => + ProbedConnection.GetSchema(collectionName, restrictionValues); + + protected override void Dispose(bool disposing) + { + if (disposing && ProbedConnection is not null) + { + ProbedConnection.StateChange -= StateChangeHandler; + ProbedConnection.Dispose(); + } + + base.Dispose(disposing); + ProbedConnection = null; + } +} diff --git a/Lombiq.Tests.UI/Services/Counters/Data/ProbedDbDataReader.cs b/Lombiq.Tests.UI/Services/Counters/Data/ProbedDbDataReader.cs new file mode 100644 index 000000000..a0586233e --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Data/ProbedDbDataReader.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace Lombiq.Tests.UI.Services.Counters.Data; + +// Generic IEnumerable<> interface is not implemented in DbDataReader. +#pragma warning disable CA1010 // Generic interface should also be implemented +public class ProbedDbDataReader : DbDataReader +#pragma warning restore CA1010 // Generic interface should also be implemented +{ + private ICounterDataCollector CounterDataCollector { get; init; } + + private ProbedDbCommand ProbedCommand { get; init; } + + private DbReaderReadCounterKey CounterKey { get; init; } + + internal DbDataReader ProbedReader { get; private set; } + + public CommandBehavior Behavior { get; } + + public override int Depth => ProbedReader.Depth; + + public override int FieldCount => ProbedReader.FieldCount; + + public override bool HasRows => ProbedReader.HasRows; + + public override bool IsClosed => ProbedReader.IsClosed; + + public override int RecordsAffected => ProbedReader.RecordsAffected; + + public override object this[string name] => ProbedReader[name]; + + public override object this[int ordinal] => ProbedReader[ordinal]; + + public ProbedDbDataReader( + DbDataReader reader, + ProbedDbCommand probedCommand, + ICounterDataCollector counterDataCollector) + : this(reader, CommandBehavior.Default, probedCommand, counterDataCollector) { } + + public ProbedDbDataReader( + DbDataReader reader, + CommandBehavior behavior, + ProbedDbCommand probedCommand, + ICounterDataCollector counterDataCollector) + { + CounterDataCollector = counterDataCollector ?? throw new ArgumentNullException(nameof(counterDataCollector)); + ProbedCommand = probedCommand ?? throw new ArgumentNullException(nameof(probedCommand)); + ProbedReader = reader ?? throw new ArgumentNullException(nameof(reader)); + Behavior = behavior; + CounterKey = DbReaderReadCounterKey.CreateFrom(ProbedCommand); + } + + public override bool GetBoolean(int ordinal) => ProbedReader.GetBoolean(ordinal); + + public override byte GetByte(int ordinal) => ProbedReader.GetByte(ordinal); + + public override long GetBytes(int ordinal, long dataOffset, byte[] buffer, int bufferOffset, int length) => + ProbedReader.GetBytes(ordinal, dataOffset, buffer, bufferOffset, length); + + public override char GetChar(int ordinal) => ProbedReader.GetChar(ordinal); + + public override long GetChars(int ordinal, long dataOffset, char[] buffer, int bufferOffset, int length) => + ProbedReader.GetChars(ordinal, dataOffset, buffer, bufferOffset, length); + + public new DbDataReader GetData(int ordinal) => ProbedReader.GetData(ordinal); + + public override string GetDataTypeName(int ordinal) => ProbedReader.GetDataTypeName(ordinal); + + public override DateTime GetDateTime(int ordinal) => ProbedReader.GetDateTime(ordinal); + + public override decimal GetDecimal(int ordinal) => ProbedReader.GetDecimal(ordinal); + + public override double GetDouble(int ordinal) => ProbedReader.GetDouble(ordinal); + + public override IEnumerator GetEnumerator() => new ReaderEnumerator(this); + + public override Type GetFieldType(int ordinal) => ProbedReader.GetFieldType(ordinal); + + public override T GetFieldValue(int ordinal) => ProbedReader.GetFieldValue(ordinal); + + public override Task GetFieldValueAsync(int ordinal, CancellationToken cancellationToken) => + ProbedReader.GetFieldValueAsync(ordinal, cancellationToken); + + public override float GetFloat(int ordinal) => ProbedReader.GetFloat(ordinal); + + public override Guid GetGuid(int ordinal) => ProbedReader.GetGuid(ordinal); + + public override short GetInt16(int ordinal) => ProbedReader.GetInt16(ordinal); + + public override int GetInt32(int ordinal) => ProbedReader.GetInt32(ordinal); + + public override long GetInt64(int ordinal) => ProbedReader.GetInt64(ordinal); + + public override string GetName(int ordinal) => ProbedReader.GetName(ordinal); + + public override int GetOrdinal(string name) => ProbedReader.GetOrdinal(name); + + public override string GetString(int ordinal) => ProbedReader.GetString(ordinal); + + public override object GetValue(int ordinal) => ProbedReader.GetValue(ordinal); + + public override int GetValues(object[] values) => ProbedReader.GetValues(values); + + public override bool IsDBNull(int ordinal) => ProbedReader.IsDBNull(ordinal); + + public override Task IsDBNullAsync(int ordinal, CancellationToken cancellationToken) => + ProbedReader.IsDBNullAsync(ordinal, cancellationToken); + + public override bool NextResult() => ProbedReader.NextResult(); + + public override Task NextResultAsync(CancellationToken cancellationToken) => + ProbedReader.NextResultAsync(cancellationToken); + + public override bool Read() + { + var result = ProbedReader.Read(); + if (result) CounterDataCollector.Increment(CounterKey); + + return result; + } + + public override async Task ReadAsync(CancellationToken cancellationToken) + { + var result = await ProbedReader.ReadAsync(cancellationToken); + if (result) CounterDataCollector.Increment(CounterKey); + + return result; + } + + public override void Close() => ProbedReader.Close(); + + public override DataTable GetSchemaTable() => ProbedReader.GetSchemaTable(); + + protected override void Dispose(bool disposing) + { + ProbedReader.Dispose(); + base.Dispose(disposing); + } + + private sealed class ReaderEnumerator : IEnumerator + { + private readonly ProbedDbDataReader _probedReader; + private readonly IEnumerator _probedEnumerator; + + public ReaderEnumerator(ProbedDbDataReader probedReader) + { + _probedReader = probedReader; + _probedEnumerator = (probedReader.ProbedReader as IEnumerable).GetEnumerator(); + } + + public object Current => _probedEnumerator.Current; + + public bool MoveNext() + { + var haveNext = _probedEnumerator.MoveNext(); + if (haveNext) _probedReader.CounterDataCollector.Increment(_probedReader.CounterKey); + + return haveNext; + } + + public void Reset() => _probedEnumerator.Reset(); + } +} diff --git a/Lombiq.Tests.UI/Services/Counters/Data/ProbedSqliteConnection.cs b/Lombiq.Tests.UI/Services/Counters/Data/ProbedSqliteConnection.cs new file mode 100644 index 000000000..c6db6a64d --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Data/ProbedSqliteConnection.cs @@ -0,0 +1,36 @@ +using Microsoft.Data.Sqlite; +using System; +using System.ComponentModel; +using System.Data.Common; + +namespace Lombiq.Tests.UI.Services.Counters.Data; + +// This is required to avoid the component being visible in VS Toolbox. +[DesignerCategory("")] +public class ProbedSqliteConnection : SqliteConnection +{ + private readonly ICounterDataCollector _counterDataCollector; + + internal SqliteConnection ProbedConnection { get; private set; } + + public ProbedSqliteConnection(SqliteConnection connection, ICounterDataCollector counterDataCollector) + : base(connection.ConnectionString) => + _counterDataCollector = counterDataCollector ?? throw new ArgumentNullException(nameof(counterDataCollector)); + + protected virtual DbCommand CreateDbCommand(DbCommand original) => + new ProbedDbCommand(original, this, _counterDataCollector); + + protected override DbCommand CreateDbCommand() => + CreateDbCommand(CreateCommand()); + + protected override void Dispose(bool disposing) + { + if (disposing && ProbedConnection is not null) + { + ProbedConnection.Dispose(); + } + + base.Dispose(disposing); + ProbedConnection = null; + } +} diff --git a/Lombiq.Tests.UI/Services/Counters/Extensions/CounterDataCollectorExtensions.cs b/Lombiq.Tests.UI/Services/Counters/Extensions/CounterDataCollectorExtensions.cs new file mode 100644 index 000000000..29f3820d1 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Extensions/CounterDataCollectorExtensions.cs @@ -0,0 +1,68 @@ +using Lombiq.Tests.UI.Services.Counters.Data; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace Lombiq.Tests.UI.Services.Counters.Extensions; + +public static class CounterDataCollectorExtensions +{ + public static int DbCommandExecuteNonQuery(this ICounterDataCollector collector, DbCommand dbCommand) + { + collector.Increment(DbCommandExecuteCounterKey.CreateFrom(dbCommand)); + collector.Increment(DbCommandTextExecuteCounterKey.CreateFrom(dbCommand)); + return dbCommand.ExecuteNonQuery(); + } + + public static Task DbCommandExecuteNonQueryAsync( + this ICounterDataCollector collector, + DbCommand dbCommand, + CancellationToken cancellationToken) + { + collector.Increment(DbCommandExecuteCounterKey.CreateFrom(dbCommand)); + collector.Increment(DbCommandTextExecuteCounterKey.CreateFrom(dbCommand)); + return dbCommand.ExecuteNonQueryAsync(cancellationToken); + } + + public static object DbCommandExecuteScalar(this ICounterDataCollector collector, DbCommand dbCommand) + { + collector.Increment(DbCommandExecuteCounterKey.CreateFrom(dbCommand)); + collector.Increment(DbCommandTextExecuteCounterKey.CreateFrom(dbCommand)); + return dbCommand.ExecuteScalar(); + } + + public static Task DbCommandExecuteScalarAsync( + this ICounterDataCollector collector, + DbCommand dbCommand, + CancellationToken cancellationToken) + { + collector.Increment(DbCommandExecuteCounterKey.CreateFrom(dbCommand)); + collector.Increment(DbCommandTextExecuteCounterKey.CreateFrom(dbCommand)); + + return dbCommand.ExecuteScalarAsync(cancellationToken); + } + + public static DbDataReader DbCommandExecuteDbDataReader( + this ICounterDataCollector collector, + DbCommand dbCommand, + CommandBehavior behavior) + { + collector.Increment(DbCommandExecuteCounterKey.CreateFrom(dbCommand)); + collector.Increment(DbCommandTextExecuteCounterKey.CreateFrom(dbCommand)); + + return dbCommand.ExecuteReader(behavior); + } + + public static Task DbCommandExecuteDbDataReaderAsync( + this ICounterDataCollector collector, + DbCommand dbCommand, + CommandBehavior behavior, + CancellationToken cancellationToken) + { + collector.Increment(DbCommandExecuteCounterKey.CreateFrom(dbCommand)); + collector.Increment(DbCommandTextExecuteCounterKey.CreateFrom(dbCommand)); + + return dbCommand.ExecuteReaderAsync(behavior, cancellationToken); + } +} diff --git a/Lombiq.Tests.UI/Services/Counters/Extensions/RelativeUrlConfigurationKeyExtensions.cs b/Lombiq.Tests.UI/Services/Counters/Extensions/RelativeUrlConfigurationKeyExtensions.cs new file mode 100644 index 000000000..4ff27c4d7 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Extensions/RelativeUrlConfigurationKeyExtensions.cs @@ -0,0 +1,20 @@ +using System; + +namespace Lombiq.Tests.UI.Services.Counters.Extensions; + +public static class RelativeUrlConfigurationKeyExtensions +{ + public static bool EqualsWith(this IRelativeUrlConfigurationKey left, IRelativeUrlConfigurationKey right) + { + if (ReferenceEquals(left, right)) return true; + + if (left?.Url is null || right?.Url is null) return false; + + var leftUrl = left.Url.IsAbsoluteUri ? left.Url.PathAndQuery : left.Url.OriginalString; + var rightUrl = right.Url.IsAbsoluteUri ? right.Url.PathAndQuery : right.Url.OriginalString; + + return (left.ExactMatch || right.ExactMatch) + ? string.Equals(leftUrl, rightUrl, StringComparison.OrdinalIgnoreCase) + : leftUrl.EqualsOrdinalIgnoreCase(rightUrl) || rightUrl.EqualsOrdinalIgnoreCase(leftUrl); + } +} diff --git a/Lombiq.Tests.UI/Services/Counters/Extensions/RunningPhaseCounterConfigurationExtensions.cs b/Lombiq.Tests.UI/Services/Counters/Extensions/RunningPhaseCounterConfigurationExtensions.cs new file mode 100644 index 000000000..45f7a4779 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Extensions/RunningPhaseCounterConfigurationExtensions.cs @@ -0,0 +1,61 @@ +using Lombiq.HelpfulLibraries.OrchardCore.Mvc; +using Lombiq.Tests.UI.Services.Counters.Configuration; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace Lombiq.Tests.UI.Services.Counters.Extensions; + +public static class RunningPhaseCounterConfigurationExtensions +{ + public static CounterConfiguration AddIfMissingAndConfigure( + this RunningPhaseCounterConfiguration configuration, + ICounterConfigurationKey key, + Action configure) + { + var phaseConfiguration = configuration.GetMaybeByKey(key); + if (phaseConfiguration is not null) + { + phaseConfiguration = configuration[key]; + configure?.Invoke(phaseConfiguration); + + return phaseConfiguration; + } + + phaseConfiguration = new PhaseCounterConfiguration(); + configure.Invoke(phaseConfiguration); + configuration.Add(key, phaseConfiguration); + + return phaseConfiguration; + } + + public static void ConfigureForRelativeUrl( + this RunningPhaseCounterConfiguration configuration, + Action configure, + Expression> actionExpressionAsync, + bool exactMatch, + params (string Key, object Value)[] additionalArguments) + where TController : ControllerBase => + configuration.AddIfMissingAndConfigure( + new RelativeUrlConfigurationKey( + new Uri( + TypedRoute.CreateFromExpression(actionExpressionAsync.StripResult(), additionalArguments).ToString(), + UriKind.Relative), + exactMatch), + configure); + + public static CounterConfiguration GetMaybeByKey( + this RunningPhaseCounterConfiguration configuration, + ICounterConfigurationKey key) + { + var configurationKey = configuration.Keys.FirstOrDefault(item => item.Equals(key)); + if (configurationKey is null) + { + return null; + } + + return configuration[configurationKey]; + } +} diff --git a/Lombiq.Tests.UI/Services/Counters/ICounterConfigurationKey.cs b/Lombiq.Tests.UI/Services/Counters/ICounterConfigurationKey.cs new file mode 100644 index 000000000..7e35a9350 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/ICounterConfigurationKey.cs @@ -0,0 +1,10 @@ +using System; + +namespace Lombiq.Tests.UI.Services.Counters; + +/// +/// Represents a key in . +/// +public interface ICounterConfigurationKey : IEquatable +{ +} diff --git a/Lombiq.Tests.UI/Services/Counters/ICounterDataCollector.cs b/Lombiq.Tests.UI/Services/Counters/ICounterDataCollector.cs new file mode 100644 index 000000000..1a51c6ea9 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/ICounterDataCollector.cs @@ -0,0 +1,36 @@ +using System; + +namespace Lombiq.Tests.UI.Services.Counters; + +/// +/// Represents a data collector which collects and asserts data from probes attached to it. +/// +public interface ICounterDataCollector : ICounterProbe +{ + /// + /// Attaches a instance given by to the current collector instance. + /// + /// The instance to attach. + void AttachProbe(ICounterProbe probe); + + /// + /// Resets the collected counters and probes. + /// + void Reset(); + + /// + /// Asserts the data collected by . + /// + /// The instance to assert. + void AssertCounter(ICounterProbe probe); + + /// + /// Asserts the data collected by the instance. + /// + void AssertCounter(); + + /// + /// Postpones exception thrown by a counter when the exception was thrown from the test context. + /// + void PostponeCounterException(Exception exception); +} diff --git a/Lombiq.Tests.UI/Services/Counters/ICounterKey.cs b/Lombiq.Tests.UI/Services/Counters/ICounterKey.cs new file mode 100644 index 000000000..1a241d3e7 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/ICounterKey.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace Lombiq.Tests.UI.Services.Counters; + +/// +/// Represents a key in . +/// +public interface ICounterKey : IEquatable +{ + /// + /// Gets the display name of the key. + /// + string DisplayName { get; } + + /// + /// Dumps the key content to a human-readable format. + /// + /// A human-readable representation of the instance. + IEnumerable Dump(); +} diff --git a/Lombiq.Tests.UI/Services/Counters/ICounterProbe.cs b/Lombiq.Tests.UI/Services/Counters/ICounterProbe.cs new file mode 100644 index 000000000..39cc22a41 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/ICounterProbe.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace Lombiq.Tests.UI.Services.Counters; + +/// +/// Represents a probe for the counter infrastructure to collect data while it is attached to the component under +/// monitoring. +/// +public interface ICounterProbe +{ + /// + /// Gets a value indicating whether the instance is attached. + /// + bool IsAttached { get; } + + /// + /// Gets or sets a callback which is called when the probe completed capturing the data. + /// + Action CaptureCompleted { get; set; } + + /// + /// Gets the collected values. + /// + IDictionary Counters { get; } + + /// + /// Increments the selected by . If the + /// does not exists, creates a new instance. + /// + /// The counter key. + void Increment(ICounterKey counter); + + /// + /// Dumps the probe headline to a human-readable format. + /// + /// A human-readable string representation of instance in one line. + public string DumpHeadline(); + + /// + /// Dumps the probe content to a human-readable format. + /// + /// A human-readable representation of instance. + IEnumerable Dump(); +} diff --git a/Lombiq.Tests.UI/Services/Counters/ICounterValue.cs b/Lombiq.Tests.UI/Services/Counters/ICounterValue.cs new file mode 100644 index 000000000..0a1186da9 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/ICounterValue.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Lombiq.Tests.UI.Services.Counters; + +/// +/// Represents a value in . +/// +public interface ICounterValue +{ + /// + /// Gets the display name of the value. + /// + string DisplayName { get; } + + /// + /// Dumps the value content to a human-readable format. + /// + /// A human-readable representation of the instance. + IEnumerable Dump(); +} diff --git a/Lombiq.Tests.UI/Services/Counters/IOutOfTestContextCounterProbe.cs b/Lombiq.Tests.UI/Services/Counters/IOutOfTestContextCounterProbe.cs new file mode 100644 index 000000000..a97654845 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/IOutOfTestContextCounterProbe.cs @@ -0,0 +1,9 @@ +namespace Lombiq.Tests.UI.Services.Counters; + +/// +/// Represents a probe that is out of the test context, i.e. it's not executed in the test context, but in the web +/// application context. +/// +public interface IOutOfTestContextCounterProbe : ICounterProbe +{ +} diff --git a/Lombiq.Tests.UI/Services/Counters/IRelativeUrlConfigurationKey.cs b/Lombiq.Tests.UI/Services/Counters/IRelativeUrlConfigurationKey.cs new file mode 100644 index 000000000..26f272f60 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/IRelativeUrlConfigurationKey.cs @@ -0,0 +1,19 @@ +using System; + +namespace Lombiq.Tests.UI.Services.Counters; + +/// +/// Represents a relative URL configuration key in . +/// +public interface IRelativeUrlConfigurationKey : ICounterConfigurationKey +{ + /// + /// Gets the URL. + /// + Uri Url { get; } + + /// + /// Gets a value indicating whether the URL should be matched exactly or substring match is enough. + /// + bool ExactMatch { get; } +} diff --git a/Lombiq.Tests.UI/Services/Counters/Middlewares/RequestProbeMiddleware.cs b/Lombiq.Tests.UI/Services/Counters/Middlewares/RequestProbeMiddleware.cs new file mode 100644 index 000000000..8da8f2add --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Middlewares/RequestProbeMiddleware.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using System; +using System.Threading.Tasks; + +namespace Lombiq.Tests.UI.Services.Counters.Middlewares; + +public class RequestProbeMiddleware +{ + private readonly RequestDelegate _next; + private readonly ICounterDataCollector _counterDataCollector; + + public RequestProbeMiddleware(RequestDelegate next, ICounterDataCollector counterDataCollector) + { + _next = next; + _counterDataCollector = counterDataCollector; + } + + public async Task InvokeAsync(HttpContext context) + { + using (new RequestProbe(_counterDataCollector, context.Request.Method, new Uri(context.Request.GetEncodedUrl()))) + await _next.Invoke(context); + } +} diff --git a/Lombiq.Tests.UI/Services/Counters/NavigationProbe.cs b/Lombiq.Tests.UI/Services/Counters/NavigationProbe.cs new file mode 100644 index 000000000..96f33b8bf --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/NavigationProbe.cs @@ -0,0 +1,24 @@ +using Lombiq.Tests.UI.Services.Counters.Extensions; +using System; + +namespace Lombiq.Tests.UI.Services.Counters; + +public sealed class NavigationProbe : CounterProbe, IRelativeUrlConfigurationKey +{ + public Uri AbsoluteUri { get; init; } + + public NavigationProbe(ICounterDataCollector counterDataCollector, Uri absoluteUri) + : base(counterDataCollector) => + AbsoluteUri = absoluteUri; + + public override string DumpHeadline() => $"{nameof(NavigationProbe)}, AbsoluteUri = {AbsoluteUri}"; + + #region IRelativeUrlConfigurationKey implementation + + Uri IRelativeUrlConfigurationKey.Url => AbsoluteUri; + bool IRelativeUrlConfigurationKey.ExactMatch => false; + bool IEquatable.Equals(ICounterConfigurationKey other) => + this.EqualsWith(other as IRelativeUrlConfigurationKey); + + #endregion +} diff --git a/Lombiq.Tests.UI/Services/Counters/RequestProbe.cs b/Lombiq.Tests.UI/Services/Counters/RequestProbe.cs new file mode 100644 index 000000000..f3683e177 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/RequestProbe.cs @@ -0,0 +1,28 @@ +using Lombiq.Tests.UI.Services.Counters.Extensions; +using System; + +namespace Lombiq.Tests.UI.Services.Counters; + +public sealed class RequestProbe : CounterProbe, IRelativeUrlConfigurationKey +{ + public string RequestMethod { get; init; } + public Uri AbsoluteUri { get; init; } + + public RequestProbe(ICounterDataCollector counterDataCollector, string requestMethod, Uri absoluteUri) + : base(counterDataCollector) + { + RequestMethod = requestMethod; + AbsoluteUri = absoluteUri; + } + + public override string DumpHeadline() => $"{nameof(RequestProbe)}, [{RequestMethod}]{AbsoluteUri}"; + + #region IRelativeUrlConfigurationKey implementation + + Uri IRelativeUrlConfigurationKey.Url => AbsoluteUri; + bool IRelativeUrlConfigurationKey.ExactMatch => false; + bool IEquatable.Equals(ICounterConfigurationKey other) => + this.EqualsWith(other as IRelativeUrlConfigurationKey); + + #endregion +} diff --git a/Lombiq.Tests.UI/Services/Counters/SessionProbe.cs b/Lombiq.Tests.UI/Services/Counters/SessionProbe.cs new file mode 100644 index 000000000..1df6ae253 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/SessionProbe.cs @@ -0,0 +1,76 @@ +using Lombiq.Tests.UI.Services.Counters.Extensions; +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Threading.Tasks; +using YesSql; +using YesSql.Indexes; + +namespace Lombiq.Tests.UI.Services.Counters; + +public sealed class SessionProbe : CounterProbe, IOutOfTestContextCounterProbe, ISession, IRelativeUrlConfigurationKey +{ + private readonly ISession _session; + public string RequestMethod { get; init; } + public Uri AbsoluteUri { get; init; } + DbTransaction ISession.CurrentTransaction => _session.CurrentTransaction; + IStore ISession.Store => _session.Store; + + public SessionProbe(ICounterDataCollector counterDataCollector, string requestMethod, Uri absoluteUri, ISession session) + : base(counterDataCollector) + { + RequestMethod = requestMethod; + AbsoluteUri = absoluteUri; + _session = session; + } + + public override string DumpHeadline() => $"{nameof(SessionProbe)}, [{RequestMethod}]{AbsoluteUri}"; + + protected override void OnDisposing() => _session.Dispose(); + + // Needs to be implemented on mock class. +#pragma warning disable CS0618 // Type or member is obsolete + void ISession.Save(object obj, bool checkConcurrency, string collection) => + _session.Save(obj, checkConcurrency, collection); +#pragma warning restore CS0618 // Type or member is obsolete + Task ISession.SaveAsync(object obj, bool checkConcurrency, string collection) => + _session.SaveAsync(obj, checkConcurrency, collection); + void ISession.Delete(object item, string collection) => + _session.Delete(item, collection); + bool ISession.Import(object item, long id, long version, string collection) => + _session.Import(item, id, version, collection); + void ISession.Detach(object item, string collection) => + _session.Detach(item, collection); + Task> ISession.GetAsync(long[] ids, string collection) => + _session.GetAsync(ids, collection); + IQuery ISession.Query(string collection) => + _session.Query(collection); + IQuery ISession.ExecuteQuery(ICompiledQuery compiledQuery, string collection) => + _session.ExecuteQuery(compiledQuery, collection); + Task ISession.CancelAsync() => _session.CancelAsync(); + Task ISession.FlushAsync() => _session.FlushAsync(); + Task ISession.SaveChangesAsync() => _session.SaveChangesAsync(); + Task ISession.CreateConnectionAsync() => _session.CreateConnectionAsync(); + Task ISession.BeginTransactionAsync() => _session.BeginTransactionAsync(); + Task ISession.BeginTransactionAsync(IsolationLevel isolationLevel) => + _session.BeginTransactionAsync(isolationLevel); + ISession ISession.RegisterIndexes(IIndexProvider[] indexProviders, string collection) => + _session.RegisterIndexes(indexProviders, collection); + async ValueTask IAsyncDisposable.DisposeAsync() + { + await _session.DisposeAsync(); + // Should be at the end because the Session implementation calls CommitOrRollbackTransactionAsync in + // DisposeAsync and we should count the executed DB commands in it. + Dispose(); + } + + #region IRelativeUrlConfigurationKey implementation + + Uri IRelativeUrlConfigurationKey.Url => AbsoluteUri; + bool IRelativeUrlConfigurationKey.ExactMatch => false; + bool IEquatable.Equals(ICounterConfigurationKey other) => + this.EqualsWith(other as IRelativeUrlConfigurationKey); + + #endregion +} diff --git a/Lombiq.Tests.UI/Services/Counters/Value/CounterValue.cs b/Lombiq.Tests.UI/Services/Counters/Value/CounterValue.cs new file mode 100644 index 000000000..fd161a152 --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Value/CounterValue.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Globalization; + +namespace Lombiq.Tests.UI.Services.Counters.Value; + +public abstract class CounterValue : ICounterValue + where TValue : struct +{ + public virtual string DisplayName => "Count"; + + public TValue Value { get; set; } + + public virtual IEnumerable Dump() => + [string.Create(CultureInfo.InvariantCulture, $"{GetType().Name} value: {Value}")]; +} diff --git a/Lombiq.Tests.UI/Services/Counters/Value/IntegerCounterValue.cs b/Lombiq.Tests.UI/Services/Counters/Value/IntegerCounterValue.cs new file mode 100644 index 000000000..a17696f8d --- /dev/null +++ b/Lombiq.Tests.UI/Services/Counters/Value/IntegerCounterValue.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; + +namespace Lombiq.Tests.UI.Services.Counters.Value; + +public class IntegerCounterValue : CounterValue +{ + public override IEnumerable Dump() => + [ + $"{DisplayName}: {this}", + ]; + + public override string ToString() => Value.ToTechnicalString(); +} diff --git a/Lombiq.Tests.UI/Services/OrchardCoreHosting/OrchardApplicationFactory.cs b/Lombiq.Tests.UI/Services/OrchardCoreHosting/OrchardApplicationFactory.cs index 5f90ad820..3d73274dd 100644 --- a/Lombiq.Tests.UI/Services/OrchardCoreHosting/OrchardApplicationFactory.cs +++ b/Lombiq.Tests.UI/Services/OrchardCoreHosting/OrchardApplicationFactory.cs @@ -1,5 +1,10 @@ using Lombiq.Tests.Integration.Services; +using Lombiq.Tests.UI.Services.Counters; +using Lombiq.Tests.UI.Services.Counters.Middlewares; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc.Razor.Compilation; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; @@ -11,27 +16,32 @@ using NLog; using NLog.Web; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using YesSql; +using ISession = YesSql.ISession; namespace Lombiq.Tests.UI.Services.OrchardCoreHosting; public sealed class OrchardApplicationFactory : WebApplicationFactory, IProxyConnectionProvider where TStartup : class { + private readonly ICounterDataCollector _counterDataCollector; private readonly Action _configureHost; private readonly Action _configuration; private readonly Action _configureOrchard; - private readonly List _createdStores = []; + private readonly ConcurrentBag _createdStores = []; public OrchardApplicationFactory( + ICounterDataCollector counterDataCollector, Action configureHost = null, Action configuration = null, Action configureOrchard = null) { + _counterDataCollector = counterDataCollector; _configureHost = configureHost; _configuration = configuration; _configureOrchard = configureOrchard; @@ -91,16 +101,18 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) private void ConfigureTestServices(IServiceCollection services) { + services.AddSingleton(_counterDataCollector); + var builder = services - .LastOrDefault(descriptor => descriptor.ServiceType == typeof(OrchardCoreBuilder))? - .ImplementationInstance as OrchardCoreBuilder - ?? throw new InvalidOperationException( - "Please call WebApplicationBuilder.Services.AddOrchardCms() in your Program.cs!"); + .LastOrDefault(descriptor => descriptor.ServiceType == typeof(OrchardCoreBuilder))? + .ImplementationInstance as OrchardCoreBuilder + ?? throw new InvalidOperationException( + "Please call WebApplicationBuilder.Services.AddOrchardCms() in your Program.cs!"); var configuration = services - .LastOrDefault(descriptor => descriptor.ServiceType == typeof(ConfigurationManager))? - .ImplementationInstance as ConfigurationManager - ?? throw new InvalidOperationException( - $"Please add {nameof(ConfigurationManager)} instance to WebApplicationBuilder.Services in your Program.cs!"); + .LastOrDefault(descriptor => descriptor.ServiceType == typeof(ConfigurationManager))? + .ImplementationInstance as ConfigurationManager + ?? throw new InvalidOperationException( + $"Please add {nameof(ConfigurationManager)} instance to WebApplicationBuilder.Services in your Program.cs!"); _configureOrchard?.Invoke(configuration, builder); @@ -109,8 +121,13 @@ .ImplementationInstance as ConfigurationManager { AddFakeStore(builderServices); AddFakeViewCompilerProvider(builderServices); + AddSessionProbe(builderServices); }, int.MaxValue); + + builder.Configure( + app => app.UseMiddleware(), + int.MaxValue); } private void AddFakeStore(IServiceCollection services) @@ -127,13 +144,38 @@ private void AddFakeStore(IServiceCollection services) return null; } - lock (_createdStores) - { - var fakeStore = new FakeStore((IStore)storeDescriptor.ImplementationFactory.Invoke(serviceProvider)); - _createdStores.Add(fakeStore); + store.Configuration.ConnectionFactory = new ProbedConnectionFactory( + store.Configuration.ConnectionFactory, + _counterDataCollector); + + var fakeStore = new FakeStore(store); + _createdStores.Add(fakeStore); + + return fakeStore; + }); + } - return fakeStore; + private void AddSessionProbe(IServiceCollection services) + { + var sessionDescriptor = services.LastOrDefault(descriptor => descriptor.ServiceType == typeof(ISession)); + + services.RemoveAll(); + + services.AddScoped(serviceProvider => + { + var session = (ISession)sessionDescriptor.ImplementationFactory.Invoke(serviceProvider); + if (session is null) + { + return null; } + + var httpContextAccessor = serviceProvider.GetRequiredService(); + + return new SessionProbe( + _counterDataCollector, + httpContextAccessor.HttpContext.Request.Method, + new Uri(httpContextAccessor.HttpContext.Request.GetEncodedUrl()), + session); }); } diff --git a/Lombiq.Tests.UI/Services/OrchardCoreInstance.cs b/Lombiq.Tests.UI/Services/OrchardCoreInstance.cs index ef939c5ec..03f7ec10d 100644 --- a/Lombiq.Tests.UI/Services/OrchardCoreInstance.cs +++ b/Lombiq.Tests.UI/Services/OrchardCoreInstance.cs @@ -2,6 +2,7 @@ using Lombiq.Tests.UI.Constants; using Lombiq.Tests.UI.Helpers; using Lombiq.Tests.UI.Models; +using Lombiq.Tests.UI.Services.Counters; using Lombiq.Tests.UI.Services.OrchardCoreHosting; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -51,6 +52,7 @@ public sealed class OrchardCoreInstance : IWebApplicationInstance private readonly OrchardCoreConfiguration _configuration; private readonly string _contextId; private readonly ITestOutputHelper _testOutputHelper; + private readonly ICounterDataCollector _counterDataCollector; private string _contentRootPath; private bool _isDisposed; private OrchardApplicationFactory _orchardApplication; @@ -59,11 +61,16 @@ public sealed class OrchardCoreInstance : IWebApplicationInstance public IServiceProvider Services => _orchardApplication?.Services; - public OrchardCoreInstance(OrchardCoreConfiguration configuration, string contextId, ITestOutputHelper testOutputHelper) + public OrchardCoreInstance( + OrchardCoreConfiguration configuration, + string contextId, + ITestOutputHelper testOutputHelper, + ICounterDataCollector counterDataCollector) { _configuration = configuration; _contextId = contextId; _testOutputHelper = testOutputHelper; + _counterDataCollector = counterDataCollector; } public async Task StartUpAsync() @@ -162,6 +169,7 @@ await _configuration.BeforeAppStart Path.Combine(Path.GetDirectoryName(typeof(OrchardCoreInstance<>).Assembly.Location), "refs"), 60); _orchardApplication = new OrchardApplicationFactory( + _counterDataCollector, configuration => configuration.AddCommandLine(arguments.Arguments.ToArray()), builder => builder diff --git a/Lombiq.Tests.UI/Services/OrchardCoreUITestExecutorConfiguration.cs b/Lombiq.Tests.UI/Services/OrchardCoreUITestExecutorConfiguration.cs index 5bc571087..b433a2307 100644 --- a/Lombiq.Tests.UI/Services/OrchardCoreUITestExecutorConfiguration.cs +++ b/Lombiq.Tests.UI/Services/OrchardCoreUITestExecutorConfiguration.cs @@ -1,5 +1,11 @@ +using Lombiq.Tests.UI.Exceptions; using Lombiq.Tests.UI.Extensions; using Lombiq.Tests.UI.SecurityScanning; +using Lombiq.Tests.UI.Services.Counters; +using Lombiq.Tests.UI.Services.Counters.Configuration; +using Lombiq.Tests.UI.Services.Counters.Data; +using Lombiq.Tests.UI.Services.Counters.Extensions; +using Lombiq.Tests.UI.Services.Counters.Value; using Lombiq.Tests.UI.Services.GitHub; using Lombiq.Tests.UI.Shortcuts.Controllers; using OpenQA.Selenium; @@ -24,6 +30,13 @@ public enum Browser public class OrchardCoreUITestExecutorConfiguration { + private const string WorkflowTypeStartActivitiesQuery = + "SELECT DISTINCT [Document].* FROM [Document] INNER JOIN [WorkflowTypeStartActivitiesIndex]" + + " AS [WorkflowTypeStartActivitiesIndex_a1]" + + " ON [WorkflowTypeStartActivitiesIndex_a1].[DocumentId] = [Document].[Id]" + + " WHERE (([WorkflowTypeStartActivitiesIndex_a1].[StartActivityName] = @p0)" + + " and ([WorkflowTypeStartActivitiesIndex_a1].[IsEnabled] = @p1))"; + public static readonly Func AssertAppLogsAreEmptyAsync = app => app.LogsShouldBeEmptyAsync(); @@ -45,6 +58,22 @@ public class OrchardCoreUITestExecutorConfiguration // under a different URL. !logEntry.IsNotFoundLogEntry("/favicon.ico"); + public static readonly IEnumerable DefaultCounterExcludeList = + [ + new DbCommandExecuteCounterKey( + WorkflowTypeStartActivitiesQuery, + new("p0", "ContentCreatedEvent"), + new("p1", value: true)), + new DbCommandExecuteCounterKey( + WorkflowTypeStartActivitiesQuery, + new("p0", "ContentPublishedEvent"), + new("p1", value: true)), + new DbCommandExecuteCounterKey( + WorkflowTypeStartActivitiesQuery, + new("p0", "ContentUpdatedEvent"), + new("p1", value: true)), + ]; + /// /// Gets the global events available during UI test execution. /// @@ -176,6 +205,11 @@ public class OrchardCoreUITestExecutorConfiguration /// public ShortcutsConfiguration ShortcutsConfiguration { get; set; } = new(); + /// + /// Gets or sets configuration for performance counting and monitoring. + /// + public CounterConfigurations CounterConfiguration { get; set; } = new(); + public async Task AssertAppLogsMaybeAsync(IWebApplicationInstance instance, Action log) { if (instance == null || AssertAppLogsAsync == null) return; @@ -256,4 +290,80 @@ public static Func CreateAppLogAssertionForSecuri return app => app.LogsShouldBeEmptyAsync(canContainWarnings: true, permittedErrorLines); } + + public static Action DefaultAssertCounterData( + PhaseCounterConfiguration configuration) => + (collector, probe) => + { + var counterConfiguration = configuration as CounterConfiguration; + if (counterConfiguration is RunningPhaseCounterConfiguration runningPhaseCounterConfiguration + && probe is ICounterConfigurationKey counterConfigurationKey) + { + counterConfiguration = runningPhaseCounterConfiguration.GetMaybeByKey(counterConfigurationKey) + ?? configuration; + } + + (CounterThresholdConfiguration Settings, string Name)? threshold = probe switch + { + NavigationProbe => + (Settings: counterConfiguration.NavigationThreshold, Name: nameof(counterConfiguration.NavigationThreshold)), + RequestProbe => + (Settings: counterConfiguration.PageLoadThreshold, Name: nameof(counterConfiguration.PageLoadThreshold)), + SessionProbe => + (Settings: counterConfiguration.SessionThreshold, Name: nameof(counterConfiguration.SessionThreshold)), + CounterDataCollector when counterConfiguration is PhaseCounterConfiguration phaseCounterConfiguration => + (Settings: phaseCounterConfiguration.PhaseThreshold, Name: nameof(phaseCounterConfiguration.PhaseThreshold)), + _ => null, + }; + + if (threshold is { } settings && settings.Settings.IsEnabled) + { + try + { + AssertIntegerCounterValue( + probe, + counterConfiguration.ExcludeFilter ?? (key => false), + $"{settings.Name}.{nameof(settings.Settings.DbCommandIncludingParametersExecutionCountThreshold)}", + settings.Settings.DbCommandIncludingParametersExecutionCountThreshold); + AssertIntegerCounterValue( + probe, + counterConfiguration.ExcludeFilter ?? (key => false), + $"{settings.Name}.{nameof(settings.Settings.DbCommandExcludingParametersExecutionThreshold)}", + settings.Settings.DbCommandExcludingParametersExecutionThreshold); + AssertIntegerCounterValue( + probe, + counterConfiguration.ExcludeFilter ?? (key => false), + $"{settings.Name}.{nameof(settings.Settings.DbReaderReadThreshold)}", + settings.Settings.DbReaderReadThreshold); + } + catch (CounterThresholdException exception) when (probe is IOutOfTestContextCounterProbe) + { + collector.PostponeCounterException(exception); + } + } + }; + + public static void AssertIntegerCounterValue( + ICounterProbe probe, + Func excludeFilter, + string thresholdName, + int threshold) + where TKey : ICounterKey => + probe.Counters.Keys + .OfType() + .Where(key => !excludeFilter(key)) + .ForEach(key => + { + if (probe.Counters[key] is IntegerCounterValue counterValue + && counterValue.Value > threshold) + { + throw new CounterThresholdException( + probe, + key, + counterValue, + $"Counter value is greater then {thresholdName}, threshold: {threshold.ToTechnicalString()}."); + } + }); + + public static bool DefaultCounterExcludeFilter(ICounterKey key) => DefaultCounterExcludeList.Contains(key); } diff --git a/Lombiq.Tests.UI/Services/ProbedConnectionFactory.cs b/Lombiq.Tests.UI/Services/ProbedConnectionFactory.cs new file mode 100644 index 000000000..7d29135e7 --- /dev/null +++ b/Lombiq.Tests.UI/Services/ProbedConnectionFactory.cs @@ -0,0 +1,33 @@ +using Lombiq.Tests.UI.Services.Counters; +using Lombiq.Tests.UI.Services.Counters.Data; +using Microsoft.Data.Sqlite; +using System; +using System.Data.Common; +using YesSql; + +namespace Lombiq.Tests.UI.Services; + +public class ProbedConnectionFactory : IConnectionFactory +{ + private readonly IConnectionFactory _connectionFactory; + private readonly ICounterDataCollector _counterDataCollector; + + public Type DbConnectionType => typeof(ProbedDbConnection); + + public ProbedConnectionFactory(IConnectionFactory connectionFactory, ICounterDataCollector counterDataCollector) + { + _connectionFactory = connectionFactory; + _counterDataCollector = counterDataCollector; + } + + public DbConnection CreateConnection() + { + var connection = _connectionFactory.CreateConnection(); + + // This condition and the ProbedSqliteConnection can be removed once + // https://github.com/OrchardCMS/OrchardCore/issues/14217 gets fixed. + return connection is SqliteConnection sqliteConnection + ? new ProbedSqliteConnection(sqliteConnection, _counterDataCollector) + : new ProbedDbConnection(connection, _counterDataCollector); + } +} diff --git a/Lombiq.Tests.UI/Services/UITestContext.cs b/Lombiq.Tests.UI/Services/UITestContext.cs index 9e995e954..72b5af3ac 100644 --- a/Lombiq.Tests.UI/Services/UITestContext.cs +++ b/Lombiq.Tests.UI/Services/UITestContext.cs @@ -117,24 +117,23 @@ public class UITestContext /// public string AdminUrlPrefix { get; set; } = "/Admin"; - public UITestContext( - string id, - UITestManifest testManifest, - OrchardCoreUITestExecutorConfiguration configuration, - IWebApplicationInstance application, - AtataScope scope, - RunningContextContainer runningContextContainer, - ZapManager zapManager) + /// + /// Gets the currently running instance. + /// + public CounterDataCollector CounterDataCollector { get; init; } + + internal UITestContext(UITestContextParameters parameters) { - Id = id; - TestManifest = testManifest; - Configuration = configuration; - SqlServerRunningContext = runningContextContainer.SqlServerRunningContext; - Application = application; - Scope = scope; - SmtpServiceRunningContext = runningContextContainer.SmtpServiceRunningContext; - AzureBlobStorageRunningContext = runningContextContainer.AzureBlobStorageRunningContext; - ZapManager = zapManager; + Id = parameters.Id; + TestManifest = parameters.TestManifest; + Configuration = parameters.Configuration; + SqlServerRunningContext = parameters.RunningContextContainer.SqlServerRunningContext; + Application = parameters.Application; + Scope = parameters.Scope; + SmtpServiceRunningContext = parameters.RunningContextContainer.SmtpServiceRunningContext; + AzureBlobStorageRunningContext = parameters.RunningContextContainer.AzureBlobStorageRunningContext; + ZapManager = parameters.ZapManager; + CounterDataCollector = parameters.CounterDataCollector; } /// diff --git a/Lombiq.Tests.UI/Services/UITestExecutionSession.cs b/Lombiq.Tests.UI/Services/UITestExecutionSession.cs index ca53acdea..c97bd75ec 100644 --- a/Lombiq.Tests.UI/Services/UITestExecutionSession.cs +++ b/Lombiq.Tests.UI/Services/UITestExecutionSession.cs @@ -7,6 +7,7 @@ using Lombiq.Tests.UI.Helpers; using Lombiq.Tests.UI.Models; using Lombiq.Tests.UI.SecurityScanning; +using Lombiq.Tests.UI.Services.Counters.Configuration; using Lombiq.Tests.UI.Services.GitHub; using Microsoft.VisualBasic.FileIO; using Mono.Unix; @@ -114,6 +115,11 @@ public async Task ExecuteAsync(int retryCount, string dumpRootPath) _context.RetryCount = retryCount; _context.FailureDumpContainer.Clear(); + + BeginDataCollection( + _configuration.CounterConfiguration.AfterSetup, + nameof(_configuration.CounterConfiguration.AfterSetup)); + failureDumpContainer = _context.FailureDumpContainer; _context.SetDefaultBrowserSize(); @@ -122,6 +128,8 @@ public async Task ExecuteAsync(int retryCount, string dumpRootPath) await _context.AssertLogsAsync(); + EndAssertDataCollection(); + return true; } catch (Exception ex) @@ -160,6 +168,21 @@ public async Task ExecuteAsync(int retryCount, string dumpRootPath) return false; } + private void BeginDataCollection(PhaseCounterConfiguration counterConfiguration, string phase) + { + _context.CounterDataCollector.Reset(); + _context.CounterDataCollector.Phase = phase; + _context.CounterDataCollector.AssertCounterData = counterConfiguration.AssertCounterData + ?? OrchardCoreUITestExecutorConfiguration.DefaultAssertCounterData(counterConfiguration); + _context.CounterDataCollector.IsEnabled = _configuration.CounterConfiguration.IsEnabled; + } + + private void EndAssertDataCollection() + { + _context.CounterDataCollector.Dump().ForEach(_testOutputHelper.WriteLine); + _context.CounterDataCollector.AssertCounter(); + } + private async ValueTask ShutdownAsync() { if (_configuration.RunAssertLogsOnAllPageChanges) @@ -502,6 +525,10 @@ private async Task SetupAsync() // config to be available at startup too. _context = await CreateContextAsync(); + BeginDataCollection( + _configuration.CounterConfiguration.Setup, + nameof(_configuration.CounterConfiguration.Setup)); + SetupSqlServerSnapshot(); SetupAzureBlobStorageSnapshot(); @@ -510,6 +537,9 @@ private async Task SetupAsync() var result = (_context, await setupConfiguration.SetupOperation(_context)); await _context.AssertLogsAsync(); + + EndAssertDataCollection(); + _testOutputHelper.WriteLineTimestampedAndDebug("Finished setup operation."); return result; @@ -528,6 +558,7 @@ private async Task SetupAsync() await _context.GoToRelativeUrlAsync(resultUri.PathAndQuery); } + catch (CounterThresholdException) { throw; } catch (Exception ex) when (ex is not SetupFailedFastException) { if (setupConfiguration.FastFailSetup) @@ -633,7 +664,9 @@ Task UITestingBeforeAppStartHandlerAsync(string contentRootPath, InstanceCommand _configuration.OrchardCoreConfiguration.BeforeAppStart.RemoveAll(UITestingBeforeAppStartHandlerAsync); _configuration.OrchardCoreConfiguration.BeforeAppStart += UITestingBeforeAppStartHandlerAsync; - _applicationInstance = _webApplicationInstanceFactory(_configuration, contextId); + var counterDataCollector = new CounterDataCollector(_testOutputHelper); + + _applicationInstance = _webApplicationInstanceFactory(_configuration, contextId, counterDataCollector); var uri = await _applicationInstance.StartUpAsync(); _configuration.SetUpEvents(); @@ -663,13 +696,17 @@ Task UITestingBeforeAppStartHandlerAsync(string contentRootPath, InstanceCommand var atataScope = await AtataFactory.StartAtataScopeAsync(contextId, _testOutputHelper, uri, _configuration); return new UITestContext( - contextId, - _testManifest, - _configuration, - _applicationInstance, - atataScope, - new RunningContextContainer(sqlServerContext, smtpContext, azureBlobStorageContext), - _zapManager); + new() + { + Id = contextId, + TestManifest = _testManifest, + Configuration = _configuration, + Application = _applicationInstance, + Scope = atataScope, + RunningContextContainer = new RunningContextContainer(sqlServerContext, smtpContext, azureBlobStorageContext), + ZapManager = _zapManager, + CounterDataCollector = counterDataCollector, + }); } private string GetSetupHashCode() => diff --git a/Lombiq.Tests.UI/Services/UITestExecutor.cs b/Lombiq.Tests.UI/Services/UITestExecutor.cs index 1c8b8782a..816d6768c 100644 --- a/Lombiq.Tests.UI/Services/UITestExecutor.cs +++ b/Lombiq.Tests.UI/Services/UITestExecutor.cs @@ -1,6 +1,7 @@ using Lombiq.HelpfulLibraries.Common.Utilities; using Lombiq.Tests.UI.Helpers; using Lombiq.Tests.UI.Models; +using Lombiq.Tests.UI.Services.Counters; using Lombiq.Tests.UI.Services.GitHub; using System; using System.IO; @@ -12,11 +13,12 @@ namespace Lombiq.Tests.UI.Services; public delegate IWebApplicationInstance WebApplicationInstanceFactory( OrchardCoreUITestExecutorConfiguration configuration, - string contextId); + string contextId, + ICounterDataCollector counterDataCollector); public static class UITestExecutor { - private static readonly object _numberOfTestsLimitLock = new(); + private static readonly SemaphoreSlim _numberOfTestsLimitLock = new(1, 1); private static SemaphoreSlim _numberOfTestsLimit; /// @@ -55,14 +57,6 @@ public static Task ExecuteOrchardCoreTestAsync( configuration.TestOutputHelper.WriteLineTimestampedAndDebug("Finished preparation for {0}.", testManifest.Name); - if (_numberOfTestsLimit == null && configuration.MaxParallelTests > 0) - { - lock (_numberOfTestsLimitLock) - { - _numberOfTestsLimit ??= new SemaphoreSlim(configuration.MaxParallelTests); - } - } - return ExecuteOrchardCoreTestInnerAsync(webApplicationInstanceFactory, testManifest, configuration, dumpRootPath); } @@ -72,6 +66,8 @@ private static async Task ExecuteOrchardCoreTestInnerAsync( OrchardCoreUITestExecutorConfiguration configuration, string dumpRootPath) { + await PrepareTestLimitAsync(configuration); + var retryCount = 0; var passed = false; while (!passed) @@ -94,7 +90,6 @@ private static async Task ExecuteOrchardCoreTestInnerAsync( catch (Exception ex) { // When the last try failed. - if (configuration.ExtendGitHubActionsOutput && configuration.GitHubActionsOutputConfiguration.EnableErrorAnnotations && GitHubHelper.IsGitHubEnvironment) @@ -198,4 +193,16 @@ private static string PrepareDumpFolder( return dumpRootPath; } + + private static async Task PrepareTestLimitAsync(OrchardCoreUITestExecutorConfiguration configuration) + { + await _numberOfTestsLimitLock.WaitAsync(); + + if (_numberOfTestsLimit == null && configuration.MaxParallelTests > 0) + { + _numberOfTestsLimit = new SemaphoreSlim(configuration.MaxParallelTests); + } + + _numberOfTestsLimitLock.Release(); + } } diff --git a/Readme.md b/Readme.md index bad9741d7..b918aadfe 100644 --- a/Readme.md +++ b/Readme.md @@ -27,6 +27,7 @@ Highlights: - If your app uses a camera, a fake video capture source in Chrome is supported. [Here's a demo video of the feature](https://www.youtube.com/watch?v=sGcD0eJ2ytc), and check out the docs [here](Lombiq.Tests.UI/Docs/FakeVideoCaptureSource.md). - Interactive mode for debugging the app while the test is paused. [Here's a demo of the feature](Lombiq.Tests.UI.Samples/Tests/InteractiveModeTests.cs), and a [demo video here](https://www.youtube.com/watch?v=ItNltaruWTY). - Security scanning with [Zed Attack Proxy (ZAP)](https://www.zaproxy.org/), the world's most widely used web app security scanner, right from UI tests. See a demo video [here](https://www.youtube.com/watch?v=iUYivLkFbY4). +- Duplicate SQL query detector: Built-in infrastructure to detect N+1 query issue. See a demo video of the project [here](https://www.youtube.com/watch?v=mEUg6-pad-E), and the Orchard Harvest 2023 conference talk about automated QA in Orchard Core [here](https://youtu.be/CHdhwD2NHBU). Also, see our [Testing Toolbox](https://github.com/Lombiq/Testing-Toolbox) for similar features for lower-level tests.