diff --git a/eng/Npm.Workspace.nodeproj b/eng/Npm.Workspace.nodeproj index 04e2ae3da87d..81c004313ac1 100644 --- a/eng/Npm.Workspace.nodeproj +++ b/eng/Npm.Workspace.nodeproj @@ -44,7 +44,6 @@ @@ -65,7 +64,9 @@ - + <_NpmGeneratedPackages Include="$(PackageOutputPath)/*.tgz" /> diff --git a/eng/tools/GenerateFiles/Directory.Build.targets.in b/eng/tools/GenerateFiles/Directory.Build.targets.in index a3e5d4742794..a056fb9edcfa 100644 --- a/eng/tools/GenerateFiles/Directory.Build.targets.in +++ b/eng/tools/GenerateFiles/Directory.Build.targets.in @@ -122,8 +122,9 @@ - false - false + + false + false false diff --git a/src/ProjectTemplates/Shared/Project.cs b/src/ProjectTemplates/Shared/Project.cs index 3b1c0588c96a..ed00a32f7f8e 100644 --- a/src/ProjectTemplates/Shared/Project.cs +++ b/src/ProjectTemplates/Shared/Project.cs @@ -66,13 +66,19 @@ internal async Task RunDotNetNewAsync( bool useLocalDB = false, bool noHttps = false, bool errorOnRestoreError = true, + bool isItemTemplate = false, string[] args = null, // Used to set special options in MSBuild IDictionary environmentVariables = null) { - var hiveArg = $" --debug:disable-sdk-templates --debug:custom-hive \"{TemplatePackageInstaller.CustomHivePath}\""; + var hiveArg = $"--debug:disable-sdk-templates --debug:custom-hive \"{TemplatePackageInstaller.CustomHivePath}\""; var argString = $"new {templateName} {hiveArg}"; environmentVariables ??= new Dictionary(); + if (!isItemTemplate) + { + argString += " --no-restore"; + } + if (!string.IsNullOrEmpty(auth)) { argString += $" --auth {auth}"; @@ -113,18 +119,30 @@ internal async Task RunDotNetNewAsync( Directory.Delete(TemplateOutputDir, recursive: true); } - using var execution = ProcessEx.Run(Output, AppContext.BaseDirectory, DotNetMuxer.MuxerPathOrDefault(), argString, environmentVariables); - await execution.Exited; + using var createExecution = ProcessEx.Run(Output, AppContext.BaseDirectory, DotNetMuxer.MuxerPathOrDefault(), argString, environmentVariables); + await createExecution.Exited; - var result = new ProcessResult(execution); + var createResult = new ProcessResult(createExecution); + Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create", this, createResult)); - // Because dotnet new automatically restores but silently ignores restore errors, need to handle restore errors explicitly - if (errorOnRestoreError && (execution.Output.Contains("Restore failed.") || execution.Error.Contains("Restore failed."))) + if (!isItemTemplate) { - result.ExitCode = -1; - } + argString = "restore /bl"; + using var restoreExecution = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), argString, environmentVariables); + await restoreExecution.Exited; - Assert.True(0 == result.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", this, result)); + var restoreResult = new ProcessResult(restoreExecution); + + // Because dotnet new automatically restores but silently ignores restore errors, need to handle restore errors explicitly + if (errorOnRestoreError && (restoreExecution.Output.Contains("Restore failed.") || restoreExecution.Error.Contains("Restore failed."))) + { + restoreResult.ExitCode = -1; + } + + CaptureBinLogOnFailure(restoreExecution); + + Assert.True(0 == restoreResult.ExitCode, ErrorMessages.GetFailedProcessMessage("restore", this, restoreResult)); + } } internal async Task RunDotNetPublishAsync(IDictionary packageOptions = null, string additionalArgs = null, bool noRestore = true) diff --git a/src/ProjectTemplates/TestInfrastructure/Directory.Build.props.in b/src/ProjectTemplates/TestInfrastructure/Directory.Build.props.in index 88c7d6173021..31b2ee9d6124 100644 --- a/src/ProjectTemplates/TestInfrastructure/Directory.Build.props.in +++ b/src/ProjectTemplates/TestInfrastructure/Directory.Build.props.in @@ -3,6 +3,20 @@ ${RepoRoot} ${ArtifactsBinDir} + + + ${TargetingPackLayoutRoot} + ${SharedFrameworkLayoutRoot} + + + true + true diff --git a/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets b/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets index 156b26ffb9cb..4e1a98035f07 100644 --- a/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets +++ b/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets @@ -26,6 +26,9 @@ $([MSBuild]::NormalizePath('$(OutputPath)$(TestTemplateCreationFolder)')) $(TestTemplateCreationFolder)\Hives\$([System.Guid]::NewGuid())\.templateengine + $(TestTemplateCreationFolder)dotnet\ + <_DotNetHostFileName>dotnet + <_DotNetHostFileName Condition="$([MSBuild]::IsOSPlatform(`Windows`))">dotnet.exe @@ -48,10 +51,16 @@ <_Parameter1>TestTemplateCreationFolder <_Parameter2>$(TestTemplateCreationFolder) + <_Parameter1>CustomTemplateHivePath <_Parameter2>$(CustomTemplateHivePath) + + + <_Parameter1>DotNetHostOverride + <_Parameter2>$(TemplateTestDotNetRoot)$(_DotNetHostFileName) + @@ -72,6 +81,20 @@ + + <_FilesToCopy Include="$(LocalDotNetRoot)$(_DotNetHostFileName)" /> + <_FilesToCopy Include="$(LocalDotNetRoot)host\**\*" DestinationRelativeFolder="host\" /> + <_FilesToCopy Include="$(LocalDotNetRoot)shared\**\*" DestinationRelativeFolder="shared\" /> + <_FilesToCopy Include="$(LocalDotNetRoot)sdk\**\*" DestinationRelativeFolder="sdk\" /> + <_FilesToCopy Include="$(SharedFrameworkLayoutRoot)\**\*" /> + + <_DestinationFiles Include="@(_FilesToCopy->'$(TemplateTestDotNetRoot)%(DestinationRelativeFolder)%(RecursiveDir)%(Filename)%(Extension)')" /> + + + + @@ -84,7 +107,7 @@ diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs index cfded6021e29..f9bc0a14f181 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorTemplateTest.cs @@ -1,13 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.IO; using System.Runtime.InteropServices; -using System.Threading.Tasks; using Microsoft.AspNetCore.BrowserTesting; +using Microsoft.Playwright; using Templates.Test.Helpers; -using Xunit; namespace BlazorTemplates.Tests; @@ -18,14 +15,19 @@ public abstract class BlazorTemplateTest : BrowserTestBase public BlazorTemplateTest(ProjectFactoryFixture projectFactory) { ProjectFactory = projectFactory; - Microsoft.Playwright.Program.Main(new[] { "install" }); + Microsoft.Playwright.Program.Main(["install"]); } public ProjectFactoryFixture ProjectFactory { get; set; } public abstract string ProjectType { get; } - protected async Task CreateBuildPublishAsync(string auth = null, string[] args = null, string targetFramework = null, bool serverProject = false, bool onlyCreate = false) + protected async Task CreateBuildPublishAsync( + string auth = null, + string[] args = null, + string targetFramework = null, + Func getTargetProject = null, + bool onlyCreate = false) { // Additional arguments are needed. See: https://github.com/dotnet/aspnetcore/issues/24278 Environment.SetEnvironmentVariable("EnableDefaultScopedCssItems", "true"); @@ -38,21 +40,17 @@ protected async Task CreateBuildPublishAsync(string auth = null, string await project.RunDotNetNewAsync(ProjectType, auth: auth, args: args); + project = getTargetProject?.Invoke(project) ?? project; + if (!onlyCreate) { - var targetProject = project; - if (serverProject) - { - targetProject = GetSubProject(project, "Server", $"{project.ProjectName}.Server"); - } - - await targetProject.RunDotNetPublishAsync(noRestore: false); + await project.RunDotNetPublishAsync(noRestore: false); // Run dotnet build after publish. The reason is that one uses Config = Debug and the other uses Config = Release // The output from publish will go into bin/Release/netcoreappX.Y/publish and won't be affected by calling build // later, while the opposite is not true. - await targetProject.RunDotNetBuildAsync(); + await project.RunDotNetBuildAsync(); } return project; @@ -83,6 +81,138 @@ public static bool TryValidateBrowserRequired(BrowserKind browserKind, bool isRe return isRequired; } + protected async Task TestBasicInteractionInNewPageAsync( + BrowserKind browserKind, + string listeningUri, + string appName, + BlazorTemplatePages pagesToExclude = BlazorTemplatePages.None, + bool usesAuth = false) + { + if (!BrowserManager.IsAvailable(browserKind)) + { + EnsureBrowserAvailable(browserKind); + return; + } + + await using var browser = await BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + var page = await browser.NewPageAsync(); + + Output.WriteLine($"Opening browser at {listeningUri}..."); + await page.GotoAsync(listeningUri, new() { WaitUntil = WaitUntilState.NetworkIdle }); + + await TestBasicInteractionAsync(page, appName, pagesToExclude, usesAuth); + + await page.CloseAsync(); + } + + protected async Task TestBasicInteractionAsync( + IPage page, + string appName, + BlazorTemplatePages pagesToExclude = BlazorTemplatePages.None, + bool usesAuth = false) + { + await page.WaitForSelectorAsync("nav"); + + if (!pagesToExclude.HasFlag(BlazorTemplatePages.Home)) + { + // Initially displays the home page + await page.WaitForSelectorAsync("h1 >> text=Hello, world!"); + + Assert.Equal("Home", (await page.TitleAsync()).Trim()); + } + + if (!pagesToExclude.HasFlag(BlazorTemplatePages.Counter)) + { + // Can navigate to the counter page + await Task.WhenAll( + page.WaitForNavigationAsync(new() { UrlString = "**/counter" }), + page.WaitForSelectorAsync("h1 >> text=Counter"), + page.WaitForSelectorAsync("p >> text=Current count: 0"), + page.ClickAsync("a[href=counter]")); + + // Clicking the counter button works + await IncrementCounterAsync(page); + } + + if (usesAuth) + { + await Task.WhenAll( + page.WaitForNavigationAsync(new() { UrlString = "**/Identity/Account/Login**", WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Log in")); + + await Task.WhenAll( + page.WaitForSelectorAsync("[name=\"Input.Email\"]"), + page.WaitForNavigationAsync(new() { UrlString = "**/Identity/Account/Register**", WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Register as a new user")); + + var userName = $"{Guid.NewGuid()}@example.com"; + var password = "[PLACEHOLDER]-1a"; + + 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 + await Task.WhenAll( + page.WaitForNavigationAsync(new() { UrlString = "**/Identity/Account/RegisterConfirmation**", WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("#registerSubmit")); + + // We will be redirected to the ConfirmEmail + await Task.WhenAll( + page.WaitForNavigationAsync(new() { UrlString = "**/Identity/Account/ConfirmEmail**", WaitUntil = WaitUntilState.NetworkIdle }), + page.ClickAsync("text=Click here to confirm your account")); + + // Now we can login + 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 + await page.GotoAsync(new Uri(page.Url).GetLeftPart(UriPartial.Authority)); + Assert.Equal(appName.Trim(), (await page.TitleAsync()).Trim()); + } + + if (!pagesToExclude.HasFlag(BlazorTemplatePages.Weather)) + { + await page.ClickAsync("a[href=weather]"); + await page.WaitForSelectorAsync("h1 >> text=Weather"); + + // Asynchronously loads and displays the table of weather forecasts + await page.WaitForSelectorAsync("table>tbody>tr"); + Assert.Equal(5, await page.Locator("p+table>tbody>tr").CountAsync()); + } + + static async Task IncrementCounterAsync(IPage page) + { + // Allow multiple click attempts because some interactive render modes + // won't be immediately available + const int MaxIncrementAttempts = 5; + const float IncrementTimeoutMilliseconds = 3000f; + for (var i = 0; i < MaxIncrementAttempts; i++) + { + await page.ClickAsync("p+button >> text=Click me"); + try + { + await page.WaitForSelectorAsync("p >> text=Current count: 1", new() + { + Timeout = IncrementTimeoutMilliseconds, + }); + + // The counter successfully incremented, so we're done + return; + } + catch (TimeoutException) + { + // The counter did not increment; try again + } + } + + Assert.Fail($"The counter did not increment after {MaxIncrementAttempts} attempts"); + } + } + protected void EnsureBrowserAvailable(BrowserKind browserKind) { Assert.False( @@ -92,4 +222,14 @@ protected void EnsureBrowserAvailable(BrowserKind browserKind) out var errorMessage), errorMessage); } + + [Flags] + protected enum BlazorTemplatePages + { + None = 0, + Home = 1, + Counter = 2, + Weather = 4, + All = ~0, + } } diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWasmTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWasmTemplateTest.cs index aadcabbea21b..1329beb45531 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWasmTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWasmTemplateTest.cs @@ -2,130 +2,81 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.AspNetCore.BrowserTesting; using Microsoft.AspNetCore.Internal; -using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.CommandLineUtils; using Microsoft.Playwright; using Templates.Test.Helpers; namespace BlazorTemplates.Tests; -public class BlazorWasmTemplateTest : BlazorTemplateTest +public class BlazorWasmTemplateTest(ProjectFactoryFixture projectFactory) : BlazorTemplateTest(projectFactory) { - public BlazorWasmTemplateTest(ProjectFactoryFixture projectFactory) - : base(projectFactory) { } - public override string ProjectType { get; } = "blazorwasm"; [Theory] - [QuarantinedTest("https://github.com/dotnet/aspnetcore/issues/47225")] [InlineData(BrowserKind.Chromium)] public async Task BlazorWasmStandaloneTemplate_Works(BrowserKind browserKind) { var project = await CreateBuildPublishAsync(); + var appName = project.ProjectName; // The service worker assets manifest isn't generated for non-PWA projects var publishDir = Path.Combine(project.TemplatePublishDir, "wwwroot"); Assert.False(File.Exists(Path.Combine(publishDir, "service-worker-assets.js")), "Non-PWA templates should not produce service-worker-assets.js"); - await BuildAndRunTest(project.ProjectName, project, browserKind); + // Test the built project + using (var aspNetProcess = project.StartBuiltProjectAsync()) + { + Assert.False( + aspNetProcess.Process.HasExited, + ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", project, aspNetProcess.Process)); + await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); + await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName); + } + + // Test the published project var (serveProcess, listeningUri) = RunPublishedStandaloneBlazorProject(project); using (serveProcess) { - Output.WriteLine($"Opening browser at {listeningUri}..."); - if (BrowserManager.IsAvailable(browserKind)) - { - await using var browser = await BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); - var page = await NavigateToPage(browser, listeningUri); - await TestBasicNavigation(project.ProjectName, page); - } - else - { - EnsureBrowserAvailable(browserKind); - } + await TestBasicInteractionInNewPageAsync(browserKind, listeningUri, appName); } } - private static async Task NavigateToPage(IBrowserContext browser, string listeningUri) - { - var page = await browser.NewPageAsync(); - await page.GotoAsync(listeningUri, new() { WaitUntil = WaitUntilState.NetworkIdle }); - return page; - } - - [Theory(Skip="https://github.com/dotnet/aspnetcore/issues/46430")] + [Theory] [InlineData(BrowserKind.Chromium)] - public async Task BlazorWasmHostedTemplate_Works(BrowserKind browserKind) + public async Task BlazorWasmStandalonePwaTemplate_Works(BrowserKind browserKind) { - var project = await CreateBuildPublishAsync(args: new[] { "--hosted" }, serverProject: true); - - var serverProject = GetSubProject(project, "Server", $"{project.ProjectName}.Server"); - - await BuildAndRunTest(project.ProjectName, serverProject, browserKind); - - using var aspNetProcess = serverProject.StartPublishedProjectAsync(); - - Assert.False( - aspNetProcess.Process.HasExited, - ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", serverProject, aspNetProcess.Process)); + var project = await CreateBuildPublishAsync(args: ["--pwa"]); + var appName = project.ProjectName; - await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - await AssertCompressionFormat(aspNetProcess, "br"); - - if (BrowserManager.IsAvailable(browserKind)) - { - await using var browser = await BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); - var page = await browser.NewPageAsync(); - await aspNetProcess.VisitInBrowserAsync(page); - await TestBasicNavigation(project.ProjectName, page); - } - else - { - EnsureBrowserAvailable(browserKind); - } - } - - private static async Task AssertCompressionFormat(AspNetProcess aspNetProcess, string expectedEncoding) - { - var response = await aspNetProcess.SendRequest(() => + // Test the built project + using (var aspNetProcess = project.StartBuiltProjectAsync()) { - var request = new HttpRequestMessage(HttpMethod.Get, new Uri(aspNetProcess.ListeningUri, "/_framework/blazor.boot.json")); - // These are the same as chrome - request.Headers.AcceptEncoding.Clear(); - request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("gzip")); - request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("deflate")); - request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("br")); - - return request; - }); - Assert.Equal(expectedEncoding, response.Content.Headers.ContentEncoding.Single()); - } - - [Theory(Skip = "https://github.com/dotnet/aspnetcore/issues/45736")] - [InlineData(BrowserKind.Chromium)] - public async Task BlazorWasmStandalonePwaTemplate_Works(BrowserKind browserKind) - { - var project = await CreateBuildPublishAsync(args: new[] { "--pwa" }); + Assert.False( + aspNetProcess.Process.HasExited, + ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", project, aspNetProcess.Process)); - await BuildAndRunTest(project.ProjectName, project, browserKind); + await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); + await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName); + } ValidatePublishedServiceWorker(project); + // Test the published project if (BrowserManager.IsAvailable(browserKind)) { var (serveProcess, listeningUri) = RunPublishedStandaloneBlazorProject(project); await using var browser = await BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); Output.WriteLine($"Opening browser at {listeningUri}..."); - var page = await NavigateToPage(browser, listeningUri); + var page = await browser.NewPageAsync(); + await page.GotoAsync(listeningUri, new() { WaitUntil = WaitUntilState.NetworkIdle }); using (serveProcess) { - await TestBasicNavigation(project.ProjectName, page); + await TestBasicInteractionAsync(page, project.ProjectName); } // The PWA template supports offline use. By now, the browser should have cached everything it needs, @@ -133,7 +84,7 @@ public async Task BlazorWasmStandalonePwaTemplate_Works(BrowserKind browserKind) await page.GotoAsync("about:blank"); await browser.SetOfflineAsync(true); await page.GotoAsync(listeningUri); - await TestBasicNavigation(project.ProjectName, page, skipFetchData: true); + await TestBasicInteractionAsync(page, project.ProjectName, pagesToExclude: BlazorTemplatePages.Weather); await page.CloseAsync(); } else @@ -142,52 +93,10 @@ public async Task BlazorWasmStandalonePwaTemplate_Works(BrowserKind browserKind) } } - [Theory(Skip = "https://github.com/dotnet/aspnetcore/issues/45736")] - [InlineData(BrowserKind.Chromium)] - public async Task BlazorWasmHostedPwaTemplate_Works(BrowserKind browserKind) - { - var project = await CreateBuildPublishAsync(args: new[] { "--hosted", "--pwa" }, serverProject: true); - - var serverProject = GetSubProject(project, "Server", $"{project.ProjectName}.Server"); - - await BuildAndRunTest(project.ProjectName, serverProject, browserKind); - - ValidatePublishedServiceWorker(serverProject); - - string listeningUri = null; - if (BrowserManager.IsAvailable(browserKind)) - { - await using var browser = await BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); - IPage page = null; - using (var aspNetProcess = serverProject.StartPublishedProjectAsync()) - { - Assert.False( - aspNetProcess.Process.HasExited, - ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", serverProject, aspNetProcess.Process)); - - await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - 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 - 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" - await page.GotoAsync("about:blank"); - await browser.SetOfflineAsync(true); - await page.GotoAsync(listeningUri); - await TestBasicNavigation(project.ProjectName, page, skipFetchData: true); - await page.CloseAsync(); - } - else - { - EnsureBrowserAvailable(browserKind); - } - } + [Theory] + [MemberData(nameof(TemplateData))] + public Task BlazorWasmStandaloneTemplate_AzureActiveDirectoryTemplate_Works(TemplateInstance instance) + => CreateBuildPublishAsync(args: instance.Arguments, targetFramework: "netstandard2.1"); private static void ValidatePublishedServiceWorker(Project project) { @@ -210,50 +119,19 @@ private static void ValidatePublishedServiceWorker(Project project) var serviceWorkerAssetsManifestVersionJson = serviceWorkerAssetsManifestVersionMatch.Groups[1].Captures[0].Value; var serviceWorkerAssetsManifestVersion = JsonSerializer.Deserialize(serviceWorkerAssetsManifestVersionJson); Assert.True(serviceWorkerContents.Contains($"/* Manifest version: {serviceWorkerAssetsManifestVersion} */", StringComparison.Ordinal)); + + static string ReadFile(string basePath, string path) + { + var fullPath = Path.Combine(basePath, path); + var doesExist = File.Exists(fullPath); + + Assert.True(doesExist, $"Expected file to exist, but it doesn't: {path}"); + return File.ReadAllText(Path.Combine(basePath, path)); + } } public static TheoryData TemplateData => new TheoryData { - new TemplateInstance( - "blazorwasmhostedaadb2c", "-ho", - "-au", "IndividualB2C", - "--aad-b2c-instance", "example.b2clogin.com", - "-ssp", "b2c_1_siupin", - "--client-id", "clientId", - "--domain", "my-domain", - "--default-scope", "full", - "--app-id-uri", "ApiUri", - "--api-client-id", "1234123413241324"), - new TemplateInstance( - "blazorwasmhostedaad", "-ho", - "-au", "SingleOrg", - "--domain", "my-domain", - "--tenant-id", "tenantId", - "--client-id", "clientId", - "--default-scope", "full", - "--app-id-uri", "ApiUri", - "--api-client-id", "1234123413241324"), - new TemplateInstance( - "blazorwasmhostedaadgraph", "-ho", - "-au", "SingleOrg", - "--calls-graph", - "--domain", "my-domain", - "--tenant-id", "tenantId", - "--client-id", "clientId", - "--default-scope", "full", - "--app-id-uri", "ApiUri", - "--api-client-id", "1234123413241324"), - new TemplateInstance( - "blazorwasmhostedaadapi", "-ho", - "-au", "SingleOrg", - "--called-api-url", "\"https://graph.microsoft.com\"", - "--called-api-scopes", "user.readwrite", - "--domain", "my-domain", - "--tenant-id", "tenantId", - "--client-id", "clientId", - "--default-scope", "full", - "--app-id-uri", "ApiUri", - "--api-client-id", "1234123413241324"), new TemplateInstance( "blazorwasmstandaloneaadb2c", "-au", "IndividualB2C", @@ -281,160 +159,50 @@ public TemplateInstance(string name, params string[] arguments) public string[] Arguments { get; } } - [Theory(Skip = "https://github.com/dotnet/aspnetcore/issues/37782")] - [MemberData(nameof(TemplateData))] - public Task BlazorWasmHostedTemplate_AzureActiveDirectoryTemplate_Works(TemplateInstance instance) - => CreateBuildPublishAsync(args: instance.Arguments, targetFramework: "netstandard2.1"); - - protected async Task BuildAndRunTest(string appName, Project project, BrowserKind browserKind, bool usesAuth = false) - { - using var aspNetProcess = project.StartBuiltProjectAsync(); - - Assert.False( - aspNetProcess.Process.HasExited, - ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", project, aspNetProcess.Process)); - - await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); - if (BrowserManager.IsAvailable(browserKind)) - { - await using var browser = await BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); - var page = await browser.NewPageAsync(); - await aspNetProcess.VisitInBrowserAsync(page); - await TestBasicNavigation(appName, page, usesAuth); - await page.CloseAsync(); - } - else - { - EnsureBrowserAvailable(browserKind); - } - } - - private static async Task TestBasicNavigation(string appName, IPage page, bool usesAuth = false, bool skipFetchData = false) - { - await page.WaitForSelectorAsync("nav"); - - // Initially displays the home page - await page.WaitForSelectorAsync("h1 >> text=Hello, world!"); - - Assert.Equal("Home", (await page.TitleAsync()).Trim()); - - // Can navigate to the counter page - await Task.WhenAll( - page.WaitForNavigationAsync(new() { UrlString = "**/counter" }), - page.WaitForSelectorAsync("h1 >> text=Counter"), - page.WaitForSelectorAsync("p >> text=Current count: 0"), - page.ClickAsync("a[href=counter]")); - - // Clicking the counter button works - await Task.WhenAll( - page.WaitForSelectorAsync("p >> text=Current count: 1"), - page.ClickAsync("p+button >> text=Click me")); - - if (usesAuth) - { - await Task.WhenAll( - page.WaitForNavigationAsync(new() { UrlString = "**/Identity/Account/Login**", WaitUntil = WaitUntilState.NetworkIdle }), - page.ClickAsync("text=Log in")); - - await Task.WhenAll( - page.WaitForSelectorAsync("[name=\"Input.Email\"]"), - page.WaitForNavigationAsync(new() { UrlString = "**/Identity/Account/Register**", WaitUntil = WaitUntilState.NetworkIdle }), - page.ClickAsync("text=Register as a new user")); - - var userName = $"{Guid.NewGuid()}@example.com"; - var password = "[PLACEHOLDER]-1a"; - - 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 - await Task.WhenAll( - page.WaitForNavigationAsync(new() { UrlString = "**/Identity/Account/RegisterConfirmation**", WaitUntil = WaitUntilState.NetworkIdle }), - page.ClickAsync("#registerSubmit")); - - // We will be redirected to the ConfirmEmail - await Task.WhenAll( - page.WaitForNavigationAsync(new() { UrlString = "**/Identity/Account/ConfirmEmail**", WaitUntil = WaitUntilState.NetworkIdle }), - page.ClickAsync("text=Click here to confirm your account")); - - // Now we can login - 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 - await page.GotoAsync(new Uri(page.Url).GetLeftPart(UriPartial.Authority)); - Assert.Equal(appName.Trim(), (await page.TitleAsync()).Trim()); - } - - if (!skipFetchData) - { - await page.ClickAsync("a[href=weather]"); - await page.WaitForSelectorAsync("h1 >> text=Weather"); - - // Asynchronously loads and displays the table of weather forecasts - await page.WaitForSelectorAsync("table>tbody>tr"); - Assert.Equal(5, await page.Locator("p+table>tbody>tr").CountAsync()); - } - } - - private static string ReadFile(string basePath, string path) - { - var fullPath = Path.Combine(basePath, path); - var doesExist = File.Exists(fullPath); - - Assert.True(doesExist, $"Expected file to exist, but it doesn't: {path}"); - return File.ReadAllText(Path.Combine(basePath, path)); - } - private (ProcessEx, string url) RunPublishedStandaloneBlazorProject(Project project) { var publishDir = Path.Combine(project.TemplatePublishDir, "wwwroot"); Output.WriteLine("Running dotnet serve on published output..."); - var developmentCertificate = DevelopmentCertificate.Create(project.TemplateOutputDir); - var args = $"-S --pfx \"{developmentCertificate.CertificatePath}\" --pfx-pwd \"{developmentCertificate.CertificatePassword}\" --port 0"; var command = DotNetMuxer.MuxerPathOrDefault(); + string args; if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HELIX_DIR"))) { - args = $"serve " + args; + args = $"serve "; } else { command = "dotnet-serve"; - args = "--roll-forward LatestMajor " + args; // dotnet-serve targets net5.0 by default + args = "--roll-forward LatestMajor"; // dotnet-serve targets net5.0 by default } var serveProcess = ProcessEx.Run(TestOutputHelper, publishDir, command, args); var listeningUri = ResolveListeningUrl(serveProcess); return (serveProcess, listeningUri); - } - private static string ResolveListeningUrl(ProcessEx process) - { - var buffer = new List(); - try + static string ResolveListeningUrl(ProcessEx process) { - foreach (var line in process.OutputLinesAsEnumerable) + var buffer = new List(); + try { - if (line != null) + foreach (var line in process.OutputLinesAsEnumerable) { - buffer.Add(line); - if (line.Trim().Contains("https://", StringComparison.Ordinal) || line.Trim().Contains("http://", StringComparison.Ordinal)) + if (line != null) { - return line.Trim(); + buffer.Add(line); + if (line.Trim().Contains("https://", StringComparison.Ordinal) || line.Trim().Contains("http://", StringComparison.Ordinal)) + { + return line.Trim(); + } } } } - } - catch (OperationCanceledException) - { - } + catch (OperationCanceledException) + { + } - throw new InvalidOperationException(@$"Couldn't find listening url: -{string.Join(Environment.NewLine, buffer.Append(process.Error))}"); + throw new InvalidOperationException( + $"Couldn't find listening url:\n{string.Join(Environment.NewLine, buffer.Append(process.Error))}"); + } } } diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs new file mode 100644 index 000000000000..dc2cf5b63982 --- /dev/null +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/BlazorWebTemplateTest.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.BrowserTesting; +using Microsoft.AspNetCore.InternalTesting; +using Templates.Test.Helpers; + +namespace BlazorTemplates.Tests; + +public class BlazorWebTemplateTest(ProjectFactoryFixture projectFactory) : BlazorTemplateTest(projectFactory) +{ + public override string ProjectType => "blazor"; + + [ConditionalTheory] + [SkipNonHelix] + [InlineData(BrowserKind.Chromium, "None")] + [InlineData(BrowserKind.Chromium, "Server")] + [InlineData(BrowserKind.Chromium, "WebAssembly")] + [InlineData(BrowserKind.Chromium, "Auto")] + public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string interactivityOption) + { + var project = await CreateBuildPublishAsync( + args: ["-int", interactivityOption], + getTargetProject: GetTargetProject); + + // There won't be a counter page when the 'None' interactivity option is used + var pagesToExclude = interactivityOption is "None" + ? BlazorTemplatePages.Counter + : BlazorTemplatePages.None; + + var appName = project.ProjectName; + + // Test the built project + using (var aspNetProcess = project.StartBuiltProjectAsync()) + { + Assert.False( + aspNetProcess.Process.HasExited, + ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", project, aspNetProcess.Process)); + + await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); + await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName, pagesToExclude); + } + + // Test the published project + using (var aspNetProcess = project.StartPublishedProjectAsync()) + { + Assert.False( + aspNetProcess.Process.HasExited, + ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", project, aspNetProcess.Process)); + + await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); + + if (HasClientProject()) + { + await AssertWebAssemblyCompressionFormatAsync(aspNetProcess, "br"); + } + + await TestBasicInteractionInNewPageAsync(browserKind, aspNetProcess.ListeningUri.AbsoluteUri, appName, pagesToExclude); + } + + bool HasClientProject() + => interactivityOption is "WebAssembly" or "Auto"; + + Project GetTargetProject(Project rootProject) + { + if (HasClientProject()) + { + // Multiple projects were created, so we need to specifically select the server + // project to be used + return GetSubProject(rootProject, rootProject.ProjectName, rootProject.ProjectName); + } + + // In other cases, just use the root project + return rootProject; + } + } + + private static async Task AssertWebAssemblyCompressionFormatAsync(AspNetProcess aspNetProcess, string expectedEncoding) + { + var response = await aspNetProcess.SendRequest(() => + { + var request = new HttpRequestMessage(HttpMethod.Get, new Uri(aspNetProcess.ListeningUri, "/_framework/blazor.boot.json")); + // These are the same as chrome + request.Headers.AcceptEncoding.Clear(); + request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("gzip")); + request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("deflate")); + request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("br")); + return request; + }); + Assert.Equal(expectedEncoding, response.Content.Headers.ContentEncoding.Single()); + } +} diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/Templates.Blazor.Tests.csproj b/src/ProjectTemplates/test/Templates.Blazor.Tests/Templates.Blazor.Tests.csproj index 1db85d5286fe..933f42dd9423 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/Templates.Blazor.Tests.csproj +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/Templates.Blazor.Tests.csproj @@ -54,8 +54,10 @@ - - + diff --git a/src/ProjectTemplates/test/Templates.Blazor.WebAssembly.Auth.Tests/BlazorWasmTemplateAuthTest.cs b/src/ProjectTemplates/test/Templates.Blazor.WebAssembly.Auth.Tests/BlazorWasmTemplateAuthTest.cs index 8e00c09a1e14..540c22178863 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.WebAssembly.Auth.Tests/BlazorWasmTemplateAuthTest.cs +++ b/src/ProjectTemplates/test/Templates.Blazor.WebAssembly.Auth.Tests/BlazorWasmTemplateAuthTest.cs @@ -1,19 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading.Tasks; using Microsoft.AspNetCore.InternalTesting; -using Newtonsoft.Json.Linq; using Templates.Test.Helpers; -using Xunit; -using Xunit.Abstractions; -using Xunit.Sdk; namespace Templates.Blazor.Test; @@ -95,31 +84,31 @@ public TemplateInstance(string name, string auth, params string[] arguments) [ConditionalTheory] [MemberData(nameof(TemplateDataIndividualB2C))] - public Task BlazorWasmHostedTemplate_AzureActiveDirectoryTemplate_IndividualB2C_Works(TemplateInstance instance) + public Task BlazorWasmStandaloneTemplate_AzureActiveDirectoryTemplate_IndividualB2C_Works(TemplateInstance instance) => CreateBuildPublishAsync(auth: instance.Auth, args: instance.Arguments, targetFramework: "netstandard2.1"); [ConditionalTheory] [MemberData(nameof(TemplateDataIndividualB2C))] - public Task BlazorWasmHostedTemplate_AzureActiveDirectoryTemplate_IndividualB2C_NoHttps_Works(TemplateInstance instance) + public Task BlazorWasmStandaloneTemplate_AzureActiveDirectoryTemplate_IndividualB2C_NoHttps_Works(TemplateInstance instance) => CreateBuildPublishAsync(auth: instance.Auth, args: instance.Arguments.Union(new[] { ArgConstants.NoHttps }).ToArray(), targetFramework: "netstandard2.1"); [ConditionalTheory] [MemberData(nameof(TemplateDataSingleOrg))] - public Task BlazorWasmHostedTemplate_AzureActiveDirectoryTemplate_SingleOrg_Works(TemplateInstance instance) + public Task BlazorWasmStandaloneTemplate_AzureActiveDirectoryTemplate_SingleOrg_Works(TemplateInstance instance) => CreateBuildPublishAsync(auth: instance.Auth, args: instance.Arguments, targetFramework: "netstandard2.1"); [ConditionalTheory] [MemberData(nameof(TemplateDataSingleOrg))] - public Task BlazorWasmHostedTemplate_AzureActiveDirectoryTemplate_SingleOrg_NoHttps_Works(TemplateInstance instance) + public Task BlazorWasmStandaloneTemplate_AzureActiveDirectoryTemplate_SingleOrg_NoHttps_Works(TemplateInstance instance) => CreateBuildPublishAsync(auth: instance.Auth, args: instance.Arguments.Union(new[] { ArgConstants.NoHttps }).ToArray(), targetFramework: "netstandard2.1"); [ConditionalTheory] [MemberData(nameof(TemplateDataSingleOrgProgramMain))] - public Task BlazorWasmHostedTemplate_AzureActiveDirectoryTemplate_SingleOrg_ProgramMain_Works(TemplateInstance instance) + public Task BlazorWasmStandaloneTemplate_AzureActiveDirectoryTemplate_SingleOrg_ProgramMain_Works(TemplateInstance instance) => CreateBuildPublishAsync(auth: instance.Auth, args: instance.Arguments, targetFramework: "netstandard2.1"); [ConditionalTheory] [MemberData(nameof(TemplateDataSingleOrgProgramMain))] - public Task BlazorWasmHostedTemplate_AzureActiveDirectoryTemplate_SingleOrg_NoHttps_ProgramMain_Works(TemplateInstance instance) + public Task BlazorWasmStandaloneTemplate_AzureActiveDirectoryTemplate_SingleOrg_NoHttps_ProgramMain_Works(TemplateInstance instance) => CreateBuildPublishAsync(auth: instance.Auth, args: instance.Arguments.Union(new[] { ArgConstants.NoHttps }).ToArray(), targetFramework: "netstandard2.1"); } diff --git a/src/ProjectTemplates/test/Templates.Tests/ItemTemplateTests/BlazorServerTests.cs b/src/ProjectTemplates/test/Templates.Tests/ItemTemplateTests/BlazorServerTests.cs index f520e6cb600a..3035cd16d5d6 100644 --- a/src/ProjectTemplates/test/Templates.Tests/ItemTemplateTests/BlazorServerTests.cs +++ b/src/ProjectTemplates/test/Templates.Tests/ItemTemplateTests/BlazorServerTests.cs @@ -27,7 +27,7 @@ public async Task BlazorServerItemTemplate() { Project = await ProjectFactory.CreateProject(Output); - await Project.RunDotNetNewAsync("razorcomponent --name Different"); + await Project.RunDotNetNewAsync("razorcomponent --name Different", isItemTemplate: true); Project.AssertFileExists("Different.razor", shouldExist: true); Assert.Contains("

Different

", Project.ReadFile("Different.razor")); diff --git a/src/Shared/CommandLineUtils/Utilities/DotNetMuxer.cs b/src/Shared/CommandLineUtils/Utilities/DotNetMuxer.cs index d9f6b8c1f665..b65817648e96 100644 --- a/src/Shared/CommandLineUtils/Utilities/DotNetMuxer.cs +++ b/src/Shared/CommandLineUtils/Utilities/DotNetMuxer.cs @@ -9,6 +9,8 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; +using System.Reflection; using System.Runtime.InteropServices; namespace Microsoft.Extensions.CommandLineUtils; @@ -22,7 +24,7 @@ internal static class DotNetMuxer static DotNetMuxer() { - MuxerPath = TryFindMuxerPath(Process.GetCurrentProcess().MainModule?.FileName); + MuxerPath = TryFindMuxerPath(); } /// @@ -38,18 +40,45 @@ static DotNetMuxer() public static string MuxerPathOrDefault() => MuxerPath ?? MuxerName; - internal static string? TryFindMuxerPath(string? mainModule) + private static string? TryFindMuxerPath() { - var fileName = MuxerName; + // If not running on Helix, use a custom .NET host, if specified. + // This allows test projects to use a .NET host with the custom-built + // ASP.NET Core shared framework. + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("helix"))) + { + var dotNetHostOverride = typeof(DotNetMuxer).Assembly.GetCustomAttributes() + .SingleOrDefault(a => a.Key == "DotNetHostOverride")?.Value; + if (dotNetHostOverride is not null) + { + return dotNetHostOverride; + } + } + + var expectedFileName = MuxerName; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - fileName += ".exe"; + expectedFileName += ".exe"; + } + + // If the currently running process is dotnet(.exe), return that path + var mainModuleFullPath = Process.GetCurrentProcess().MainModule?.FileName; + var mainModuleFileName = Path.GetFileName(mainModuleFullPath); + if (string.Equals(expectedFileName, mainModuleFileName, StringComparison.OrdinalIgnoreCase)) + { + return mainModuleFullPath; } - if (!string.IsNullOrEmpty(mainModule) - && string.Equals(Path.GetFileName(mainModule!), fileName, StringComparison.OrdinalIgnoreCase)) + // The currently running process may not be dotnet(.exe). For example, + // it might be "testhost(.exe)" when running tests. + // In this case, we can get the location where the CLR is installed, + // and find dotnet(.exe) relative to that path. + var runtimeDirectory = RuntimeEnvironment.GetRuntimeDirectory(); + var candidateDotNetExePath = Path.Combine(runtimeDirectory, "..", "..", "..", expectedFileName); + if (File.Exists(candidateDotNetExePath)) { - return mainModule; + var normalizedPath = Path.GetFullPath(candidateDotNetExePath); + return normalizedPath; } return null; diff --git a/src/Shared/test/Shared.Tests/DotNetMuxerTests.cs b/src/Shared/test/Shared.Tests/DotNetMuxerTests.cs index 193f9582ac12..27b223f6e46f 100644 --- a/src/Shared/test/Shared.Tests/DotNetMuxerTests.cs +++ b/src/Shared/test/Shared.Tests/DotNetMuxerTests.cs @@ -14,28 +14,11 @@ public class DotNetMuxerTests [Fact] public void FindsTheMuxer() { - - var muxerPath = DotNetMuxer.TryFindMuxerPath(GetDotnetPath()); + var muxerPath = DotNetMuxer.MuxerPath; Assert.NotNull(muxerPath); Assert.True(File.Exists(muxerPath), "The file did not exist"); Assert.True(Path.IsPathRooted(muxerPath), "The path should be rooted"); Assert.Equal("dotnet", Path.GetFileNameWithoutExtension(muxerPath), ignoreCase: true); - - static string GetDotnetPath() - { - // Process.MainModule is app[.exe] and not `dotnet`. We can instead calculate the dotnet SDK path - // by looking at the shared fx directory instead. - // depsFile = /dotnet/shared/Microsoft.NETCore.App/6.0-preview2/Microsoft.NETCore.App.deps.json - var depsFile = (string)AppContext.GetData("FX_DEPS_FILE"); - return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(depsFile), "..", "..", "..", "dotnet" + (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : ""))); - } - } - - [Fact] - public void ReturnsNullIfMainModuleIsNotDotNet() - { - var muxerPath = DotNetMuxer.TryFindMuxerPath(@"d:\some-path\testhost.exe"); - Assert.Null(muxerPath); } } #endif