diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5d08a6e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,93 @@ +[*.cs] + +dotnet_diagnostic.CS0169.severity = warning +dotnet_diagnostic.CS0414.severity = warning + +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = block_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_throw_expression = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_indent_labels = one_less_than_current +csharp_style_prefer_index_operator = true:suggestion + +[*.{cs,vb}] +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +end_of_line = crlf diff --git a/C3D.Extensions.Playwright.AspNetCore.sln b/C3D.Extensions.Playwright.AspNetCore.sln index c5673fd..8bbd557 100644 --- a/C3D.Extensions.Playwright.AspNetCore.sln +++ b/C3D.Extensions.Playwright.AspNetCore.sln @@ -7,6 +7,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "C3D.Extensions.Playwright.A EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{DA726315-C6FB-4BCB-A766-4BD32A9643A2}" ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig Directory.Build.props = Directory.Build.props Directory.Build.targets = Directory.Build.targets global.json = global.json diff --git a/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs b/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs index fdc44d9..e4589f4 100644 --- a/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs +++ b/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.Playwright; using System.Diagnostics.CodeAnalysis; @@ -22,9 +23,26 @@ public class PlaywrightWebApplicationFactory : WebApplicationFactory port ??= 5000 + Interlocked.Add(ref nextPort, 10 + System.Random.Shared.Next(10)); + public virtual int Port => port ??= GetNextPort(); public string Uri => uri ??= $"http://localhost:{Port}"; + private static readonly int[] avoidPorts = new[] { + 5060, 5061, + 6000, + 6566, + 6665, 6666, 6667, 6668, 6669, + 6697, 10080 }; + + private int GetNextPort() + { + var port = 5000 + Interlocked.Add(ref nextPort, 10 + System.Random.Shared.Next(10)); + while (avoidPorts.Contains(port)) // We shouldn't ever roll into the next port because we have a space of at least 10 ports + { + port++; + } + return port; + } + private static int nextPort = 0; #region "Overridable Properties" @@ -123,7 +141,7 @@ public async Task CreateCustomPlaywrightBrowserAsync(PlaywrightBrowser EnsureBrowserInstalled(browserType); var launchOptions = new BrowserTypeLaunchOptions(LaunchOptions); browserOptions?.Invoke(launchOptions); - return await LaunchAsync(GetBrowser(browserType), launchOptions); + return await LaunchBrowserAsync(GetBrowser(browserType), launchOptions); } private void EnsureBrowserInstalled(PlaywrightBrowserType? browserType) @@ -205,25 +223,85 @@ 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 LaunchAsync(GetBrowser(), LaunchOptions)) ?? throw new InvalidOperationException(); + browser ??= (await LaunchBrowserAsync(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) + private async Task LaunchBrowserAsync(IBrowserType browser, BrowserTypeLaunchOptions options) + { + List args = new(options.Args ?? Enumerable.Empty()); + + // In most browsers, ports are blocked, such as sip ports 5060/5061. + // As these may be picked, we explicitly allow whatever port this host is running on. + + switch (browser.Name) + { + case "chromium": + options = UpdateChromiumOptions(options, args); + break; + case "firefox": + options = UpdateFirefoxOptions(options); + + break; + } + return await browser.LaunchAsync(options); + } + + private BrowserTypeLaunchOptions UpdateFirefoxOptions(BrowserTypeLaunchOptions options) + { + var prefs = options.FirefoxUserPrefs is null ? new Dictionary() : new Dictionary(options.FirefoxUserPrefs); + if (prefs.TryGetValue("network.security.ports.banned.override", out var portsList)) + { + var ports = ((string)portsList).Split(',').Select(p => int.Parse(p)).ToList(); + if (!ports.Contains(Port)) + { + ports.Add(Port); + prefs["network.security.ports.banned.override"] = string.Join(',', ports); + options = new(options) + { + FirefoxUserPrefs = prefs + }; + } + } + else + { + prefs["network.security.ports.banned.override"] = Port.ToString(); + options = new(options) + { + FirefoxUserPrefs = prefs + }; + } + + return options; + } + + private BrowserTypeLaunchOptions UpdateChromiumOptions(BrowserTypeLaunchOptions options, List args) { - var args = (options.Args ?? Array.Empty()).ToList(); - var ports = args.SingleOrDefault(arg => arg.StartsWith("--explicitly-allowed-ports")); - if (ports is null && browser.Name == "chromium") + var portsArg = args.SingleOrDefault(arg => arg.StartsWith("--explicitly-allowed-ports")); + if (portsArg is null) { - // 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); + else + { + var ports = portsArg.Split('=', 2)[1].Split(',').Select(p => int.Parse(p)).ToList(); + if (!ports.Contains(Port)) + { + ports.Add(Port); + args.Remove(portsArg); + args.Add($"--explicitly-allowed-ports={string.Join(',', ports)}"); + options = new(options) + { + Args = args + }; + } + } + + return options; } protected override void Dispose(bool disposing) diff --git a/test/Sample.WebApp.Tests/BlockedPortsTests.cs b/test/Sample.WebApp.Tests/BlockedPortsTests.cs new file mode 100644 index 0000000..4ec2136 --- /dev/null +++ b/test/Sample.WebApp.Tests/BlockedPortsTests.cs @@ -0,0 +1,90 @@ +using C3D.Extensions.Playwright.AspNetCore; +using C3D.Extensions.Playwright.AspNetCore.Xunit; +using Microsoft.AspNetCore.Builder; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Xunit.Abstractions; + +namespace Sample.WebApp.Tests; + +public class Port5060Fixture : PlaywrightFixture +{ + public Port5060Fixture(IMessageSink output) : base(output) { } + + public override int Port => 5060; // Force the application to port 5060. +} + +public class BlockedPortsTests : IClassFixture +{ + private readonly PlaywrightFixture webApplication; + private readonly ITestOutputHelper outputHelper; + + public BlockedPortsTests(Port5060Fixture webApplication, ITestOutputHelper outputHelper) + { + this.webApplication = webApplication; + this.outputHelper = outputHelper; + } + + private void WriteFunctionName([CallerMemberName] string? caller = null) => outputHelper.WriteLine(caller); + + + [Fact] + public async Task ChromiumAllowsBlockedPort() + { + WriteFunctionName(); + + var page = await webApplication.CreatePlaywrightPageAsync(); + await page.GotoAsync("/"); + + var pageTitle = await page.TitleAsync(); + var uri = page.Url; + var webAppUri = new Uri(webApplication.Uri).ToString(); // Note, this will add the trailing / to the Uri + + await page.CloseAsync(); + + Assert.Equal(webAppUri, uri); // Check browser goes to expected page + Assert.Equal("Home page", pageTitle); // Check browser can read title + } + + + [Fact] + public async Task FireFoxAllowsBlockedPort() + { + WriteFunctionName(); + + await using var browserPage = await webApplication.CreateCustomPlaywrightBrowserPageAsync(PlaywrightBrowserType.Firefox); + var page = browserPage.Page; // This is for convienence only + await page.GotoAsync("/"); + + var pageTitle = await page.TitleAsync(); + var uri = page.Url; + var webAppUri = new Uri(webApplication.Uri).ToString(); // Note, this will add the trailing / to the Uri + // We don't need to close the page here as the browserPage utility object will do it for us. + + Assert.Equal(webAppUri, uri); // Check browser goes to expected page + Assert.Equal("Home page", pageTitle); // Check browser can read title + } + + [Fact] + public async Task WebkitDoesNotAllowBlockedPort() + { + WriteFunctionName(); + + await using var browserPage = await webApplication.CreateCustomPlaywrightBrowserPageAsync(PlaywrightBrowserType.Webkit); + var page = browserPage.Page; // This is for convienence only + await page.GotoAsync("/"); + + var pageTitle = await page.TitleAsync(); + var uri = page.Url; + var webAppUri = new Uri(webApplication.Uri).ToString(); // Note, this will add the trailing / to the Uri + // We don't need to close the page here as the browserPage utility object will do it for us. + + Assert.NotEqual(webAppUri, uri); // Check browser *didn't* go to expected page + Assert.NotEqual("Home page", pageTitle); // Check browser *didn't* read title + } + +}