diff --git a/src/TagzApp.Web/Services/Base/BaseProviderManager.cs b/src/TagzApp.Web/Services/Base/BaseProviderManager.cs new file mode 100644 index 00000000..0fc7104e --- /dev/null +++ b/src/TagzApp.Web/Services/Base/BaseProviderManager.cs @@ -0,0 +1,82 @@ +using System.Reflection; +using TagzApp.Communication.Extensions; + +namespace TagzApp.Web.Services.Base; + +public class BaseProviderManager +{ + private readonly IServiceCollection _Services; + private readonly IConfiguration _Configuration; + private readonly ILogger _Logger; + protected IEnumerable _Providers; + + public BaseProviderManager(IConfiguration configuration, ILogger logger, + IEnumerable? socialMediaProviders) + { + _Services = new ServiceCollection(); + _Configuration = configuration; + _Logger = logger; + _Providers = socialMediaProviders != null && socialMediaProviders.Count() > 0 + ? socialMediaProviders : new List(); + } + + public void InitProviders() + { + if (!_Providers.Any()) + { + LoadConfigurationProviders(); + } + } + + private void LoadConfigurationProviders() + { + List configProviders = new List(); + var path = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty; + + if (!string.IsNullOrWhiteSpace(path)) + { + foreach (string dllPath in Directory.GetFiles(path, "*.dll", SearchOption.AllDirectories)) + { + try + { + var assembly = Assembly.LoadFrom(dllPath); + var providerAssemblies = assembly.GetTypes() + .Where(t => typeof(IConfigureProvider).IsAssignableFrom(t) && !t.IsInterface); + + if (providerAssemblies.Any()) + { + foreach (var provider in providerAssemblies) + { + var providerInstance = Activator.CreateInstance(provider) as IConfigureProvider; + + if (providerInstance != null) + { + configProviders.Add(providerInstance); + } + } + } + } catch(Exception ex) { + _Logger.LogWarning(ex, $"Skipping {dllPath} due to error"); + } + } + + ConfigureProviders(configProviders); + } + } + + private void ConfigureProviders(IEnumerable configurationProviders) + { + var socialMediaProviders = new List(); + + foreach (var provider in configurationProviders) + { + provider.RegisterServices(_Services, _Configuration); + } + + _Services.AddPolicies(_Configuration); + + var sp = _Services.BuildServiceProvider(); + socialMediaProviders.AddRange(sp.GetServices()); + _Providers = socialMediaProviders; + } +} diff --git a/src/TagzApp.Web/Services/InMemoryMessagingService.cs b/src/TagzApp.Web/Services/InMemoryMessagingService.cs index 54062bbb..832b0272 100644 --- a/src/TagzApp.Web/Services/InMemoryMessagingService.cs +++ b/src/TagzApp.Web/Services/InMemoryMessagingService.cs @@ -3,26 +3,26 @@ using System.Collections.Immutable; using TagzApp.Web.Data; using TagzApp.Web.Hubs; +using TagzApp.Web.Services.Base; namespace TagzApp.Web.Services; -public class InMemoryMessagingService : IHostedService +public class InMemoryMessagingService : BaseProviderManager, IHostedService { private InMemoryContentMessaging _Service = default; - private readonly IEnumerable _Providers; private readonly IHubContext _HubContext; private readonly ILogger _Logger; public InMemoryMessagingService( - IEnumerable providers, + IConfiguration configuration, IHubContext hubContext, - ILogger logger - ) + ILogger logger, + IEnumerable? socialMediaProviders = null + ):base(configuration, logger, socialMediaProviders) { - _Providers = providers; _HubContext = hubContext; _Logger = logger; } @@ -36,7 +36,7 @@ ILogger logger public Task StartAsync(CancellationToken cancellationToken) { - + InitProviders(); _Service = new InMemoryContentMessaging(); _Service.StartProviders(_Providers, cancellationToken); diff --git a/src/TagzApp.Web/ServicesExtensions.cs b/src/TagzApp.Web/ServicesExtensions.cs index c3019bb4..dea5ee92 100644 --- a/src/TagzApp.Web/ServicesExtensions.cs +++ b/src/TagzApp.Web/ServicesExtensions.cs @@ -8,85 +8,37 @@ namespace TagzApp.Web; public static class ServicesExtensions { - public static IServiceCollection ConfigureProvider(this IServiceCollection services, IConfiguration configuration) where T : IConfigureProvider, new() - { + public static IServiceCollection AddTagzAppHostedServices(this IServiceCollection services, IConfigurationRoot configuration) + { - var providerStart = (IConfigureProvider)(Activator.CreateInstance()); - providerStart.RegisterServices(services, configuration); + services.AddSingleton(); + services.AddHostedService(s => s.GetRequiredService()); - return services; + return services; - } - - public static IServiceCollection ConfigureProvider(this IServiceCollection services, IConfigureProvider provider, IConfiguration configuration) - { - - provider.RegisterServices(services, configuration); - - return services; - - } - - public static IServiceCollection AddTagzAppHostedServices(this IServiceCollection services, IConfigurationRoot configuration) - { - - services.AddSingleton(); - services.AddHostedService(s => s.GetRequiredService()); - - // Register the providers - if (SocialMediaProviders.Any()) - { - foreach (var item in SocialMediaProviders) - { - services.ConfigureProvider(item, configuration); - } - } - else - { - services.ConfigureProvider(configuration); - services.ConfigureProvider(configuration); - services.ConfigureProvider(configuration); - } - - return services; - - } + } - /// - /// A collection of externally configured providers - /// - public static List SocialMediaProviders { get; set; } = new(); - public static AuthenticationBuilder AddExternalProvider(this AuthenticationBuilder builder, string name, IConfiguration configuration, + /// + /// A collection of externally configured providers + /// + public static AuthenticationBuilder AddExternalProvider(this AuthenticationBuilder builder, string name, IConfiguration configuration, Action action) - { + { var section = configuration.GetSection($"Authentication:{name}"); if (section is not null) action(section); return builder; } - public static AuthenticationBuilder AddExternalProvider(this AuthenticationBuilder builder, string name, IConfiguration configuration, - Action action) - { - return builder.AddExternalProvider(name, configuration, (section) => { - var clientID = section["ClientID"]; - var clientSecret = section["ClientSecret"]; - if (!string.IsNullOrEmpty(clientID) && !string.IsNullOrEmpty(clientSecret)) - { - action(clientID, clientSecret); - } - }); - } - public static AuthenticationBuilder AddExternalProvider(this AuthenticationBuilder builder, string name, IConfiguration configuration, Action> action) - { + { return builder.AddExternalProvider(name, configuration, (section) => { var clientID = section["ClientID"]; var clientSecret = section["ClientSecret"]; if (!string.IsNullOrEmpty(clientID) && !string.IsNullOrEmpty(clientSecret)) { action(options => - { + { options.ClientId = clientID; options.ClientSecret = clientSecret; }); @@ -95,18 +47,18 @@ public static AuthenticationBuilder AddExternalProvider(this AuthenticationBuild } public static AuthenticationBuilder AddExternalProviders(this AuthenticationBuilder builder, IConfiguration configuration) - { + { - builder.AddExternalProvider("Microsoft", configuration , options => builder.AddMicrosoftAccount(options)); + builder.AddExternalProvider("Microsoft", configuration, options => builder.AddMicrosoftAccount(options)); builder.AddExternalProvider("GitHub", configuration, options => builder.AddGitHub(options)); builder.AddExternalProvider("LinkedIn", configuration, options => builder.AddLinkedIn(options)); - return builder; + return builder; - } + } public static async Task InitializeSecurity(this IServiceProvider services) - { + { using var scope = services.CreateScope(); diff --git a/src/TagzApp.WebTest/PlaywrightWebApplicationFactory.cs b/src/TagzApp.WebTest/PlaywrightWebApplicationFactory.cs new file mode 100644 index 00000000..10ce0156 --- /dev/null +++ b/src/TagzApp.WebTest/PlaywrightWebApplicationFactory.cs @@ -0,0 +1,230 @@ +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Logging.Abstractions; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +namespace TagzApp.WebTest; + +/// +/// WebApplicationFactory that wraps the TestHost in a Kestrel server. +/// +/// +///

+/// Credit to https://github.com/CZEMacLeod for writing this. +///

+///
+public class PlaywrightWebApplicationFactory : WebApplicationFactory, IAsyncLifetime +{ + private TestServer? _TestServer; + private IPlaywright? _Playwright; + private IBrowser? _Browser; + private IPage? _StaticPage; + private string? _Uri; + private readonly IMessageSink _Output; + + private static int _NextPort = 0; + private bool _Headless = true; + + public string? Uri => _Uri; + + protected virtual string? Environment { get; } = "Development"; + + public PlaywrightWebApplicationFactory(IMessageSink output) => this._Output = output; + + [MemberNotNull(nameof(_Uri))] + protected override IHost CreateHost(IHostBuilder builder) + { + + if (Environment is not null) + { + builder.UseEnvironment(Environment); + } + builder.ConfigureLogging(logging => + { + logging.SetMinimumLevel(LogLevel.Trace); + logging.AddProvider(new MessageSinkProvider(_Output)); + }); + + // We randomize the server port so we ensure that any hard coded Uri's fail in the tests. + // This also allows multiple servers to run during the tests. + var port = 5000 + Interlocked.Add(ref _NextPort, 10 + System.Random.Shared.Next(10)); + _Uri = $"http://localhost:{port}"; + + // We the testHost, which can be used with HttpClient with a custom transport + // It is assumed that the return of CreateHost is a host based on the TestHost Server. + var testHost = base.CreateHost(builder); + + // Now we reconfigure the builder to use kestrel so we have an http listener that can be used by playwright + builder.ConfigureWebHost(webHostBuilder => webHostBuilder.UseKestrel(options => + { + options.ListenLocalhost(port); + })); + var host = base.CreateHost(builder); + + UpdateUriFromHost(host); // For some reason, the kestrel server host does not seem to return the addresses. + + return new CompositeHost(testHost, host); + } + + private void UpdateUriFromHost(IHost host) + { + var server = host.Services.GetRequiredService(); + var addresses = server.Features.Get() ?? throw new NullReferenceException("Could not get IServerAddressesFeature"); + var serverAddress = addresses.Addresses.FirstOrDefault(); + + if (serverAddress is not null) + { + _Uri = serverAddress; + } + else + { + var message = new Xunit.Sdk.DiagnosticMessage("Could not get server address from IServerAddressesFeature"); + _Output.OnMessage(message); + } + } + + public async Task CreatePlaywrightPageAsync(bool headless = true) + { + // This method should instantiate a new browser and page for each test. + // Fix for when browser was not launching in visible mode (headless=false) + // TODO: determine what configuration for Browser Type & Browser Page options should be used (how to pass in and implement without bloating parameters) + _Headless = headless; + await GetBrowser(headless); + + // TODO: For future BrowserNewPageOptions can affect items like Viewport, ScreenSize size, ignoreHttpsErrors etc. + // Ref: https://playwright.dev/dotnet/docs/api/class-browser#browser-new-page + var browserNewPageOptions = new BrowserNewPageOptions() + { + BaseURL = _Uri + }; + return await _Browser.NewPageAsync(browserNewPageOptions); + } + + public async Task CreateSingletonPlaywrightPageAsync(bool headless = true) + { + // This method should instantiate a page for all tests in a class where tests dependencies and use same browser page. + // TODO Discuss whether this needs to be a singleton or implemented differently + if (_StaticPage is null) { _StaticPage = await CreatePlaywrightPageAsync(headless); } + return _StaticPage; + } + + [MemberNotNull(nameof(_Browser))] + public async Task GetBrowser(bool headless = true, bool devtools = false) + { + // TODO: For future BrowserTypeLaunchOptions typeOptions can also effect type of Browser to launch + // (We are only using Chromium for now) Note: Channel options exist for MSedge and Chrome also + // Also, consider SlowMo, Timeout options + // Refs: + // https://playwright.dev/dotnet/docs/api/class-browsertype#browsertypelaunchoptions + // Future Guide for runtime configs: https://playwright.dev/dotnet/docs/next/browsers + + var typeOptions = new BrowserTypeLaunchOptions() { Headless = headless, Devtools = devtools }; + if (_Browser is null) + { +#pragma warning disable CS8602 // Dereference of a possibly null reference. + // Browser Type gets cached as part of the app + _Browser ??= (await _Playwright.Chromium.LaunchAsync(typeOptions)) ?? throw new InvalidOperationException(); +#pragma warning restore CS8602 // Dereference of a possibly null reference. + } + return _Browser; + } + + [MemberNotNull(nameof(_TestServer), nameof(_Playwright))] + public async Task InitializeAsync() + { + // Test classes which inherit IClassFixture will have this called automatically. + // This method should only ever need to be called once as part of the xUnit test lifecycle. + _TestServer = Server; // Ensures Server (TestServer WebApplicationFactory) is initialized + +#pragma warning disable CS8774 // Member must have a non-null value when exiting. + _Playwright ??= (await Playwright.CreateAsync()) ?? throw new InvalidOperationException(); + //_Browser ??= (await _Playwright.Chromium.LaunchAsync(new() { Headless = _Headless, Devtools = true })) ?? throw new InvalidOperationException(); +#pragma warning restore CS8774 // Member must have a non-null value when exiting. + } + + async Task IAsyncLifetime.DisposeAsync() + { + if (_Browser is not null) + { + await _Browser.DisposeAsync(); + } + _Browser = null; + _Playwright?.Dispose(); + _Playwright = null; + } + + // CompositeHost is based on https://github.com/xaviersolau/DevArticles/blob/e2e_test_blazor_with_playwright/MyBlazorApp/MyAppTests/WebTestingHostFactory.cs + // Relay the call to both test host and kestrel host. + public class CompositeHost : IHost + { + private readonly IHost testHost; + private readonly IHost kestrelHost; + public CompositeHost(IHost testHost, IHost kestrelHost) + { + this.testHost = testHost; + this.kestrelHost = kestrelHost; + } + public IServiceProvider Services => testHost.Services; + public void Dispose() + { + testHost.Dispose(); + kestrelHost.Dispose(); + } + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await testHost.StartAsync(cancellationToken); + await kestrelHost.StartAsync(cancellationToken); + } + public async Task StopAsync(CancellationToken cancellationToken = default) + { + await testHost.StopAsync(cancellationToken); + await kestrelHost.StopAsync(cancellationToken); + } + } + + private class MessageSinkProvider : ILoggerProvider + { + private IMessageSink? output; + + private readonly ConcurrentDictionary _loggers = new(StringComparer.OrdinalIgnoreCase); + + public MessageSinkProvider(IMessageSink output) => this.output = output; + + public ILogger CreateLogger(string categoryName) => + _loggers.GetOrAdd(categoryName, name => output is null ? NullLogger.Instance : new MessageSinkLogger(name, output)); + + protected virtual void Dispose(bool disposing) { output = null; } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + private class MessageSinkLogger : ILogger + { + private string name; + private IMessageSink output; + + public MessageSinkLogger(string name, IMessageSink output) + { + this.name = name; + this.output = output; + } + + public IDisposable BeginScope(TState state) where TState : notnull => default!; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var message = new Xunit.Sdk.DiagnosticMessage(name + ":" + formatter(state, exception)); + output.OnMessage(message); + } + } + } +} \ No newline at end of file