diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 141eb757a838..c6054f70769a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -13,6 +13,12 @@ "commands": [ "dotnet-format" ] + }, + "playwright-sharp-tool": { + "version": "0.170.2", + "commands": [ + "playwright-sharp" + ] } } } diff --git a/AspNetCore.sln b/AspNetCore.sln index 19a61c8895b4..25201eefd3ac 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1568,6 +1568,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IIS.Common.TestLib", "src\S EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InProcessNewShimWebSite", "src\Servers\IIS\IIS\test\testassets\InProcessNewShimWebSite\InProcessNewShimWebSite.csproj", "{22EA0993-8DFC-40C2-8481-8E85E21EFB56}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BrowserTesting", "BrowserTesting", "{8F33439F-5532-45D6-8A44-20EF9104AA9D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.BrowserTesting", "src\Shared\BrowserTesting\src\Microsoft.AspNetCore.BrowserTesting.csproj", "{B739074E-6652-4F5B-B37E-775DC2245FEC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -7435,6 +7439,18 @@ Global {22EA0993-8DFC-40C2-8481-8E85E21EFB56}.Release|x64.Build.0 = Release|x64 {22EA0993-8DFC-40C2-8481-8E85E21EFB56}.Release|x86.ActiveCfg = Release|x86 {22EA0993-8DFC-40C2-8481-8E85E21EFB56}.Release|x86.Build.0 = Release|x86 + {B739074E-6652-4F5B-B37E-775DC2245FEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B739074E-6652-4F5B-B37E-775DC2245FEC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B739074E-6652-4F5B-B37E-775DC2245FEC}.Debug|x64.ActiveCfg = Debug|Any CPU + {B739074E-6652-4F5B-B37E-775DC2245FEC}.Debug|x64.Build.0 = Debug|Any CPU + {B739074E-6652-4F5B-B37E-775DC2245FEC}.Debug|x86.ActiveCfg = Debug|Any CPU + {B739074E-6652-4F5B-B37E-775DC2245FEC}.Debug|x86.Build.0 = Debug|Any CPU + {B739074E-6652-4F5B-B37E-775DC2245FEC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B739074E-6652-4F5B-B37E-775DC2245FEC}.Release|Any CPU.Build.0 = Release|Any CPU + {B739074E-6652-4F5B-B37E-775DC2245FEC}.Release|x64.ActiveCfg = Release|Any CPU + {B739074E-6652-4F5B-B37E-775DC2245FEC}.Release|x64.Build.0 = Release|Any CPU + {B739074E-6652-4F5B-B37E-775DC2245FEC}.Release|x86.ActiveCfg = Release|Any CPU + {B739074E-6652-4F5B-B37E-775DC2245FEC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -8209,6 +8225,8 @@ Global {9B8F871E-ED33-4D2F-AA49-E39D9299EC85} = {41BB7BA4-AC08-4E9A-83EA-6D587A5B951C} {7F295396-DBBD-40A5-A645-10004D1324DA} = {41BB7BA4-AC08-4E9A-83EA-6D587A5B951C} {22EA0993-8DFC-40C2-8481-8E85E21EFB56} = {41BB7BA4-AC08-4E9A-83EA-6D587A5B951C} + {8F33439F-5532-45D6-8A44-20EF9104AA9D} = {5F0044F2-4C66-46A8-BD79-075F001AA034} + {B739074E-6652-4F5B-B37E-775DC2245FEC} = {8F33439F-5532-45D6-8A44-20EF9104AA9D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/eng/Dependencies.props b/eng/Dependencies.props index bd6f4c69b9f4..bf6613405102 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -175,6 +175,7 @@ and are generated based on the last package release. + diff --git a/eng/Versions.props b/eng/Versions.props index 93f47d9fc75e..17e5eac3dfd5 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -273,6 +273,7 @@ 1.0.2 12.0.2 13.0.4 + 0.180.0 3.0.0 7.1.0 4.0.0-alpha07 diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/AssemblyInfo.AssemblyFixtures.cs b/src/ProjectTemplates/BlazorTemplates.Tests/AssemblyInfo.AssemblyFixtures.cs index b60a694a12d7..3eb30b095263 100644 --- a/src/ProjectTemplates/BlazorTemplates.Tests/AssemblyInfo.AssemblyFixtures.cs +++ b/src/ProjectTemplates/BlazorTemplates.Tests/AssemblyInfo.AssemblyFixtures.cs @@ -1,10 +1,11 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using Microsoft.AspNetCore.E2ETesting; using Microsoft.AspNetCore.Testing; +using ProjectTemplates.Tests.Infrastructure; +using Templates.Test; using Templates.Test.Helpers; [assembly: AssemblyFixture(typeof(ProjectFactoryFixture))] -[assembly: AssemblyFixture(typeof(SeleniumStandaloneServer))] +[assembly: AssemblyFixture(typeof(PlaywrightFixture))] diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/BlazorServerTemplateTest.cs b/src/ProjectTemplates/BlazorTemplates.Tests/BlazorServerTemplateTest.cs index fac1a67ccda9..de27f48c4cb6 100644 --- a/src/ProjectTemplates/BlazorTemplates.Tests/BlazorServerTemplateTest.cs +++ b/src/ProjectTemplates/BlazorTemplates.Tests/BlazorServerTemplateTest.cs @@ -2,37 +2,44 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Linq; using System.Net; -using System.Threading; +using System.Runtime.InteropServices; using System.Threading.Tasks; -using Microsoft.AspNetCore.E2ETesting; -using Microsoft.AspNetCore.Testing; -using OpenQA.Selenium; +using Microsoft.AspNetCore.BrowserTesting; +using PlaywrightSharp; +using ProjectTemplates.Tests.Infrastructure; using Templates.Test.Helpers; using Xunit; using Xunit.Abstractions; namespace Templates.Test { - public class BlazorServerTemplateTest : BrowserTestBase + public class BlazorServerTemplateTest : BlazorTemplateTest { - public BlazorServerTemplateTest(ProjectFactoryFixture projectFactory, BrowserFixture browserFixture, ITestOutputHelper output) : base(browserFixture, output) + public BlazorServerTemplateTest(ProjectFactoryFixture projectFactory, PlaywrightFixture fixture, ITestOutputHelper output) + : base(fixture) { - ProjectFactory = projectFactory; + ProjectFactory = projectFactory; ; + Output = output; + BrowserContextInfo = new ContextInformation(CreateFactory(output)); } public ProjectFactoryFixture ProjectFactory { get; set; } - + public ITestOutputHelper Output { get; } + public ContextInformation BrowserContextInfo { get; } public Project Project { get; private set; } - [Fact] - public async Task BlazorServerTemplateWorks_NoAuth() + + [Theory] + [InlineData(BrowserKind.Chromium)] + public async Task BlazorServerTemplateWorks_NoAuth(BrowserKind browserKind) { // Additional arguments are needed. See: https://github.com/dotnet/aspnetcore/issues/24278 Environment.SetEnvironmentVariable("EnableDefaultScopedCssItems", "true"); - Project = await ProjectFactory.GetOrCreateProject("blazorservernoauth", Output); + Project = await ProjectFactory.GetOrCreateProject("blazorservernoauth" + browserKind.ToString(), Output); var createResult = await Project.RunDotNetNewAsync("blazorserver"); Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", Project, createResult)); @@ -47,6 +54,10 @@ public async Task BlazorServerTemplateWorks_NoAuth() var buildResult = await Project.RunDotNetBuildAsync(); Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", Project, buildResult)); + await using var browser = Fixture.BrowserManager.IsAvailable(browserKind) ? + await Fixture.BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo) : + null; + using (var aspNetProcess = Project.StartBuiltProjectAsync()) { Assert.False( @@ -54,14 +65,17 @@ public async Task BlazorServerTemplateWorks_NoAuth() ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", Project, aspNetProcess.Process)); await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - if (BrowserFixture.IsHostAutomationSupported()) + + if (Fixture.BrowserManager.IsAvailable(browserKind)) { - aspNetProcess.VisitInBrowser(Browser); - TestBasicNavigation(); + var page = await browser.NewPageAsync(); + await aspNetProcess.VisitInBrowserAsync(page); + await TestBasicNavigation(page); + await page.CloseAsync(); } else { - BrowserFixture.EnforceSupportedConfigurations(); + EnsureBrowserAvailable(browserKind); } } @@ -72,27 +86,31 @@ public async Task BlazorServerTemplateWorks_NoAuth() ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", Project, aspNetProcess.Process)); await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - if (BrowserFixture.IsHostAutomationSupported()) + if (Fixture.BrowserManager.IsAvailable(browserKind)) { - aspNetProcess.VisitInBrowser(Browser); - TestBasicNavigation(); + var page = await browser.NewPageAsync(); + await aspNetProcess.VisitInBrowserAsync(page); + await TestBasicNavigation(page); + await page.CloseAsync(); } else { - BrowserFixture.EnforceSupportedConfigurations(); + EnsureBrowserAvailable(browserKind); } } } + public static IEnumerable BlazorServerTemplateWorks_IndividualAuthData => + BrowserManager.WithBrowsers(new[] { BrowserKind.Chromium }, true, false); + [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task BlazorServerTemplateWorks_IndividualAuth(bool useLocalDB) + [MemberData(nameof(BlazorServerTemplateWorks_IndividualAuthData))] + public async Task BlazorServerTemplateWorks_IndividualAuth(BrowserKind browserKind, bool useLocalDB) { // Additional arguments are needed. See: https://github.com/dotnet/aspnetcore/issues/24278 Environment.SetEnvironmentVariable("EnableDefaultScopedCssItems", "true"); - Project = await ProjectFactory.GetOrCreateProject("blazorserverindividual" + (useLocalDB ? "uld" : ""), Output); + Project = await ProjectFactory.GetOrCreateProject("blazorserverindividual" + browserKind + (useLocalDB ? "uld" : ""), Output); var createResult = await Project.RunDotNetNewAsync("blazorserver", auth: "Individual", useLocalDB: useLocalDB); Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", Project, createResult)); @@ -107,6 +125,10 @@ public async Task BlazorServerTemplateWorks_IndividualAuth(bool useLocalDB) var buildResult = await Project.RunDotNetBuildAsync(); Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", Project, buildResult)); + var browser = !Fixture.BrowserManager.IsAvailable(browserKind) ? + null : + await Fixture.BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + using (var aspNetProcess = Project.StartBuiltProjectAsync()) { Assert.False( @@ -114,18 +136,19 @@ public async Task BlazorServerTemplateWorks_IndividualAuth(bool useLocalDB) ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", Project, aspNetProcess.Process)); await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - if (BrowserFixture.IsHostAutomationSupported()) + if (Fixture.BrowserManager.IsAvailable(browserKind)) { - aspNetProcess.VisitInBrowser(Browser); - TestBasicNavigation(); + var page = await browser.NewPageAsync(); + await aspNetProcess.VisitInBrowserAsync(page); + await TestBasicNavigation(page); + await page.CloseAsync(); } else { - BrowserFixture.EnforceSupportedConfigurations(); + EnsureBrowserAvailable(browserKind); } } - using (var aspNetProcess = Project.StartPublishedProjectAsync()) { Assert.False( @@ -133,58 +156,55 @@ public async Task BlazorServerTemplateWorks_IndividualAuth(bool useLocalDB) ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", Project, aspNetProcess.Process)); await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - if (BrowserFixture.IsHostAutomationSupported()) + if (Fixture.BrowserManager.IsAvailable(browserKind)) { - aspNetProcess.VisitInBrowser(Browser); - TestBasicNavigation(); + var page = await browser.NewPageAsync(); + await aspNetProcess.VisitInBrowserAsync(page); + await TestBasicNavigation(page); + await page.CloseAsync(); + } + else + { + EnsureBrowserAvailable(browserKind); } } } - private void TestBasicNavigation() + private async Task TestBasicNavigation(IPage page) { - var retries = 3; - var connected = false; - do - { - try - { - Browser.Contains("Information: WebSocket connected to", - () => string.Join(Environment.NewLine, Browser.GetBrowserLogs(LogLevel.Info).Select(b => b.Message))); - connected = true; - } - catch (TimeoutException) when(retries-- > 0) - { - Browser.Navigate().Refresh(); - } - } while (!connected && retries > 0); + var socket = BrowserContextInfo.Pages[page].WebSockets.SingleOrDefault() ?? + (await page.WaitForEventAsync(PageEvent.WebSocket)).WebSocket; + + // Receive render batch + await socket.WaitForEventAsync(WebSocketEvent.FrameReceived); + await socket.WaitForEventAsync(WebSocketEvent.FrameSent); + // JS interop call to intercept navigation + await socket.WaitForEventAsync(WebSocketEvent.FrameReceived); + await socket.WaitForEventAsync(WebSocketEvent.FrameSent); - Browser.Exists(By.TagName("ul")); + await page.WaitForSelectorAsync("ul"); // element gets project ID injected into it during template execution - Browser.Equal(Project.ProjectName.Trim(), () => Browser.Title.Trim()); + Assert.Equal(Project.ProjectName.Trim(), (await page.GetTitleAsync()).Trim()); // Initially displays the home page - Browser.Equal("Hello, world!", () => Browser.FindElement(By.TagName("h1")).Text); + await page.WaitForSelectorAsync("h1 >> text=Hello, world!"); // Can navigate to the counter page - Browser.Click(By.PartialLinkText("Counter")); - Browser.Contains("counter", () => Browser.Url); - Browser.Equal("Counter", () => Browser.FindElement(By.TagName("h1")).Text); + await page.ClickAsync("a[href=counter] >> text=Counter"); + await page.WaitForSelectorAsync("h1+p >> text=Current count: 0"); // Clicking the counter button works - Browser.Equal("Current count: 0", () => Browser.FindElement(By.CssSelector("h1 + p")).Text); - Browser.Click(By.CssSelector("p+button")); - Browser.Equal("Current count: 1", () => Browser.FindElement(By.CssSelector("h1 + p")).Text); + await page.ClickAsync("p+button >> text=Click me"); + await page.WaitForSelectorAsync("h1+p >> text=Current count: 1"); // Can navigate to the 'fetch data' page - Browser.Click(By.PartialLinkText("Fetch data")); - Browser.Contains("fetchdata", () => Browser.Url); - Browser.Equal("Weather forecast", () => Browser.FindElement(By.TagName("h1")).Text); + await page.ClickAsync("a[href=fetchdata] >> text=Fetch data"); + await page.WaitForSelectorAsync("h1 >> text=Weather forecast"); // Asynchronously loads and displays the table of weather forecasts - Browser.Exists(By.CssSelector("table>tbody>tr")); - Browser.Equal(5, () => Browser.FindElements(By.CssSelector("p+table>tbody>tr")).Count); + await page.WaitForSelectorAsync("table>tbody>tr"); + Assert.Equal(5, (await page.QuerySelectorAllAsync("p+table>tbody>tr")).Count()); } [Theory] diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/BlazorTemplates.Tests/BlazorTemplateTest.cs new file mode 100644 index 000000000000..4057e99fec6a --- /dev/null +++ b/src/ProjectTemplates/BlazorTemplates.Tests/BlazorTemplateTest.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Runtime.InteropServices; +using Microsoft.AspNetCore.BrowserTesting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using ProjectTemplates.Tests.Infrastructure; +using Xunit; +using Xunit.Abstractions; + +namespace Templates.Test +{ + public class BlazorTemplateTest + { + public BlazorTemplateTest(PlaywrightFixture<BlazorServerTemplateTest> browserFixture) + { + Fixture = browserFixture; + } + + public PlaywrightFixture<BlazorServerTemplateTest> Fixture { get; } + + + public static ILoggerFactory CreateFactory(ITestOutputHelper output) + { + var testSink = new TestSink(); + testSink.MessageLogged += LogMessage; + var loggerFactory = new TestLoggerFactory(testSink, enabled: true); + return loggerFactory; + + void LogMessage(WriteContext ctx) + { + output.WriteLine($"{MapLogLevel(ctx)}: [Browser]{ctx.Message}"); + + static string MapLogLevel(WriteContext obj) => obj.LogLevel switch + { + LogLevel.Trace => "trace", + LogLevel.Debug => "dbug", + LogLevel.Information => "info", + LogLevel.Warning => "warn", + LogLevel.Error => "error", + LogLevel.Critical => "crit", + LogLevel.None => "info", + _ => "info" + }; + } + } + + public static bool TryValidateBrowserRequired(BrowserKind browserKind, bool isRequired, out string error) + { + error = !isRequired ? null : $"Browser '{browserKind}' is required but not configured on '{RuntimeInformation.OSDescription}'"; + return isRequired; + } + + protected void EnsureBrowserAvailable(BrowserKind browserKind) + { + Assert.False( + TryValidateBrowserRequired( + browserKind, + isRequired: !Fixture.BrowserManager.IsExplicitlyDisabled(browserKind), + out var errorMessage), + errorMessage); + } + } +} diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/BlazorTemplates.Tests.csproj b/src/ProjectTemplates/BlazorTemplates.Tests/BlazorTemplates.Tests.csproj index 9cf745603fff..f932b3471666 100644 --- a/src/ProjectTemplates/BlazorTemplates.Tests/BlazorTemplates.Tests.csproj +++ b/src/ProjectTemplates/BlazorTemplates.Tests/BlazorTemplates.Tests.csproj @@ -1,14 +1,10 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <!-- Shared testing infrastructure for running E2E tests using selenium --> - <Import Project="$(SharedSourceRoot)E2ETesting\E2ETesting.props" /> - +<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework> <TestGroupName>ProjectTemplates.E2ETests</TestGroupName> <DefineConstants>$(DefineConstants);XPLAT</DefineConstants> - <RunTemplateTests Condition="'$(RunTemplateTests)' == ''" >true</RunTemplateTests> + <RunTemplateTests Condition="'$(RunTemplateTests)' == ''">true</RunTemplateTests> <SkipTests Condition="'$(RunTemplateTests)' != 'true'">true</SkipTests> <!--Do not run this test project on Helix.--> <BuildHelixPayload>false</BuildHelixPayload> @@ -24,6 +20,11 @@ <TestDependsOnAspNetRuntime>true</TestDependsOnAspNetRuntime> </PropertyGroup> + <ItemGroup> + <Compile Include="$(SharedSourceRoot)CommandLineUtils\**\*.cs" /> + <Compile Include="$(SharedSourceRoot)CertificateGeneration\**\*.cs" LinkBase="shared\CertificateGeneration" /> + </ItemGroup> + <ItemGroup> <EmbeddedResource Include="template-baselines.json" /> <Compile Include="$(SharedSourceRoot)Process\*.cs" LinkBase="shared\Process" /> @@ -31,16 +32,29 @@ </ItemGroup> <ItemGroup> + <Content Include="playwrightSettings.*.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + <DependentUpon>playwrightSettings.json</DependentUpon> + </Content> + <Content Include="playwrightSettings.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> + + <ItemGroup> + <Reference Include="Microsoft.Extensions.Configuration" /> + <Reference Include="Microsoft.Extensions.Configuration.Json" /> + <Reference Include="Anglesharp" /> <ProjectReference Include="$(RepoRoot)src\Framework\App.Runtime\src\Microsoft.AspNetCore.App.Runtime.csproj"> <ReferenceOutputAssembly>false</ReferenceOutputAssembly> <SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties> </ProjectReference> <ProjectReference Include="$(RepoRoot)src\Hosting\Server.IntegrationTesting\src\Microsoft.AspNetCore.Server.IntegrationTesting.csproj" /> + <ProjectReference Include="$(RepoRoot)src\Shared\BrowserTesting\src\Microsoft.AspNetCore.BrowserTesting.csproj" /> <ProjectReference Include="../testassets/DotNetToolsInstaller/DotNetToolsInstaller.csproj" ReferenceOutputAssembly="false" /> <ProjectReference Include="../Web.ProjectTemplates/Microsoft.DotNet.Web.ProjectTemplates.csproj" ReferenceOutputAssembly="false" /> </ItemGroup> - <!-- Shared testing infrastructure for running E2E tests using selenium --> - <Import Project="$(SharedSourceRoot)E2ETesting\E2ETesting.targets" /> + <!-- Shared testing infrastructure for running E2E template tests --> <Import Project="..\TestInfrastructure\PrepareForTest.targets" /> </Project> diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/BlazorWasmTemplateTest.cs b/src/ProjectTemplates/BlazorTemplates.Tests/BlazorWasmTemplateTest.cs index 9e4598b29819..2f52d3e2aa30 100644 --- a/src/ProjectTemplates/BlazorTemplates.Tests/BlazorWasmTemplateTest.cs +++ b/src/ProjectTemplates/BlazorTemplates.Tests/BlazorWasmTemplateTest.cs @@ -10,43 +10,44 @@ using System.Net.Http.Headers; using System.Text.Json; using System.Text.RegularExpressions; -using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.E2ETesting; +using Microsoft.AspNetCore.BrowserTesting; using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.CommandLineUtils; using Newtonsoft.Json.Linq; -using OpenQA.Selenium; -using OpenQA.Selenium.Support.Extensions; +using PlaywrightSharp; +using ProjectTemplates.Tests.Infrastructure; using Templates.Test.Helpers; using Xunit; using Xunit.Abstractions; namespace Templates.Test { - public class BlazorWasmTemplateTest : BrowserTestBase + public class BlazorWasmTemplateTest : BlazorTemplateTest { - public BlazorWasmTemplateTest(ProjectFactoryFixture projectFactory, BrowserFixture browserFixture, ITestOutputHelper output) - : base(browserFixture, output) + public BlazorWasmTemplateTest(ProjectFactoryFixture projectFactory, PlaywrightFixture<BlazorServerTemplateTest> browserFixture, ITestOutputHelper output) + : base(browserFixture) { ProjectFactory = projectFactory; + Output = output; + BrowserContextInfo = new ContextInformation(CreateFactory(output)); } public ProjectFactoryFixture ProjectFactory { get; set; } - public override Task InitializeAsync() - { - return InitializeAsync(isolationContext: Guid.NewGuid().ToString()); - } + public ITestOutputHelper Output { get; } + + public ContextInformation BrowserContextInfo { get; } - [Fact] - public async Task BlazorWasmStandaloneTemplate_Works() + [Theory] + [InlineData(BrowserKind.Chromium)] + public async Task BlazorWasmStandaloneTemplate_Works(BrowserKind browserKind) { // Additional arguments are needed. See: https://github.com/dotnet/aspnetcore/issues/24278 Environment.SetEnvironmentVariable("EnableDefaultScopedCssItems", "true"); - var project = await ProjectFactory.GetOrCreateProject("blazorstandalone", Output); + var project = await ProjectFactory.GetOrCreateProject("blazorstandalone" + browserKind, Output); var createResult = await project.RunDotNetNewAsync("blazorwasm"); Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", project, createResult)); @@ -61,32 +62,40 @@ public async Task BlazorWasmStandaloneTemplate_Works() var buildResult = await project.RunDotNetBuildAsync(); Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", project, buildResult)); - await BuildAndRunTest(project.ProjectName, project); + await BuildAndRunTest(project.ProjectName, project, browserKind); - if (BrowserFixture.IsHostAutomationSupported()) + var (serveProcess, listeningUri) = RunPublishedStandaloneBlazorProject(project); + using (serveProcess) { - var (serveProcess, listeningUri) = RunPublishedStandaloneBlazorProject(project); - using (serveProcess) + Output.WriteLine($"Opening browser at {listeningUri}..."); + if (Fixture.BrowserManager.IsAvailable(browserKind)) { - Output.WriteLine($"Opening browser at {listeningUri}..."); - Browser.Navigate().GoToUrl(listeningUri); - TestBasicNavigation(project.ProjectName); + await using var browser = await Fixture.BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + var page = await NavigateToPage(browser, listeningUri); + await TestBasicNavigation(project.ProjectName, page); + } + else + { + EnsureBrowserAvailable(browserKind); } - } - else - { - BrowserFixture.EnforceSupportedConfigurations(); } } - [Fact] - public async Task BlazorWasmHostedTemplate_Works() + private async Task<IPage> NavigateToPage(IBrowserContext browser, string listeningUri) + { + var page = await browser.NewPageAsync(); + await page.GoToAsync(listeningUri, LifecycleEvent.Networkidle); + return page; + } + + [Theory] + [InlineData(BrowserKind.Chromium)] + public async Task BlazorWasmHostedTemplate_Works(BrowserKind browserKind) { // Additional arguments are needed. See: https://github.com/dotnet/aspnetcore/issues/24278 Environment.SetEnvironmentVariable("EnableDefaultScopedCssItems", "true"); - var project = await ProjectFactory.GetOrCreateProject("blazorhosted", Output); - + var project = await ProjectFactory.GetOrCreateProject("blazorhosted" + browserKind, Output); var createResult = await project.RunDotNetNewAsync("blazorwasm", args: new[] { "--hosted" }); Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", project, createResult)); @@ -98,7 +107,7 @@ public async Task BlazorWasmHostedTemplate_Works() var buildResult = await serverProject.RunDotNetBuildAsync(); Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", serverProject, buildResult)); - await BuildAndRunTest(project.ProjectName, serverProject); + await BuildAndRunTest(project.ProjectName, serverProject, browserKind); using var aspNetProcess = serverProject.StartPublishedProjectAsync(); @@ -108,14 +117,17 @@ public async Task BlazorWasmHostedTemplate_Works() await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); await AssertCompressionFormat(aspNetProcess, "br"); - if (BrowserFixture.IsHostAutomationSupported()) + + if (Fixture.BrowserManager.IsAvailable(browserKind)) { - aspNetProcess.VisitInBrowser(Browser); - TestBasicNavigation(project.ProjectName); + await using var browser = await Fixture.BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + var page = await browser.NewPageAsync(); + await aspNetProcess.VisitInBrowserAsync(page); + await TestBasicNavigation(project.ProjectName, page); } else { - BrowserFixture.EnforceSupportedConfigurations(); + EnsureBrowserAvailable(browserKind); } } @@ -135,8 +147,9 @@ private static async Task AssertCompressionFormat(AspNetProcess aspNetProcess, s Assert.Equal(expectedEncoding, response.Content.Headers.ContentEncoding.Single()); } - [Fact] - public async Task BlazorWasmStandalonePwaTemplate_Works() + [Theory] + [InlineData(BrowserKind.Chromium)] + public async Task BlazorWasmStandalonePwaTemplate_Works(BrowserKind browserKind) { // Additional arguments are needed. See: https://github.com/dotnet/aspnetcore/issues/24278 Environment.SetEnvironmentVariable("EnableDefaultScopedCssItems", "true"); @@ -152,32 +165,38 @@ public async Task BlazorWasmStandalonePwaTemplate_Works() var buildResult = await project.RunDotNetBuildAsync(); Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", project, buildResult)); - await BuildAndRunTest(project.ProjectName, project); + await BuildAndRunTest(project.ProjectName, project, browserKind); ValidatePublishedServiceWorker(project); - if (BrowserFixture.IsHostAutomationSupported()) + if (Fixture.BrowserManager.IsAvailable(browserKind)) { var (serveProcess, listeningUri) = RunPublishedStandaloneBlazorProject(project); + await using var browser = await Fixture.BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + Output.WriteLine($"Opening browser at {listeningUri}..."); + var page = await NavigateToPage(browser, listeningUri); using (serveProcess) { - Output.WriteLine($"Opening browser at {listeningUri}..."); - Browser.Navigate().GoToUrl(listeningUri); - TestBasicNavigation(project.ProjectName); + await TestBasicNavigation(project.ProjectName, page); } // The PWA template supports offline use. By now, the browser should have cached everything it needs, // so we can continue working even without the server. - ValidateAppWorksOffline(project, listeningUri, skipFetchData: false); + await page.GoToAsync("about:blank"); + await browser.SetOfflineAsync(true); + await page.GoToAsync(listeningUri); + await TestBasicNavigation(project.ProjectName, page, skipFetchData: true); + await page.CloseAsync(); } else { - BrowserFixture.EnforceSupportedConfigurations(); + EnsureBrowserAvailable(browserKind); } } - [Fact] - public async Task BlazorWasmHostedPwaTemplate_Works() + [Theory] + [InlineData(BrowserKind.Chromium)] + public async Task BlazorWasmHostedPwaTemplate_Works(BrowserKind browserKind) { // Additional arguments are needed. See: https://github.com/dotnet/aspnetcore/issues/24278 Environment.SetEnvironmentVariable("EnableDefaultScopedCssItems", "true"); @@ -195,13 +214,15 @@ public async Task BlazorWasmHostedPwaTemplate_Works() var buildResult = await serverProject.RunDotNetBuildAsync(); Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", serverProject, buildResult)); - await BuildAndRunTest(project.ProjectName, serverProject); + await BuildAndRunTest(project.ProjectName, serverProject, browserKind); ValidatePublishedServiceWorker(serverProject); string listeningUri = null; - if (BrowserFixture.IsHostAutomationSupported()) + if (Fixture.BrowserManager.IsAvailable(browserKind)) { + await using var browser = await Fixture.BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + IPage page = null; using (var aspNetProcess = serverProject.StartPublishedProjectAsync()) { Assert.False( @@ -209,22 +230,26 @@ public async Task BlazorWasmHostedPwaTemplate_Works() ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", serverProject, aspNetProcess.Process)); await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - aspNetProcess.VisitInBrowser(Browser); - TestBasicNavigation(project.ProjectName); + page = await browser.NewPageAsync(); + await aspNetProcess.VisitInBrowserAsync(page); + await TestBasicNavigation(project.ProjectName, page); // Note: we don't want to use aspNetProcess.ListeningUri because that isn't necessarily the HTTPS URI - var browserUri = new Uri(Browser.Url); - listeningUri = $"{browserUri.Scheme}://{browserUri.Authority}"; + listeningUri = new Uri(page.Url).GetLeftPart(UriPartial.Authority); } // The PWA template supports offline use. By now, the browser should have cached everything it needs, // so we can continue working even without the server. // Since this is the hosted project, backend APIs won't work offline, so we need to skip "fetchdata" - ValidateAppWorksOffline(project, listeningUri, skipFetchData: true); + await page.GoToAsync("about:blank"); + await browser.SetOfflineAsync(true); + await page.GoToAsync(listeningUri); + await TestBasicNavigation(project.ProjectName, page, skipFetchData: true); + await page.CloseAsync(); } else { - BrowserFixture.EnforceSupportedConfigurations(); + EnsureBrowserAvailable(browserKind); } } @@ -251,34 +276,28 @@ private void ValidatePublishedServiceWorker(Project project) Assert.True(serviceWorkerContents.Contains($"/* Manifest version: {serviceWorkerAssetsManifestVersion} */", StringComparison.Ordinal)); } - private void ValidateAppWorksOffline(Project project, string listeningUri, bool skipFetchData) - { - Browser.Navigate().GoToUrl("about:blank"); // Be sure we're really reloading - Output.WriteLine($"Opening browser without corresponding server at {listeningUri}..."); - Browser.Navigate().GoToUrl(listeningUri); - TestBasicNavigation(project.ProjectName, skipFetchData: skipFetchData); - } - - [ConditionalFact] + [ConditionalTheory] + [InlineData(BrowserKind.Chromium)] // LocalDB doesn't work on non Windows platforms [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] - public Task BlazorWasmHostedTemplate_IndividualAuth_Works_WithLocalDB() + public Task BlazorWasmHostedTemplate_IndividualAuth_Works_WithLocalDB(BrowserKind browserKind) { - return BlazorWasmHostedTemplate_IndividualAuth_Works(true); + return BlazorWasmHostedTemplate_IndividualAuth_Works(browserKind, true); } - [Fact] - public Task BlazorWasmHostedTemplate_IndividualAuth_Works_WithOutLocalDB() + [Theory] + [InlineData(BrowserKind.Chromium)] + public Task BlazorWasmHostedTemplate_IndividualAuth_Works_WithOutLocalDB(BrowserKind browserKind) { - return BlazorWasmHostedTemplate_IndividualAuth_Works(false); + return BlazorWasmHostedTemplate_IndividualAuth_Works(browserKind, false); } - private async Task BlazorWasmHostedTemplate_IndividualAuth_Works(bool useLocalDb) + private async Task BlazorWasmHostedTemplate_IndividualAuth_Works(BrowserKind browserKind, bool useLocalDb) { // Additional arguments are needed. See: https://github.com/dotnet/aspnetcore/issues/24278 Environment.SetEnvironmentVariable("EnableDefaultScopedCssItems", "true"); - var project = await ProjectFactory.GetOrCreateProject("blazorhostedindividual" + (useLocalDb ? "uld" : ""), Output); + var project = await ProjectFactory.GetOrCreateProject("blazorhostedindividual" + browserKind + (useLocalDb ? "uld" : ""), Output); var createResult = await project.RunDotNetNewAsync("blazorwasm", args: new[] { "--hosted", "-au", "Individual", useLocalDb ? "-uld" : "" }); Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", project, createResult)); @@ -318,11 +337,11 @@ private async Task BlazorWasmHostedTemplate_IndividualAuth_Works(bool useLocalDb Assert.True(0 == dbUpdateResult.ExitCode, ErrorMessages.GetFailedProcessMessage("update database", serverProject, dbUpdateResult)); } - await BuildAndRunTest(project.ProjectName, serverProject, usesAuth: true); + await BuildAndRunTest(project.ProjectName, serverProject, browserKind, usesAuth: true); UpdatePublishedSettings(serverProject); - if (BrowserFixture.IsHostAutomationSupported()) + if (Fixture.BrowserManager.IsAvailable(browserKind)) { using var aspNetProcess = serverProject.StartPublishedProjectAsync(); @@ -332,22 +351,26 @@ private async Task BlazorWasmHostedTemplate_IndividualAuth_Works(bool useLocalDb await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - aspNetProcess.VisitInBrowser(Browser); - TestBasicNavigation(project.ProjectName, usesAuth: true); + await using var browser = await Fixture.BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + var page = await browser.NewPageAsync(); + await aspNetProcess.VisitInBrowserAsync(page); + await TestBasicNavigation(project.ProjectName, page, usesAuth: true); + await page.CloseAsync(); } else { - BrowserFixture.EnforceSupportedConfigurations(); + EnsureBrowserAvailable(browserKind); } } - [Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/28596")] - public async Task BlazorWasmStandaloneTemplate_IndividualAuth_Works() + [Theory] + [InlineData(BrowserKind.Chromium, Skip = "https://github.com/dotnet/aspnetcore/issues/28596")] + public async Task BlazorWasmStandaloneTemplate_IndividualAuth_Works(BrowserKind browserKind) { // Additional arguments are needed. See: https://github.com/dotnet/aspnetcore/issues/24278 Environment.SetEnvironmentVariable("EnableDefaultScopedCssItems", "true"); - var project = await ProjectFactory.GetOrCreateProject("blazorstandaloneindividual", Output); + var project = await ProjectFactory.GetOrCreateProject("blazorstandaloneindividual" + browserKind, Output); var createResult = await project.RunDotNetNewAsync("blazorwasm", args: new[] { "-au", @@ -374,21 +397,16 @@ public async Task BlazorWasmStandaloneTemplate_IndividualAuth_Works() // but we want to make sure that we are able to run the app without errors. // That will at least test that we are able to initialize and retrieve the configuration from the IdP // for that, we use the common microsoft tenant. - await BuildAndRunTest(project.ProjectName, project, usesAuth: false); + await BuildAndRunTest(project.ProjectName, project, browserKind, usesAuth: false); - if (BrowserFixture.IsHostAutomationSupported()) - { - var (serveProcess, listeningUri) = RunPublishedStandaloneBlazorProject(project); - using (serveProcess) - { - Output.WriteLine($"Opening browser at {listeningUri}..."); - Browser.Navigate().GoToUrl(listeningUri); - TestBasicNavigation(project.ProjectName); - } - } - else + var (serveProcess, listeningUri) = RunPublishedStandaloneBlazorProject(project); + using (serveProcess) { - BrowserFixture.EnforceSupportedConfigurations(); + Output.WriteLine($"Opening browser at {listeningUri}..."); + await using var browser = await Fixture.BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + var page = await NavigateToPage(browser, listeningUri); + await TestBasicNavigation(project.ProjectName, page); + await page.CloseAsync(); } } @@ -483,7 +501,7 @@ public async Task BlazorWasmHostedTemplate_AzureActiveDirectoryTemplate_Works(Te Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", project, buildResult)); } - protected async Task BuildAndRunTest(string appName, Project project, bool usesAuth = false) + protected async Task BuildAndRunTest(string appName, Project project, BrowserKind browserKind, bool usesAuth = false) { using var aspNetProcess = project.StartBuiltProjectAsync(); @@ -492,93 +510,93 @@ protected async Task BuildAndRunTest(string appName, Project project, bool usesA ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", project, aspNetProcess.Process)); await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - if (BrowserFixture.IsHostAutomationSupported()) + if (Fixture.BrowserManager.IsAvailable(browserKind)) { - aspNetProcess.VisitInBrowser(Browser); - TestBasicNavigation(appName, usesAuth); + await using var browser = await Fixture.BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + var page = await browser.NewPageAsync(); + await aspNetProcess.VisitInBrowserAsync(page); + await TestBasicNavigation(appName, page, usesAuth); + await page.CloseAsync(); } else { - BrowserFixture.EnforceSupportedConfigurations(); + EnsureBrowserAvailable(browserKind); } } - private void TestBasicNavigation(string appName, bool usesAuth = false, bool skipFetchData = false) + private async Task TestBasicNavigation(string appName, IPage page, bool usesAuth = false, bool skipFetchData = false) { - // Start fresh always - if (usesAuth) - { - Browser.ExecuteJavaScript("sessionStorage.clear()"); - Browser.ExecuteJavaScript("localStorage.clear()"); - Browser.Manage().Cookies.DeleteAllCookies(); - Browser.Navigate().Refresh(); - } - - // Give components.server enough time to load so that it can replace - // the prerendered content before we start making assertions. - Thread.Sleep(5000); - Browser.Exists(By.TagName("ul")); + await page.WaitForSelectorAsync("ul"); // <title> element gets project ID injected into it during template execution - Browser.Equal(appName.Trim(), () => Browser.Title.Trim()); + Assert.Equal(appName.Trim(), (await page.GetTitleAsync()).Trim()); // Initially displays the home page - Browser.Equal("Hello, world!", () => Browser.FindElement(By.TagName("h1")).Text); + await page.WaitForSelectorAsync("h1 >> text=Hello, world!"); // Can navigate to the counter page - Browser.FindElement(By.PartialLinkText("Counter")).Click(); - Browser.Contains("counter", () => Browser.Url); - Browser.Equal("Counter", () => Browser.FindElement(By.TagName("h1")).Text); + await Task.WhenAll( + page.WaitForNavigationAsync("**/counter"), + page.WaitForSelectorAsync("h1 >> text=Counter"), + page.WaitForSelectorAsync("p >> text=Current count: 0"), + page.ClickAsync("a[href=counter]")); // Clicking the counter button works - Browser.Equal("Current count: 0", () => Browser.FindElement(By.CssSelector("h1 + p")).Text); - Browser.FindElement(By.CssSelector("p+button")).Click(); - Browser.Equal("Current count: 1", () => Browser.FindElement(By.CssSelector("h1 + p")).Text); + await Task.WhenAll( + page.WaitForSelectorAsync("p >> text=Current count: 1"), + page.ClickAsync("p+button >> text=Click me")); if (usesAuth) { - Browser.FindElement(By.PartialLinkText("Log in")).Click(); - Browser.Contains("/Identity/Account/Login", () => Browser.Url); + await Task.WhenAll( + page.WaitForNavigationAsync("**/Identity/Account/Login**", LifecycleEvent.Networkidle), + page.ClickAsync("text=Log in")); - Browser.FindElement(By.PartialLinkText("Register as a new user")).Click(); + await Task.WhenAll( + page.WaitForSelectorAsync("[name=\"Input.Email\"]"), + page.WaitForNavigationAsync("**/Identity/Account/Register**", LifecycleEvent.Networkidle), + page.ClickAsync("text=Register as a new user")); var userName = $"{Guid.NewGuid()}@example.com"; var password = $"!Test.Password1$"; - Browser.Exists(By.Name("Input.Email")); - Browser.FindElement(By.Name("Input.Email")).SendKeys(userName); - Browser.FindElement(By.Name("Input.Password")).SendKeys(password); - Browser.FindElement(By.Name("Input.ConfirmPassword")).SendKeys(password); - Browser.FindElement(By.Id("registerSubmit")).Click(); + + await page.TypeAsync("[name=\"Input.Email\"]", userName); + await page.TypeAsync("[name=\"Input.Password\"]", password); + await page.TypeAsync("[name=\"Input.ConfirmPassword\"]", password); // We will be redirected to the RegisterConfirmation - Browser.Contains("/Identity/Account/RegisterConfirmation", () => Browser.Url); - Browser.FindElement(By.PartialLinkText("Click here to confirm your account")).Click(); + await Task.WhenAll( + page.WaitForNavigationAsync("**/Identity/Account/RegisterConfirmation**", LifecycleEvent.Networkidle), + page.ClickAsync("#registerSubmit")); // We will be redirected to the ConfirmEmail - Browser.Contains("/Identity/Account/ConfirmEmail", () => Browser.Url); + await Task.WhenAll( + page.WaitForNavigationAsync("**/Identity/Account/ConfirmEmail**", LifecycleEvent.Networkidle), + page.ClickAsync("text=Click here to confirm your account")); // Now we can login - Browser.FindElement(By.PartialLinkText("Login")).Click(); - Browser.Exists(By.Name("Input.Email")); - Browser.FindElement(By.Name("Input.Email")).SendKeys(userName); - Browser.FindElement(By.Name("Input.Password")).SendKeys(password); - Browser.FindElement(By.Id("login-submit")).Click(); + await page.ClickAsync("text=Login"); + await page.WaitForSelectorAsync("[name=\"Input.Email\"]"); + await page.TypeAsync("[name=\"Input.Email\"]", userName); + await page.TypeAsync("[name=\"Input.Password\"]", password); + await page.ClickAsync("#login-submit"); // Need to navigate to fetch page - Browser.Navigate().GoToUrl(new Uri(Browser.Url).GetLeftPart(UriPartial.Authority)); - Browser.Equal(appName.Trim(), () => Browser.Title.Trim()); + await page.GoToAsync(new Uri(page.Url).GetLeftPart(UriPartial.Authority)); + Assert.Equal(appName.Trim(), (await page.GetTitleAsync()).Trim()); } if (!skipFetchData) { // Can navigate to the 'fetch data' page - Browser.FindElement(By.PartialLinkText("Fetch data")).Click(); - Browser.Contains("fetchdata", () => Browser.Url); - Browser.Equal("Weather forecast", () => Browser.FindElement(By.TagName("h1")).Text); + await Task.WhenAll( + page.WaitForNavigationAsync("**/fetchdata"), + page.WaitForSelectorAsync("h1 >> text=Weather forecast"), + page.ClickAsync("text=Fetch data")); // Asynchronously loads and displays the table of weather forecasts - Browser.Exists(By.CssSelector("table>tbody>tr")); - Browser.Equal(5, () => Browser.FindElements(By.CssSelector("p+table>tbody>tr")).Count); + await page.WaitForSelectorAsync("table>tbody>tr"); + Assert.Equal(5, (await page.QuerySelectorAllAsync("p+table>tbody>tr")).Count()); } } diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/PlaywrightFixture.cs b/src/ProjectTemplates/BlazorTemplates.Tests/PlaywrightFixture.cs new file mode 100644 index 000000000000..f5ad94c908d3 --- /dev/null +++ b/src/ProjectTemplates/BlazorTemplates.Tests/PlaywrightFixture.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Microsoft.AspNetCore.BrowserTesting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Testing; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace ProjectTemplates.Tests.Infrastructure +{ + public class PlaywrightFixture<TTestAssemblyType> : IAsyncLifetime + { + private static readonly bool _isCIEnvironment = + !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ContinuousIntegrationBuild")); + + private readonly IMessageSink _diagnosticsMessageSink; + private readonly IConfiguration _configuration; + + public PlaywrightFixture(IMessageSink diagnosticsMessageSink) + { + _diagnosticsMessageSink = diagnosticsMessageSink; + + _configuration = CreateConfiguration(typeof(TTestAssemblyType).Assembly); + } + + private IConfiguration CreateConfiguration(Assembly assembly) + { + var basePath = Path.GetDirectoryName(assembly.Location); + var os = Environment.OSVersion.Platform switch + { + PlatformID.Win32NT => "win", + PlatformID.Unix => "linux", + PlatformID.MacOSX => "osx", + _ => null + }; + + var builder = new ConfigurationBuilder() + .AddJsonFile(Path.Combine(basePath, "playwrightSettings.json")) + .AddJsonFile(Path.Combine(basePath, $"playwrightSettings.{os}.json"), optional: true); + + if (_isCIEnvironment) + { + builder.AddJsonFile(Path.Combine(basePath, "playwrightSettings.ci.json"), optional: true) + .AddJsonFile(Path.Combine(basePath, $"playwrightSettings.ci.{os}.json"), optional: true); + } + + if (Debugger.IsAttached) + { + builder.AddJsonFile(Path.Combine(basePath, "playwrightSettings.debug.json"), optional: true); + } + + return builder.Build(); + } + + public async Task InitializeAsync() + { + var sink = new TestSink(); + sink.MessageLogged += LogBrowserManagerMessage; + var factory = new TestLoggerFactory(sink, enabled: true); + BrowserManager = await BrowserManager.CreateAsync(_configuration, factory); + } + + private void LogBrowserManagerMessage(WriteContext context) + { + _diagnosticsMessageSink.OnMessage(new DiagnosticMessage(context.Message)); + } + + public async Task DisposeAsync() + { + await BrowserManager.DisposeAsync(); + } + + public BrowserManager BrowserManager { get; set; } + } +} diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/e2eTestSettings.ci.json b/src/ProjectTemplates/BlazorTemplates.Tests/e2eTestSettings.ci.json deleted file mode 100644 index 325431af3582..000000000000 --- a/src/ProjectTemplates/BlazorTemplates.Tests/e2eTestSettings.ci.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - // We give each Selenium test assertion up to two minutes to fail before any other test in the - // build has failed - "DefaultWaitTimeoutInSeconds": 120, - // This value is balanced between completing the build fast enough upon failure and giving - // each E2E test a fair chance to pass even in the event that a separate test has failed already. - "DefaultAfterFailureWaitTimeoutInSeconds": 120 -} diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/e2eTestSettings.json b/src/ProjectTemplates/BlazorTemplates.Tests/e2eTestSettings.json deleted file mode 100644 index 809f33f04653..000000000000 --- a/src/ProjectTemplates/BlazorTemplates.Tests/e2eTestSettings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "DefaultWaitTimeoutInSeconds": 20, - "ScreenShotsPath": "../../screenshots" -} diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.ci.json b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.ci.json new file mode 100644 index 000000000000..146abcf62692 --- /dev/null +++ b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.ci.json @@ -0,0 +1,15 @@ +{ + "TimeoutInMilliseconds": 120000, + "TimeoutAfterFirstFailureInMilliseconds": 20000, + "GlobalBrowserOptions": { + "Headless": true + }, + "BrowserOptions": { + "Firefox": { + "IsEnabled": false + }, + "Webkit": { + "IsEnabled": false + } + } +} diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.ci.linux.json b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.ci.linux.json new file mode 100644 index 000000000000..e416652ffe39 --- /dev/null +++ b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.ci.linux.json @@ -0,0 +1,8 @@ +{ + "IsDisabled": true, + "BrowserOptions": { + "Chromium": { + "IsEnabled": false + } + } +} diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.ci.osx.json b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.ci.osx.json new file mode 100644 index 000000000000..137e7e958118 --- /dev/null +++ b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.ci.osx.json @@ -0,0 +1,7 @@ +{ + "BrowserOptions": { + "Chromium": { + "IsEnabled": false + } + } +} diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.ci.win.json b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.ci.win.json new file mode 100644 index 000000000000..2c63c0851048 --- /dev/null +++ b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.ci.win.json @@ -0,0 +1,2 @@ +{ +} diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.debug.json b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.debug.json new file mode 100644 index 000000000000..6899a11ae8f8 --- /dev/null +++ b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.debug.json @@ -0,0 +1,7 @@ +{ + "TimeoutInMilliseconds": 120000000, + "TimeoutAfterFirstFailureInMilliseconds": 20000000, + "GlobalBrowserOptions": { + "Headless": false + } +} diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.json b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.json new file mode 100644 index 000000000000..d5e34b4795f4 --- /dev/null +++ b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.json @@ -0,0 +1,38 @@ +{ + "TimeoutInMilliseconds": 30000, + "TimeoutAfterFirstFailureInMilliseconds": 10000, + "BaseArtifactsFolder": ".", + "GlobalBrowserOptions": { + "ChromiumSandbox": true, + "DumpIO": true, + "IgnoreHTTPSErrors": true, + "Headless": true, + "Timeout": 30000 + }, + "GlobalContextOptions": { + "RecordVideo": { + "Dir": "videos" + }, + "RecordHar": { + "Path": "har" + }, + "IgnoreHTTPSErrors": true + }, + "BrowserOptions": { + "Chromium": { + "BrowserKind": "Chromium", + "IsEnabled": true, + "Args": { + "--ignore-certificate-errors": true + } + }, + "Firefox": { + "BrowserKind": "Firefox", + "IsEnabled": true + }, + "Webkit": { + "BrowserKind": "Webkit", + "IsEnabled": true + } + } +} diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.linux.json b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.linux.json new file mode 100644 index 000000000000..2c63c0851048 --- /dev/null +++ b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.linux.json @@ -0,0 +1,2 @@ +{ +} diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.osx.json b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.osx.json new file mode 100644 index 000000000000..2c63c0851048 --- /dev/null +++ b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.osx.json @@ -0,0 +1,2 @@ +{ +} diff --git a/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.win.json b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.win.json new file mode 100644 index 000000000000..2c63c0851048 --- /dev/null +++ b/src/ProjectTemplates/BlazorTemplates.Tests/playwrightSettings.win.json @@ -0,0 +1,2 @@ +{ +} diff --git a/src/ProjectTemplates/ProjectTemplates.slnf b/src/ProjectTemplates/ProjectTemplates.slnf index 177f58f2eda5..e2b314c8fdf7 100644 --- a/src/ProjectTemplates/ProjectTemplates.slnf +++ b/src/ProjectTemplates/ProjectTemplates.slnf @@ -2,6 +2,7 @@ "solution": { "path": "..\\..\\AspNetCore.sln", "projects" : [ + "src\\ProjectTemplates\\BlazorTemplates.Tests\\BlazorTemplates.Tests.csproj", "src\\ProjectTemplates\\Web.ProjectTemplates\\Microsoft.DotNet.Web.ProjectTemplates.csproj", "src\\ProjectTemplates\\Web.Spa.ProjectTemplates\\Microsoft.DotNet.Web.Spa.ProjectTemplates.csproj", "src\\ProjectTemplates\\Web.Client.ItemTemplates\\Microsoft.DotNet.Web.Client.ItemTemplates.csproj", @@ -46,6 +47,7 @@ "src\\Security\\Authorization\\Core\\src\\Microsoft.AspNetCore.Authorization.csproj", "src\\Security\\Authorization\\Policy\\src\\Microsoft.AspNetCore.Authorization.Policy.csproj", "src\\Security\\CookiePolicy\\src\\Microsoft.AspNetCore.CookiePolicy.csproj", + "src\\Shared\\BrowserTesting\\src\\Microsoft.AspNetCore.BrowserTesting.csproj", "src\\Middleware\\CORS\\src\\Microsoft.AspNetCore.Cors.csproj", "src\\Middleware\\Diagnostics.Abstractions\\src\\Microsoft.AspNetCore.Diagnostics.Abstractions.csproj", "src\\Middleware\\Diagnostics\\src\\Microsoft.AspNetCore.Diagnostics.csproj", diff --git a/src/ProjectTemplates/ProjectTemplatesNoDeps.slnf b/src/ProjectTemplates/ProjectTemplatesNoDeps.slnf index b054340fb6c3..27ecd76d4103 100644 --- a/src/ProjectTemplates/ProjectTemplatesNoDeps.slnf +++ b/src/ProjectTemplates/ProjectTemplatesNoDeps.slnf @@ -2,13 +2,13 @@ "solution": { "path": "..\\..\\AspNetCore.sln", "projects": [ - "src\\ProjectTemplates\\ComponentsWebAssembly.ProjectTemplates\\Microsoft.AspNetCore.Components.WebAssembly.Templates.csproj", + "src\\ProjectTemplates\\BlazorTemplates.Tests\\BlazorTemplates.Tests.csproj", "src\\ProjectTemplates\\Web.Client.ItemTemplates\\Microsoft.DotNet.Web.Client.ItemTemplates.csproj", "src\\ProjectTemplates\\Web.ItemTemplates\\Microsoft.DotNet.Web.ItemTemplates.csproj", "src\\ProjectTemplates\\Web.ProjectTemplates\\Microsoft.DotNet.Web.ProjectTemplates.csproj", "src\\ProjectTemplates\\Web.Spa.ProjectTemplates\\Microsoft.DotNet.Web.Spa.ProjectTemplates.csproj", "src\\ProjectTemplates\\test\\ProjectTemplates.Tests.csproj", - "src\\ProjectTemplates\\BlazorTemplates.Tests\\BlazorTemplates.Tests.csproj" + "src\\Shared\\BrowserTesting\\src\\Microsoft.AspNetCore.BrowserTesting.csproj" ] } } \ No newline at end of file diff --git a/src/ProjectTemplates/Shared/AspNetProcess.cs b/src/ProjectTemplates/Shared/AspNetProcess.cs index 11da0c3fcc81..5187af7fff19 100644 --- a/src/ProjectTemplates/Shared/AspNetProcess.cs +++ b/src/ProjectTemplates/Shared/AspNetProcess.cs @@ -17,8 +17,7 @@ using Microsoft.Extensions.CommandLineUtils; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using OpenQA.Selenium; -using OpenQA.Selenium.Edge; +using PlaywrightSharp; using Xunit; using Xunit.Abstractions; @@ -103,35 +102,13 @@ public AspNetProcess( } } - public void VisitInBrowser(IWebDriver driver) + public async Task VisitInBrowserAsync(IPage page) { _output.WriteLine($"Opening browser at {ListeningUri}..."); - driver.Navigate().GoToUrl(ListeningUri); - - if (driver is EdgeDriver) - { - // Workaround for untrusted ASP.NET Core development certificates. - // The edge driver doesn't supported skipping the SSL warning page. - - if (driver.Title.Contains("Certificate error", StringComparison.OrdinalIgnoreCase)) - { - _output.WriteLine("Page contains certificate error. Attempting to get around this..."); - driver.FindElement(By.Id("moreInformationDropdownSpan")).Click(); - var continueLink = driver.FindElement(By.Id("invalidcert_continue")); - if (continueLink != null) - { - _output.WriteLine($"Clicking on link '{continueLink.Text}' to skip invalid certificate error page."); - continueLink.Click(); - driver.Navigate().GoToUrl(ListeningUri); - } - else - { - _output.WriteLine("Could not find link to skip certificate error page."); - } - } - } + await page.GoToAsync(ListeningUri.AbsoluteUri); } + public async Task AssertPagesOk(IEnumerable<Page> pages) { foreach (var page in pages) diff --git a/src/ProjectTemplates/Shared/ProjectFactoryFixture.cs b/src/ProjectTemplates/Shared/ProjectFactoryFixture.cs index 46763a3b96b8..2f25714fd64b 100644 --- a/src/ProjectTemplates/Shared/ProjectFactoryFixture.cs +++ b/src/ProjectTemplates/Shared/ProjectFactoryFixture.cs @@ -8,7 +8,6 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; -using Microsoft.AspNetCore.E2ETesting; using Xunit.Abstractions; namespace Templates.Test.Helpers @@ -24,13 +23,6 @@ public ProjectFactoryFixture(IMessageSink diagnosticsMessageSink) DiagnosticsMessageSink = diagnosticsMessageSink; } - static ProjectFactoryFixture() - { - // There is no good place to put this, so this is the best one. - // This sets the defualt timeout for all the Selenium test assertions. - WaitAssert.DefaultTimeout = TimeSpan.FromSeconds(30); - } - public async Task<Project> GetOrCreateProject(string projectKey, ITestOutputHelper output) { await TemplatePackageInstaller.EnsureTemplatingEngineInitializedAsync(output); diff --git a/src/ProjectTemplates/test/AssemblyInfo.AssemblyFixtures.cs b/src/ProjectTemplates/test/AssemblyInfo.AssemblyFixtures.cs index dded12fce8e1..b4a3b7fcf6cd 100644 --- a/src/ProjectTemplates/test/AssemblyInfo.AssemblyFixtures.cs +++ b/src/ProjectTemplates/test/AssemblyInfo.AssemblyFixtures.cs @@ -1,9 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using Microsoft.AspNetCore.E2ETesting; using Microsoft.AspNetCore.Testing; using Templates.Test.Helpers; [assembly: AssemblyFixture(typeof(ProjectFactoryFixture))] -[assembly: AssemblyFixture(typeof(SeleniumStandaloneServer))] diff --git a/src/ProjectTemplates/test/ProjectTemplates.Tests.csproj b/src/ProjectTemplates/test/ProjectTemplates.Tests.csproj index 0a83e99dcfab..132f2464d9b6 100644 --- a/src/ProjectTemplates/test/ProjectTemplates.Tests.csproj +++ b/src/ProjectTemplates/test/ProjectTemplates.Tests.csproj @@ -21,6 +21,11 @@ <TestDependsOnAspNetRuntime>true</TestDependsOnAspNetRuntime> </PropertyGroup> + <ItemGroup> + <Compile Include="$(SharedSourceRoot)CommandLineUtils\**\*.cs" /> + <Compile Include="$(SharedSourceRoot)CertificateGeneration\**\*.cs" LinkBase="shared\CertificateGeneration" /> + </ItemGroup> + <ItemGroup> <EmbeddedResource Include="template-baselines.json" /> <Compile Include="$(SharedSourceRoot)Process\*.cs" LinkBase="shared\Process" /> @@ -40,11 +45,13 @@ </ItemGroup> <ItemGroup> + <Reference Include="AngleSharp" /> <ProjectReference Include="$(RepoRoot)src\Framework\App.Runtime\src\Microsoft.AspNetCore.App.Runtime.csproj"> <ReferenceOutputAssembly>false</ReferenceOutputAssembly> <SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties> </ProjectReference> <ProjectReference Include="$(RepoRoot)src\Hosting\Server.IntegrationTesting\src\Microsoft.AspNetCore.Server.IntegrationTesting.csproj" /> + <ProjectReference Include="$(RepoRoot)src\Shared\BrowserTesting\src\Microsoft.AspNetCore.BrowserTesting.csproj" /> <ProjectReference Include="../testassets/DotNetToolsInstaller/DotNetToolsInstaller.csproj" ReferenceOutputAssembly="false" /> <ProjectReference Include="../Web.Client.ItemTemplates/Microsoft.DotNet.Web.Client.ItemTemplates.csproj" ReferenceOutputAssembly="false" /> <ProjectReference Include="../Web.ItemTemplates/Microsoft.DotNet.Web.ItemTemplates.csproj" ReferenceOutputAssembly="false" /> @@ -57,7 +64,6 @@ <PreserveExistingLogsInOutput Condition="'$(PreserveExistingLogsInOutput)' == ''">false</PreserveExistingLogsInOutput> </PropertyGroup> - <!-- Shared testing infrastructure for running E2E tests using selenium --> - <Import Project="$(SharedSourceRoot)E2ETesting\E2ETesting.targets" /> + <!-- Shared testing infrastructure for running E2E tests --> <Import Project="..\TestInfrastructure\PrepareForTest.targets" /> </Project> diff --git a/src/ProjectTemplates/xunit.runner.json b/src/ProjectTemplates/xunit.runner.json index dfb6dacb887c..9faab404ec09 100644 --- a/src/ProjectTemplates/xunit.runner.json +++ b/src/ProjectTemplates/xunit.runner.json @@ -1,5 +1,5 @@ { "longRunningTestSeconds": 60, "diagnosticMessages": true, - "maxParallelThreads": -1 + "maxParallelThreads": 1 } diff --git a/src/Shared/BrowserTesting/src/BrowserKind.cs b/src/Shared/BrowserTesting/src/BrowserKind.cs new file mode 100644 index 000000000000..917a30322c87 --- /dev/null +++ b/src/Shared/BrowserTesting/src/BrowserKind.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.BrowserTesting +{ + [Flags] + public enum BrowserKind + { + Chromium = 1, + Firefox = 2, + Webkit = 4 + } +} diff --git a/src/Shared/BrowserTesting/src/BrowserManager.cs b/src/Shared/BrowserTesting/src/BrowserManager.cs new file mode 100644 index 000000000000..ede459195318 --- /dev/null +++ b/src/Shared/BrowserTesting/src/BrowserManager.cs @@ -0,0 +1,177 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using PlaywrightSharp; + +namespace Microsoft.AspNetCore.BrowserTesting +{ + public class BrowserManager + { + private readonly BrowserManagerConfiguration _browserManagerConfiguration; + private readonly Dictionary<string, IBrowser> _launchBrowsers = new(StringComparer.Ordinal); + + private object _lock = new(); + private Task _initializeTask; + private bool _disposed; + private readonly ILoggerFactory _loggerFactory; + + private BrowserManager(IConfiguration configuration, ILoggerFactory loggerFactory) + { + _browserManagerConfiguration = new BrowserManagerConfiguration(configuration); + _loggerFactory = loggerFactory; + } + + public IPlaywright Playwright { get; private set; } + + public bool HasFailedTests { get; set; } + + public static async Task<BrowserManager> CreateAsync(IConfiguration configuration, ILoggerFactory loggerFactory) + { + var manager = new BrowserManager(configuration, loggerFactory); + await manager.InitializeAsync(); + + return manager; + } + + private async Task InitializeAsync() + { + await LazyInitializer.EnsureInitialized(ref _initializeTask, ref _lock, InitializeCore); + + async Task InitializeCore() + { + Playwright = await PlaywrightSharp.Playwright.CreateAsync(_loggerFactory, debug: "pw:api"); + foreach (var (browserName, options) in _browserManagerConfiguration.BrowserOptions) + { + if (!_launchBrowsers.ContainsKey(browserName)) + { + var effectiveLaunchOptions = _browserManagerConfiguration.GetLaunchOptions(options.BrowserLaunchOptions); + + var browser = options.BrowserKind switch + { + BrowserKind.Chromium => await Playwright.Chromium.LaunchAsync(effectiveLaunchOptions), + BrowserKind.Firefox => await Playwright.Firefox.LaunchAsync(effectiveLaunchOptions), + BrowserKind.Webkit => await Playwright.Webkit.LaunchAsync(effectiveLaunchOptions), + _ => throw new InvalidOperationException("Unsupported browser type.") + }; + + _launchBrowsers.Add(browserName, browser); + } + } + } + } + + public IEnumerable<string> GetAvailableBrowsers() => _launchBrowsers.Keys; + + public Task<IBrowserContext> GetBrowserInstance(BrowserKind browserInstance, ContextInformation contextInfo) => + GetBrowserInstance(browserInstance.ToString(), contextInfo); + + public Task<IBrowserContext> GetBrowserInstance(string browserInstance, ContextInformation contextInfo) + { + if (!_launchBrowsers.TryGetValue(browserInstance, out var browser)) + { + throw new InvalidOperationException("Invalid browser instance."); + } + + return AttachContextInfo( + browser.NewContextAsync(contextInfo.ConfigureUniqueHarPath(_browserManagerConfiguration.GetContextOptions(browserInstance))), + contextInfo); + } + + public Task<IBrowserContext> GetBrowserInstance(BrowserKind browserInstance, string contextName, ContextInformation contextInfo) => + GetBrowserInstance(browserInstance.ToString(), contextName, contextInfo); + + public Task<IBrowserContext> GetBrowserInstance(string browserInstance, string contextName, ContextInformation contextInfo) + { + if (_launchBrowsers.TryGetValue(browserInstance, out var browser)) + { + throw new InvalidOperationException("Invalid browser instance."); + } + + return AttachContextInfo( + browser.NewContextAsync(contextInfo.ConfigureUniqueHarPath(_browserManagerConfiguration.GetContextOptions(browserInstance, contextName))), + contextInfo); + } + + public Task<IBrowserContext> GetBrowserInstance(BrowserKind browserInstance, string contextName, BrowserContextOptions options, ContextInformation contextInfo) => + GetBrowserInstance(browserInstance.ToString(), contextName, options, contextInfo); + + public Task<IBrowserContext> GetBrowserInstance(string browserInstance, string contextName, BrowserContextOptions options, ContextInformation contextInfo) + { + if (_launchBrowsers.TryGetValue(browserInstance, out var browser)) + { + throw new InvalidOperationException("Invalid browser instance."); + } + + return AttachContextInfo( + browser.NewContextAsync(contextInfo.ConfigureUniqueHarPath(_browserManagerConfiguration.GetContextOptions(browserInstance, contextName, options))), + contextInfo); + } + + private async Task<IBrowserContext> AttachContextInfo(Task<IBrowserContext> browserContextTask, ContextInformation contextInfo) + { + var context = await browserContextTask; + context.DefaultTimeout = HasFailedTests ? + _browserManagerConfiguration.TimeoutAfterFirstFailureInMilliseconds: + _browserManagerConfiguration.TimeoutInMilliseconds; + + contextInfo.Attach(context); + return context; + } + + public async Task DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + foreach (var (_, browser) in _launchBrowsers) + { + await browser.DisposeAsync(); + } + Playwright.Dispose(); + } + + public bool IsAvailable(BrowserKind browserKind) => + _launchBrowsers.ContainsKey(browserKind.ToString()); + + public bool IsExplicitlyDisabled(BrowserKind browserKind) => + _browserManagerConfiguration.IsDisabled || _browserManagerConfiguration.DisabledBrowsers.Contains(browserKind.ToString()); + + public static IEnumerable<object []> WithBrowsers<T>(IEnumerable<BrowserKind> browsers, IEnumerable<T []> data) + { + var result = new List<object[]>(); + foreach (var browser in browsers) + { + foreach (var item in data) + { + result.Add(item.Cast<object>().Prepend(browser).ToArray()); + } + } + + return result; + } + + public static IEnumerable<object[]> WithBrowsers(IEnumerable<BrowserKind> browsers, params object [] data) + { + var result = new List<object[]>(); + foreach (var browser in browsers) + { + foreach (var item in data) + { + result.Add(new[] { browser, item }); + } + } + + return result; + } + } +} diff --git a/src/Shared/BrowserTesting/src/BrowserManagerConfiguration.cs b/src/Shared/BrowserTesting/src/BrowserManagerConfiguration.cs new file mode 100644 index 000000000000..8a892ac64452 --- /dev/null +++ b/src/Shared/BrowserTesting/src/BrowserManagerConfiguration.cs @@ -0,0 +1,370 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Extensions.Configuration; +using PlaywrightSharp; + +namespace Microsoft.AspNetCore.BrowserTesting +{ + public class BrowserManagerConfiguration + { + public BrowserManagerConfiguration(IConfiguration configuration) + { + Load(configuration); + } + + public int TimeoutInMilliseconds { get; private set; } + + public int TimeoutAfterFirstFailureInMilliseconds { get; private set; } + + public string BaseArtifactsFolder { get; private set; } + + public bool IsDisabled { get; private set; } + + public LaunchOptions GlobalBrowserOptions { get; private set; } + + public BrowserContextOptions GlobalContextOptions { get; private set; } + + public IDictionary<string, BrowserOptions> BrowserOptions { get; } = + new Dictionary<string, BrowserOptions>(StringComparer.OrdinalIgnoreCase); + + public ISet<string> DisabledBrowsers { get; } = + new HashSet<string>(StringComparer.OrdinalIgnoreCase); + + public IDictionary<string, BrowserContextOptions> ContextOptions { get; private set; } = + new Dictionary<string, BrowserContextOptions>(StringComparer.OrdinalIgnoreCase); + + public LaunchOptions GetLaunchOptions(LaunchOptions browserLaunchOptions) + { + if (browserLaunchOptions == null) + { + return GlobalBrowserOptions; + } + else + { + return Combine(GlobalBrowserOptions, browserLaunchOptions); + } + } + + public BrowserContextOptions GetContextOptions(string browser) + { + if (!BrowserOptions.TryGetValue(browser, out var browserOptions)) + { + throw new InvalidOperationException($"Browser '{browser}' is not configured."); + } + else if (browserOptions.DefaultContextOptions == null) + { + // Cheap clone + return Combine(GlobalContextOptions, null); + } + { + return Combine(GlobalContextOptions, browserOptions.DefaultContextOptions); + } + } + + public BrowserContextOptions GetContextOptions(string browser, string contextName) => + Combine(GetContextOptions(browser.ToString()), ContextOptions.TryGetValue(contextName, out var context) ? context : throw new InvalidOperationException("Invalid context name")); + + public BrowserContextOptions GetContextOptions(string browser, string contextName, BrowserContextOptions options) => + Combine(GetContextOptions(browser, contextName), options); + + private void Load(IConfiguration configuration) + { + TimeoutInMilliseconds = configuration.GetValue(nameof(TimeoutInMilliseconds), 30000); + TimeoutAfterFirstFailureInMilliseconds = configuration.GetValue(nameof(TimeoutAfterFirstFailureInMilliseconds), 10000); + IsDisabled = configuration.GetValue(nameof(IsDisabled), false); + BaseArtifactsFolder = Path.GetFullPath(configuration.GetValue(nameof(BaseArtifactsFolder), Path.Combine(Directory.GetCurrentDirectory(), "playwright"))); + Directory.CreateDirectory(BaseArtifactsFolder); + + var defaultBrowserOptions = configuration.GetSection(nameof(GlobalBrowserOptions)); + if (defaultBrowserOptions.Exists()) + { + GlobalBrowserOptions = LoadBrowserLaunchOptions(defaultBrowserOptions); + } + + var defaultContextOptions = configuration.GetSection(nameof(GlobalContextOptions)); + if (defaultContextOptions.Exists()) + { + GlobalContextOptions = LoadContextOptions(configuration.GetSection(nameof(GlobalContextOptions))); + } + + var browsersOptions = configuration.GetSection(nameof(BrowserOptions)); + if (!browsersOptions.Exists()) + { + throw new InvalidOperationException("Browsers not configured."); + } + + foreach (var browser in browsersOptions.GetChildren()) + { + var browserName = browser.Key; + var isEnabled = browser.GetValue<bool>("IsEnabled"); + var browserKind = browser.GetValue<BrowserKind>("BrowserKind"); + if (!isEnabled) + { + DisabledBrowsers.Add(browserName); + continue; + } + + var defaultContextOptionsSection = browser.GetSection("DefaultContextOptions"); + + var browserOptions = new BrowserOptions( + browserKind, + LoadBrowserLaunchOptions(browser), + defaultContextOptionsSection.Exists() ? LoadContextOptions(defaultContextOptionsSection) : null); + + BrowserOptions.Add(browserName, browserOptions); + } + + var contextOptions = configuration.GetSection("ContextOptions"); + foreach (var option in contextOptions.GetChildren()) + { + ContextOptions.Add(option.Key, LoadContextOptions(option)); + } + } + + private BrowserContextOptions LoadContextOptions(IConfiguration configuration) => EnsureFoldersExist(new BrowserContextOptions + { + Proxy = BindValue<ProxySettings>(configuration, nameof(BrowserContextOptions.Proxy)), + RecordVideo = BindValue<RecordVideoOptions>(configuration, nameof(BrowserContextOptions.RecordVideo)), + RecordHar = BindValue<RecordHarOptions>(configuration, nameof(BrowserContextOptions.RecordHar)), + ExtraHTTPHeaders = BindMultiValueMap( + configuration.GetSection(nameof(BrowserContextOptions.ExtraHTTPHeaders)), + argsMap => argsMap.ToDictionary(kvp => kvp.Key, kvp => string.Join(", ", kvp.Value))), + Locale = configuration.GetValue<string>(nameof(BrowserContextOptions.Locale)), + ColorScheme = configuration.GetValue<ColorScheme?>(nameof(BrowserContextOptions.ColorScheme)), + AcceptDownloads = configuration.GetValue<bool?>(nameof(BrowserContextOptions.AcceptDownloads)), + HasTouch = configuration.GetValue<bool?>(nameof(BrowserContextOptions.HasTouch)), + HttpCredentials = configuration.GetValue<Credentials>(nameof(BrowserContextOptions.HttpCredentials)), + DeviceScaleFactor = configuration.GetValue<decimal?>(nameof(BrowserContextOptions.DeviceScaleFactor)), + Offline = configuration.GetValue<bool?>(nameof(BrowserContextOptions.Offline)), + IsMobile = configuration.GetValue<bool?>(nameof(BrowserContextOptions.IsMobile)), + + // TODO: Map this properly + Permissions = configuration.GetValue<ContextPermission[]>(nameof(BrowserContextOptions.Permissions)), + + Geolocation = BindValue<Geolocation>(configuration, nameof(BrowserContextOptions.Geolocation)), + TimezoneId = configuration.GetValue<string>(nameof(BrowserContextOptions.TimezoneId)), + IgnoreHTTPSErrors = configuration.GetValue<bool?>(nameof(BrowserContextOptions.IgnoreHTTPSErrors)), + JavaScriptEnabled = configuration.GetValue<bool?>(nameof(BrowserContextOptions.JavaScriptEnabled)), + BypassCSP = configuration.GetValue<bool?>(nameof(BrowserContextOptions.BypassCSP)), + UserAgent = configuration.GetValue<string>(nameof(BrowserContextOptions.UserAgent)), + Viewport = BindValue<ViewportSize>(configuration, nameof(BrowserContextOptions.Viewport)), + StorageStatePath = configuration.GetValue<string>(nameof(BrowserContextOptions.StorageStatePath)), + + // TODO: Map this properly + StorageState = BindValue<StorageState>(configuration, nameof(BrowserContextOptions.StorageState)) + }); + + private static T BindValue<T>(IConfiguration configuration, string key) where T : new() + { + var instance = new T(); + var section = configuration.GetSection(key); + configuration.Bind(key, instance); + return section.Exists() ? instance : default; + } + + private BrowserContextOptions EnsureFoldersExist(BrowserContextOptions browserContextOptions) + { + if (browserContextOptions?.RecordVideo?.Dir != null) + { + browserContextOptions.RecordVideo.Dir = EnsureFolderExists(browserContextOptions.RecordVideo.Dir); + } + + if (browserContextOptions?.RecordHar?.Path != null) + { + browserContextOptions.RecordHar.Path = EnsureFolderExists(browserContextOptions.RecordHar.Path); + } + + return browserContextOptions; + + string EnsureFolderExists(string folderPath) + { + if (Path.IsPathRooted(folderPath)) + { + Directory.CreateDirectory(folderPath); + return folderPath; + } + else + { + folderPath = Path.Combine(BaseArtifactsFolder, folderPath); + Directory.CreateDirectory(folderPath); + return folderPath; + } + } + } + + private LaunchOptions LoadBrowserLaunchOptions(IConfiguration configuration) => new LaunchOptions + { + IgnoreDefaultArgs = BindArgumentMap(configuration.GetSection(nameof(LaunchOptions.IgnoreAllDefaultArgs))), + ChromiumSandbox = configuration.GetValue<bool?>(nameof(LaunchOptions.ChromiumSandbox)), + HandleSIGHUP = configuration.GetValue<bool?>(nameof(LaunchOptions.HandleSIGHUP)), + HandleSIGTERM = configuration.GetValue<bool?>(nameof(LaunchOptions.HandleSIGTERM)), + HandleSIGINT = configuration.GetValue<bool?>(nameof(LaunchOptions.HandleSIGINT)), + IgnoreAllDefaultArgs = configuration.GetValue<bool?>(nameof(LaunchOptions.IgnoreAllDefaultArgs)), + SlowMo = configuration.GetValue<int?>(nameof(LaunchOptions.SlowMo)), + Env = configuration.GetValue<Dictionary<string, string>>(nameof(LaunchOptions.Env)), + DumpIO = configuration.GetValue<bool?>(nameof(LaunchOptions.DumpIO)), + IgnoreHTTPSErrors = configuration.GetValue<bool?>(nameof(LaunchOptions.IgnoreHTTPSErrors)), + DownloadsPath = configuration.GetValue<string>(nameof(LaunchOptions.DownloadsPath)), + ExecutablePath = configuration.GetValue<string>(nameof(LaunchOptions.ExecutablePath)), + Devtools = configuration.GetValue<bool?>(nameof(LaunchOptions.Devtools)), + UserDataDir = configuration.GetValue<string>(nameof(LaunchOptions.UserDataDir)), + Args = BindMultiValueMap( + configuration.GetSection(nameof(LaunchOptions.Args)), + argsMap => argsMap.SelectMany(argNameValue => argNameValue.Value.Prepend(argNameValue.Key)).ToArray()), + Headless = configuration.GetValue<bool?>(nameof(LaunchOptions.Headless)), + Timeout = configuration.GetValue<int?>(nameof(LaunchOptions.Timeout)), + Proxy = configuration.GetValue<ProxySettings>(nameof(LaunchOptions.Proxy)) + }; + + private T BindMultiValueMap<T>(IConfigurationSection processArgsMap, Func<Dictionary<string, HashSet<string>>, T> mapper) + { + // TODO: We need a way to pass in arguments that allows overriding values through our config system. + // "Args": { + // // switch argument + // "arg": true, + // // single value argument + // "arg2": "value", + // // remove single value argument + // "arg3": null, + // // multi-value argument + // "arg4": { + // "value": true, + // "otherValue": "false" + // } + if (!processArgsMap.Exists()) + { + return mapper(new Dictionary<string, HashSet<string>>()); + } + var argsMap = new Dictionary<string, HashSet<string>>(); + foreach (var arg in processArgsMap.GetChildren()) + { + var argName = arg.Key; + // Its a single value being removed + if (arg.Value == null) + { + argsMap.Remove(argName); + } + else if (arg.GetChildren().Count() > 1) + { + // Its an object mapping multiple values in the form "--arg value1 value2 value3" + var argValues = InitializeMapValue(argsMap, argName); + + foreach (var (value, enabled) in arg.Get<Dictionary<string, bool>>()) + { + if (enabled) + { + argValues.Add(value); + } + else + { + argValues.Remove(value); + } + } + } + else if (!bool.TryParse(arg.Value, out var switchValue)) + { + // Its a single value + var argValue = InitializeMapValue(argsMap, argName); + argValue.Clear(); + argValue.Add(arg.Value); + } + else + { + // Its a switch value + if (switchValue) + { + _ = InitializeMapValue(argsMap, argName); + } + else + { + argsMap.Remove(argName); + } + } + } + + return mapper(argsMap); + + static HashSet<string> InitializeMapValue(Dictionary<string, HashSet<string>> argsMap, string argName) + { + if (!argsMap.TryGetValue(argName, out var argValue)) + { + argValue = new HashSet<string>(); + argsMap[argName] = argValue; + } + + return argValue; + } + } + + private string[] BindArgumentMap(IConfigurationSection configuration) => configuration.Exists() switch + { + false => Array.Empty<string>(), + true => configuration.Get<Dictionary<string, bool>>().Where(kvp => kvp.Value == true).Select(kvp => kvp.Key).ToArray() + }; + + private static BrowserContextOptions Combine(BrowserContextOptions defaultOptions, BrowserContextOptions overrideOptions) => + new() + { + Proxy = overrideOptions?.Proxy != default ? overrideOptions.Proxy : defaultOptions.Proxy, + RecordVideo = overrideOptions?.RecordVideo != default ? + new() { Dir = overrideOptions.RecordVideo.Dir, Size = overrideOptions.RecordVideo.Size?.Clone() } : + defaultOptions != default ? + new() { Dir = defaultOptions.RecordVideo.Dir, Size = defaultOptions.RecordVideo.Size?.Clone() } : + default, + RecordHar = overrideOptions?.RecordHar != default ? + new() { Path = overrideOptions.RecordHar.Path, OmitContent = overrideOptions.RecordHar.OmitContent } : + defaultOptions?.RecordHar != default ? + new() { Path = defaultOptions.RecordHar.Path, OmitContent = defaultOptions.RecordHar.OmitContent } : + default, + ExtraHTTPHeaders = overrideOptions?.ExtraHTTPHeaders != default ? overrideOptions.ExtraHTTPHeaders : defaultOptions.ExtraHTTPHeaders, + Locale = overrideOptions?.Locale != default ? overrideOptions.Locale : defaultOptions.Locale, + ColorScheme = overrideOptions?.ColorScheme != default ? overrideOptions.ColorScheme : defaultOptions.ColorScheme, + AcceptDownloads = overrideOptions?.AcceptDownloads != default ? overrideOptions.AcceptDownloads : defaultOptions.AcceptDownloads, + HasTouch = overrideOptions?.HasTouch != default ? overrideOptions.HasTouch : defaultOptions.HasTouch, + HttpCredentials = overrideOptions?.HttpCredentials != default ? overrideOptions.HttpCredentials : defaultOptions.HttpCredentials, + DeviceScaleFactor = overrideOptions?.DeviceScaleFactor != default ? overrideOptions.DeviceScaleFactor : defaultOptions.DeviceScaleFactor, + Offline = overrideOptions?.Offline != default ? overrideOptions.Offline : defaultOptions.Offline, + IsMobile = overrideOptions?.IsMobile != default ? overrideOptions.IsMobile : defaultOptions.IsMobile, + Permissions = overrideOptions?.Permissions != default ? overrideOptions.Permissions : defaultOptions.Permissions, + Geolocation = overrideOptions?.Geolocation != default ? overrideOptions.Geolocation : defaultOptions.Geolocation, + TimezoneId = overrideOptions?.TimezoneId != default ? overrideOptions.TimezoneId : defaultOptions.TimezoneId, + IgnoreHTTPSErrors = overrideOptions?.IgnoreHTTPSErrors != default ? overrideOptions.IgnoreHTTPSErrors : defaultOptions.IgnoreHTTPSErrors, + JavaScriptEnabled = overrideOptions?.JavaScriptEnabled != default ? overrideOptions.JavaScriptEnabled : defaultOptions.JavaScriptEnabled, + BypassCSP = overrideOptions?.BypassCSP != default ? overrideOptions.BypassCSP : defaultOptions.BypassCSP, + UserAgent = overrideOptions?.UserAgent != default ? overrideOptions.UserAgent : defaultOptions.UserAgent, + Viewport = overrideOptions?.Viewport != default ? overrideOptions.Viewport : defaultOptions.Viewport, + StorageStatePath = overrideOptions?.StorageStatePath != default ? overrideOptions.StorageStatePath : defaultOptions.StorageStatePath, + StorageState = overrideOptions?.StorageState != default ? overrideOptions.StorageState : defaultOptions.StorageState + }; + + private LaunchOptions Combine(LaunchOptions defaultOptions, LaunchOptions overrideOptions) => + new() + { + IgnoreDefaultArgs = overrideOptions.IgnoreDefaultArgs != default ? overrideOptions.IgnoreDefaultArgs : defaultOptions.IgnoreDefaultArgs, + ChromiumSandbox = overrideOptions.ChromiumSandbox != default ? overrideOptions.ChromiumSandbox : defaultOptions.ChromiumSandbox, + HandleSIGHUP = overrideOptions.HandleSIGHUP != default ? overrideOptions.HandleSIGHUP : defaultOptions.HandleSIGHUP, + HandleSIGTERM = overrideOptions.HandleSIGTERM != default ? overrideOptions.HandleSIGTERM : defaultOptions.HandleSIGTERM, + HandleSIGINT = overrideOptions.HandleSIGINT != default ? overrideOptions.HandleSIGINT : defaultOptions.HandleSIGINT, + IgnoreAllDefaultArgs = overrideOptions.IgnoreAllDefaultArgs != default ? overrideOptions.IgnoreAllDefaultArgs : defaultOptions.IgnoreAllDefaultArgs, + SlowMo = overrideOptions.SlowMo != default ? overrideOptions.SlowMo : defaultOptions.SlowMo, + Env = overrideOptions.Env != default ? overrideOptions.Env : defaultOptions.Env, + DumpIO = overrideOptions.DumpIO != default ? overrideOptions.DumpIO : defaultOptions.DumpIO, + IgnoreHTTPSErrors = overrideOptions.IgnoreHTTPSErrors != default ? overrideOptions.IgnoreHTTPSErrors : defaultOptions.IgnoreHTTPSErrors, + DownloadsPath = overrideOptions.DownloadsPath != default ? overrideOptions.DownloadsPath : defaultOptions.DownloadsPath, + ExecutablePath = overrideOptions.ExecutablePath != default ? overrideOptions.ExecutablePath : defaultOptions.ExecutablePath, + Devtools = overrideOptions.Devtools != default ? overrideOptions.Devtools : defaultOptions.Devtools, + UserDataDir = overrideOptions.UserDataDir != default ? overrideOptions.UserDataDir : defaultOptions.UserDataDir, + Args = overrideOptions.Args != default ? overrideOptions.Args : defaultOptions.Args, + Headless = overrideOptions.Headless != default ? overrideOptions.Headless : defaultOptions.Headless, + Timeout = overrideOptions.Timeout != default ? overrideOptions.Timeout : defaultOptions.Timeout, + Proxy = overrideOptions.Proxy != default ? overrideOptions.Proxy : defaultOptions.Proxy + }; + } + + public record BrowserOptions(BrowserKind BrowserKind, LaunchOptions BrowserLaunchOptions, BrowserContextOptions DefaultContextOptions); +} diff --git a/src/Shared/BrowserTesting/src/ContextInformation.cs b/src/Shared/BrowserTesting/src/ContextInformation.cs new file mode 100644 index 000000000000..07cadc4bf0f5 --- /dev/null +++ b/src/Shared/BrowserTesting/src/ContextInformation.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Extensions.Logging; +using PlaywrightSharp; + +namespace Microsoft.AspNetCore.BrowserTesting +{ + public class ContextInformation + { + private readonly ILoggerFactory _factory; + private string _harPath; + + public ContextInformation(ILoggerFactory factory) + { + _factory = factory; + } + + public IDictionary<IPage, PageInformation> Pages { get; } = new Dictionary<IPage, PageInformation>(); + + internal void Attach(IBrowserContext context) + { + context.Page += AttachToPage; + } + + private void AttachToPage(object sender, PageEventArgs args) + { + var logger = _factory.CreateLogger<PageInformation>(); + if (_harPath != null) + { + logger.LogInformation($"Network trace will be saved at '{_harPath}'"); + } + + var pageInfo = new PageInformation(args.Page, logger); + Pages.Add(args.Page, pageInfo); + args.Page.Close += CleanupPage; + args.Page.Crash += CleanupPage; + } + + private void CleanupPage(object sender, EventArgs e) + { + var page = (IPage)sender; + if (Pages.TryGetValue(page, out var info)) + { + info.Dispose(); + Pages.Remove(page); + } + } + + internal BrowserContextOptions ConfigureUniqueHarPath(BrowserContextOptions browserContextOptions) + { + if (browserContextOptions?.RecordHar?.Path != null) + { + var identifier = Guid.NewGuid().ToString("N"); + browserContextOptions.RecordHar.Path = Path.Combine(browserContextOptions.RecordHar.Path, $"{identifier}.har"); + _harPath = browserContextOptions.RecordHar.Path; + } + + return browserContextOptions; + } + } +} diff --git a/src/Shared/BrowserTesting/src/Directory.Build.props b/src/Shared/BrowserTesting/src/Directory.Build.props new file mode 100644 index 000000000000..7a3a58cbc2b2 --- /dev/null +++ b/src/Shared/BrowserTesting/src/Directory.Build.props @@ -0,0 +1,8 @@ +<Project> + <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory)..\, Directory.Build.props))\Directory.Build.props" /> + + <PropertyGroup> + <IdentityTestSharedSourceRoot>$(MSBuildThisFileDirectory)test\Shared\</IdentityTestSharedSourceRoot> + </PropertyGroup> + +</Project> diff --git a/src/Shared/BrowserTesting/src/Microsoft.AspNetCore.BrowserTesting.csproj b/src/Shared/BrowserTesting/src/Microsoft.AspNetCore.BrowserTesting.csproj new file mode 100644 index 000000000000..77d1222a503b --- /dev/null +++ b/src/Shared/BrowserTesting/src/Microsoft.AspNetCore.BrowserTesting.csproj @@ -0,0 +1,15 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework> + <PackageTags>aspnetcore;testing</PackageTags> + <IsShippingPackage>false</IsShippingPackage> + <AddPublicApiAnalyzers>false</AddPublicApiAnalyzers> + </PropertyGroup> + + <ItemGroup> + <Reference Include="PlaywrightSharp" Condition="'$(TargetOsName)' != 'linux-musl'" /> + <Reference Include="PlaywrightSharp" ExcludeAssets="build" Condition="'$(TargetOsName)' == 'linux-musl'" /> + </ItemGroup> + +</Project> diff --git a/src/Shared/BrowserTesting/src/PageInformation.cs b/src/Shared/BrowserTesting/src/PageInformation.cs new file mode 100644 index 000000000000..fac5ba6e2ed3 --- /dev/null +++ b/src/Shared/BrowserTesting/src/PageInformation.cs @@ -0,0 +1,124 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using PlaywrightSharp; + +namespace Microsoft.AspNetCore.BrowserTesting +{ + public class PageInformation : IDisposable + { + private readonly Page _page; + private readonly ILogger<PageInformation> _logger; + + public List<string> FailedRequests { get; } = new(); + + public List<LogEntry> BrowserConsoleLogs { get; } = new(); + + public List<string> PageErrors { get; } = new(); + + public List<IWebSocket> WebSockets { get; set; } = new(); + + public PageInformation(Page page, ILogger<PageInformation> logger) + { + page.Console += RecordConsoleMessage; + page.PageError += RecordPageError; + page.RequestFailed += RecordFailedRequest; + page.WebSocket += CaptureWebSocket; + _page = page; + _logger = logger; + + _ = LogPageVideoPath(); + } + + private void CaptureWebSocket(object sender, WebSocketEventArgs e) + { + WebSockets.Add(e.WebSocket); + } + + private async Task LogPageVideoPath() + { + try + { + var path = _page.Video != null ? await _page.Video.GetPathAsync() : null; + if (path != null) + { + _logger.LogInformation($"Page video recorded at: {path}"); + } + } + catch + { + // Silently swallow since we don't have a good way to report it and its not critical. + throw; + } + } + + public void Dispose() + { + _page.Console -= RecordConsoleMessage; + _page.PageError -= RecordPageError; + _page.RequestFailed -= RecordFailedRequest; + } + + private void RecordFailedRequest(object sender, RequestFailedEventArgs e) + { + try + { + _logger.LogError(e.FailureText); + } + catch + { + } + FailedRequests.Add(e.FailureText); + } + + private void RecordPageError(object sender, PageErrorEventArgs e) + { + // There needs to be a bit of experimentation with this, but message should be a good start. + try + { + _logger.LogError(e.Message); + } + catch + { + } + + PageErrors.Add(e.Message); + } + + private void RecordConsoleMessage(object sender, ConsoleEventArgs e) + { + var message = e.Message; + var messageText = message.Text.Replace(Environment.NewLine, $"{Environment.NewLine} "); + var location = message.Location; + + var logMessage = $"[{_page.Url}]{Environment.NewLine} {messageText}{Environment.NewLine} ({location.URL}:{location.LineNumber}:{location.ColumnNumber})"; + + try + { + _logger.Log(MapLogLevel(message.Type), logMessage); + } + catch + { + + throw; + } + + BrowserConsoleLogs.Add(new LogEntry(messageText, message.Type)); + + LogLevel MapLogLevel(string messageType) => messageType switch + { + "info" => LogLevel.Information, + "verbose" => LogLevel.Debug, + "warning" => LogLevel.Warning, + "error" => LogLevel.Error, + _ => LogLevel.Information + }; + } + + public record LogEntry(string Message, string Level); + } +} diff --git a/src/Shared/E2ETesting/E2ETesting.props b/src/Shared/E2ETesting/E2ETesting.props index f7da557009e7..61035d93b118 100644 --- a/src/Shared/E2ETesting/E2ETesting.props +++ b/src/Shared/E2ETesting/E2ETesting.props @@ -27,18 +27,12 @@ <Compile Include="$(SharedSourceRoot)E2ETesting\**\*.cs" LinkBase="Selenium" /> </ItemGroup> - <ItemGroup> - <Compile Include="$(SharedSourceRoot)CommandLineUtils\**\*.cs" /> - <Compile Include="$(SharedSourceRoot)CertificateGeneration\**\*.cs" LinkBase="shared\CertificateGeneration" /> - </ItemGroup> - <ItemGroup> <None Include="$(SharedSourceRoot)E2ETesting\E2ETesting.props" Link="Selenium\E2ETesting.props" /> <None Include="$(SharedSourceRoot)E2ETesting\E2ETesting.targets" Link="Selenium\E2ETesting.targets" /> </ItemGroup> <ItemGroup> - <Reference Include="AngleSharp" /> <Reference Include="Selenium.Support" /> <Reference Include="Selenium.WebDriver" /> <Reference Include="Microsoft.Extensions.Configuration" /> @@ -46,10 +40,6 @@ <Reference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" /> </ItemGroup> - <ItemGroup> - <ProjectReference Include="$(RepoRoot)src\Hosting\Server.IntegrationTesting\src\Microsoft.AspNetCore.Server.IntegrationTesting.csproj" /> - </ItemGroup> - <ItemGroup> <AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute"> <_Parameter1>Microsoft.AspNetCore.E2ETesting.CI</_Parameter1> diff --git a/src/Shared/Shared.slnf b/src/Shared/Shared.slnf index 889465e2182c..22f958684e47 100644 --- a/src/Shared/Shared.slnf +++ b/src/Shared/Shared.slnf @@ -1,9 +1,9 @@ -{ +{ "solution": { "path": "..\\..\\AspNetCore.sln", - "projects" : [ - "src\\Shared\\test\\Shared.Tests\\Microsoft.AspNetCore.Shared.Tests.csproj", - "src\\Shared\\test\\testassets\\ThrowingLibrary\\ThrowingLibrary.csproj" + "projects": [ + "src\\Shared\\BrowserTesting\\src\\Microsoft.AspNetCore.BrowserTesting.csproj", + "src\\Shared\\test\\Shared.Tests\\Microsoft.AspNetCore.Shared.Tests.csproj" ] } -} +} \ No newline at end of file diff --git a/src/Testing/src/xunit/AspNetTestAssemblyRunner.cs b/src/Testing/src/xunit/AspNetTestAssemblyRunner.cs index 48fbbbfa3bed..9af1e9f94326 100644 --- a/src/Testing/src/xunit/AspNetTestAssemblyRunner.cs +++ b/src/Testing/src/xunit/AspNetTestAssemblyRunner.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Xunit; using Xunit.Abstractions; using Xunit.Sdk; @@ -30,7 +31,7 @@ protected override async Task AfterTestAssemblyStartingAsync() await base.AfterTestAssemblyStartingAsync(); // Find all the AssemblyFixtureAttributes on the test assembly - Aggregator.Run(() => + await Aggregator.RunAsync(async () => { var fixturesAttributes = ((IReflectionAssemblyInfo)TestAssembly.Assembly) .Assembly @@ -42,19 +43,27 @@ protected override async Task AfterTestAssemblyStartingAsync() foreach (var fixtureAttribute in fixturesAttributes) { var ctorWithDiagnostics = fixtureAttribute.FixtureType.GetConstructor(new[] { typeof(IMessageSink) }); + object instance = null; if (ctorWithDiagnostics != null) { - _assemblyFixtureMappings[fixtureAttribute.FixtureType] = Activator.CreateInstance(fixtureAttribute.FixtureType, DiagnosticMessageSink); + instance = Activator.CreateInstance(fixtureAttribute.FixtureType, DiagnosticMessageSink); } else { - _assemblyFixtureMappings[fixtureAttribute.FixtureType] = Activator.CreateInstance(fixtureAttribute.FixtureType); + instance = Activator.CreateInstance(fixtureAttribute.FixtureType); + } + + _assemblyFixtureMappings[fixtureAttribute.FixtureType] = instance; + + if (instance is IAsyncLifetime asyncInit) + { + await asyncInit.InitializeAsync(); } } }); } - protected override Task BeforeTestAssemblyFinishedAsync() + protected override async Task BeforeTestAssemblyFinishedAsync() { // Dispose fixtures foreach (var disposable in _assemblyFixtureMappings.Values.OfType<IDisposable>()) @@ -62,7 +71,12 @@ protected override Task BeforeTestAssemblyFinishedAsync() Aggregator.Run(disposable.Dispose); } - return base.BeforeTestAssemblyFinishedAsync(); + foreach (var disposable in _assemblyFixtureMappings.Values.OfType<IAsyncLifetime>()) + { + await Aggregator.RunAsync(disposable.DisposeAsync); + } + + await base.BeforeTestAssemblyFinishedAsync(); } protected override Task<RunSummary> RunTestCollectionAsync(