From f93ff8cf3a9894898406450e19972844a2cd787f Mon Sep 17 00:00:00 2001 From: Cynthia MacLeod Date: Sat, 19 Aug 2023 20:47:16 +0100 Subject: [PATCH] Fix tests and other clean up. (#12) * Fix chromium issue when launching on a 'blocked' port. This fixes failures when the host decides to run on e.g. 5060 or 5061. Also exposes Port as a public property, and inits Port and Uri on demand. * Add EnsureServerStartedAsync and use IHostApplicationLifetime to ensure server is running before returning Playwright browser/context/page etc. * Make it possible to get both hosts from CompositeHost via Services * Make it possible to enumerate the IServer instances from the hosts. * Add a simple test to check the requested url matches playwright and the server's listening addresses. --- build/azure-pipelines.yml | 9 +- samples/Sample.WebApp/Program.cs | 2 +- .../PlaywrightWebApplicationFactory.cs | 90 +++++++++++++------ test/Sample.WebApp.Tests/SimpleTests.cs | 33 ++++++- 4 files changed, 105 insertions(+), 29 deletions(-) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index 1e620ab..72b64eb 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -49,7 +49,14 @@ trigger: pr: none pool: - vmImage: windows-latest +# vmImage: windows-latest + name: 'C3D Windows' + demands: + - msbuild + - MSBuild_17.0 + - visualstudio + - Agent.OS -equals Windows_NT + - VisualStudio.BuildTools.Version -gtVersion 17.0 variables: - group: Github-Packages diff --git a/samples/Sample.WebApp/Program.cs b/samples/Sample.WebApp/Program.cs index 8be47ad..d64718b 100644 --- a/samples/Sample.WebApp/Program.cs +++ b/samples/Sample.WebApp/Program.cs @@ -31,9 +31,9 @@ public static void Main(string[] args) { // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); + app.UseHttpsRedirection(); } - app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); diff --git a/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs b/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs index fef6195..fdc44d9 100644 --- a/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs +++ b/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs @@ -1,9 +1,10 @@ using C3D.Extensions.Playwright.AspNetCore.Utilities; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Microsoft.Playwright; using System.Diagnostics.CodeAnalysis; @@ -14,12 +15,18 @@ public class PlaywrightWebApplicationFactory : WebApplicationFactory port ??= 5000 + Interlocked.Add(ref nextPort, 10 + System.Random.Shared.Next(10)); + public string Uri => uri ??= $"http://localhost:{Port}"; private static int nextPort = 0; - public string? Uri => uri; - #region "Overridable Properties" // Properties in this region can be overridden in a derived type and used as a fixture // If you create multiple derived fixtures, and derived tests injecting each one into a base test class @@ -35,19 +42,19 @@ public class PlaywrightWebApplicationFactory : WebApplicationFactory pageOptions ??= new() { - BaseURL = uri + BaseURL = Uri }; private BrowserNewContextOptions? contextOptions; protected virtual BrowserNewContextOptions ContextOptions => contextOptions ??= new() { - BaseURL = uri + BaseURL = Uri }; public virtual LogLevel MinimumLogLevel => LogLevel.Trace; #endregion - protected virtual IBrowserType GetBrowser(PlaywrightBrowserType? browserType=null) => (browserType ?? BrowserType) switch + protected virtual IBrowserType GetBrowser(PlaywrightBrowserType? browserType = null) => (browserType ?? BrowserType) switch { PlaywrightBrowserType.Chromium => playwright?.Chromium, PlaywrightBrowserType.Firefox => playwright?.Firefox, @@ -55,14 +62,12 @@ public class PlaywrightWebApplicationFactory : WebApplicationFactory throw new ArgumentOutOfRangeException(nameof(browserType)) } ?? throw new InvalidOperationException("Could not get browser type"); - protected virtual ILoggingBuilder ConfigureLogging(ILoggingBuilder builder) { builder.SetMinimumLevel(MinimumLogLevel); return builder; } - [MemberNotNull(nameof(uri))] protected override IHost CreateHost(IHostBuilder builder) { if (Environment is not null) @@ -71,11 +76,6 @@ protected override IHost CreateHost(IHostBuilder builder) } builder.ConfigureLogging(logging => ConfigureLogging(logging)); - // 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); @@ -83,21 +83,31 @@ protected override IHost CreateHost(IHostBuilder 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); + options.ListenLocalhost(Port); })); var host = base.CreateHost(builder); + lifetime = host.Services.GetRequiredService(); + lifetime.ApplicationStarted.Register(() => hostStarted.SetResult()); + return new CompositeHost(testHost, host); } public async Task GetDefaultPlaywrightBrowserAsync() { - _ = Server; // Ensure Server is initialized - await InitializeAsync(); // Ensure Playwright is initialized + await EnsureServerStartedAsync(); // Ensure Server is initialized + await InitializeAsync(); // Ensure Playwright is initialized return browser; } + private async Task EnsureServerStartedAsync() + { + if (hostStarted.Task.IsCompleted) return; + _ = Server; // Ensure Server is initialized + await hostStarted.Task.ConfigureAwait(false); + } + /// /// Creates a new Browser instance. /// @@ -108,12 +118,12 @@ public async Task GetDefaultPlaywrightBrowserAsync() public async Task CreateCustomPlaywrightBrowserAsync(PlaywrightBrowserType? browserType = null, Action? browserOptions = null) { - _ = Server; // Ensure Server is initialized - await InitializeAsync(); // Ensure Playwright is initialized + await EnsureServerStartedAsync(); // Ensure Server is initialized + await InitializeAsync(); // Ensure Playwright is initialized EnsureBrowserInstalled(browserType); var launchOptions = new BrowserTypeLaunchOptions(LaunchOptions); browserOptions?.Invoke(launchOptions); - return await GetBrowser(browserType).LaunchAsync(launchOptions); + return await LaunchAsync(GetBrowser(browserType), launchOptions); } private void EnsureBrowserInstalled(PlaywrightBrowserType? browserType) @@ -129,8 +139,8 @@ private void EnsureBrowserInstalled(PlaywrightBrowserType? browserType) /// The consumer should close the page when they are finished with it. public async Task CreatePlaywrightPageAsync(Action? pageOptions = null) { - _ = Server; // Ensure Server is initialized - await InitializeAsync(); // Ensure Playwright is initialized + await EnsureServerStartedAsync(); // Ensure Server is initialized + await InitializeAsync(); // Ensure Playwright is initialized var options = new BrowserNewPageOptions(PageOptions); pageOptions?.Invoke(options); @@ -164,8 +174,8 @@ public async Task CreateCustomPlaywrightBrowserPageAsync( /// The consumer is responsible for the correct closure and disposal of the context and any pages creates in it. public async Task CreatePlaywrightContextAsync(Action? contextOptions = null) { - _ = Server; // Ensure Server is initialized - await InitializeAsync(); // Ensure Playwright is initialized + await EnsureServerStartedAsync(); // Ensure Server is initialized + await InitializeAsync(); // Ensure Playwright is initialized var options = new BrowserNewContextOptions(ContextOptions); contextOptions?.Invoke(options); @@ -195,10 +205,27 @@ public virtual async Task InitializeAsync() PlaywrightUtilities.InstallPlaywright(BrowserType); #pragma warning disable CS8774 // Member must have a non-null value when exiting. playwright ??= (await Microsoft.Playwright.Playwright.CreateAsync()) ?? throw new InvalidOperationException(); - browser ??= (await GetBrowser().LaunchAsync(LaunchOptions)) ?? throw new InvalidOperationException(); + browser ??= (await LaunchAsync(GetBrowser(), LaunchOptions)) ?? throw new InvalidOperationException(); #pragma warning restore CS8774 // Member must have a non-null value when exiting. } + private async Task LaunchAsync(IBrowserType browser, BrowserTypeLaunchOptions options) + { + var args = (options.Args ?? Array.Empty()).ToList(); + var ports = args.SingleOrDefault(arg => arg.StartsWith("--explicitly-allowed-ports")); + if (ports is null && browser.Name == "chromium") + { + // In chromium some ports are blocked, such as sip ports 5060/5061. + // As these may be picked, we explicitly allow whatever port this host is running on. + args.Add($"--explicitly-allowed-ports={Port}"); + options = new(options) + { + Args = args + }; + } + return await browser.LaunchAsync(options); + } + protected override void Dispose(bool disposing) { base.Dispose(disposing); @@ -228,7 +255,7 @@ public async override ValueTask DisposeAsync() // 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. - internal sealed class CompositeHost : IHost + internal sealed class CompositeHost : IHost, IServiceProvider { private readonly IHost testHost; private readonly IHost kestrelHost; @@ -237,13 +264,24 @@ public CompositeHost(IHost testHost, IHost kestrelHost) this.testHost = testHost; this.kestrelHost = kestrelHost; } - public IServiceProvider Services => testHost.Services; + public IServiceProvider Services => this; public void Dispose() { testHost.Dispose(); kestrelHost.Dispose(); GC.SuppressFinalize(this); } + + public object? GetService(Type serviceType) + { + if (serviceType == typeof(IEnumerable)) return new IHost[] { testHost, kestrelHost }; + if (serviceType == typeof(IEnumerable)) return new IServer[] { + testHost.Services.GetRequiredService(), + kestrelHost.Services.GetRequiredService() + }; + return testHost.Services.GetService(serviceType); + } + public async Task StartAsync(CancellationToken cancellationToken = default) { await testHost.StartAsync(cancellationToken); diff --git a/test/Sample.WebApp.Tests/SimpleTests.cs b/test/Sample.WebApp.Tests/SimpleTests.cs index 9aebd74..553d91f 100644 --- a/test/Sample.WebApp.Tests/SimpleTests.cs +++ b/test/Sample.WebApp.Tests/SimpleTests.cs @@ -1,4 +1,7 @@ using C3D.Extensions.Playwright.AspNetCore.Xunit; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; using System.Runtime.CompilerServices; using Xunit.Abstractions; @@ -28,4 +31,32 @@ public async Task CheckHomePageTitle() await page.CloseAsync(); } -} \ No newline at end of file + [Fact] + public async Task CheckServerUrls() + { + WriteFunctionName(); + + var page = await webApplication.CreatePlaywrightPageAsync(); + await page.GotoAsync("/"); + + var uri = page.Url; + var webAppUri = new Uri(webApplication.Uri).ToString(); // Note, this will add the trailing / to the Uri + + var addresses = webApplication.Services.GetServices() + .SelectMany(server => server.Features.Get()?.Addresses ?? Enumerable.Empty()); + + outputHelper.WriteLine("Playwright Url: {0}", uri); + outputHelper.WriteLine("Web Application Url: {0}", webApplication.Uri); + foreach (var address in addresses) + { + outputHelper.WriteLine("Server Address: {0}", address); + } + + + Assert.Equal(webAppUri, uri); // Check playwright goes to expected page + Assert.Collection(addresses, + address => Assert.Equal(address,webApplication.Uri) + ); // Check the server listens only on the expected address + + } +}