diff --git a/src/Components/WebAssembly/Build/src/targets/ServiceWorkerAssetsManifest.targets b/src/Components/WebAssembly/Build/src/targets/ServiceWorkerAssetsManifest.targets index 49cb73e1b6..383d8cdd2b 100644 --- a/src/Components/WebAssembly/Build/src/targets/ServiceWorkerAssetsManifest.targets +++ b/src/Components/WebAssembly/Build/src/targets/ServiceWorkerAssetsManifest.targets @@ -44,7 +44,12 @@ Condition="'%(RelativePath)' != '$(ServiceWorkerAssetsManifest)'"> $([System.String]::Copy('$([System.String]::Copy('%(StaticWebAsset.BasePath)').TrimEnd('/'))/%(StaticWebAsset.RelativePath)').Replace('\','/').TrimStart('/')) + + + + + @@ -53,11 +58,10 @@ - + <_CombinedHashIntermediatePath>$(_BlazorIntermediateOutputPath)serviceworkerhashes.txt @@ -65,6 +69,7 @@ @@ -72,8 +77,50 @@ - $([System.String]::Copy('%(_ServiceWorkerAssetsManifestCombinedHash.FileHash)').Substring(0, 8)) + $([System.String]::Copy('%(_ServiceWorkerAssetsManifestCombinedHash.FileHash)').Substring(0, 8)) + + + + + + + + + + + <_BlazorOutputWithTargetPath Include="@(_ServiceWorkerIntermediateFile)" /> + + + + + + + <_ServiceWorkerIntermediateFile Include="@(ServiceWorker->'$(IntermediateOutputPath)blazor\serviceworkers\%(Identity)')"> + %(ServiceWorker.PublishedContent) + %(ServiceWorker.Identity) + %(ServiceWorker.Identity) + $([System.String]::Copy('%(ServiceWorker.Identity)').Substring(8)) + + + + + + + + + + + + diff --git a/src/ProjectTemplates/ComponentsWebAssembly.ProjectTemplates/ComponentsWebAssembly-CSharp.Client.csproj.in b/src/ProjectTemplates/ComponentsWebAssembly.ProjectTemplates/ComponentsWebAssembly-CSharp.Client.csproj.in index dea5fe2a1d..f4f890bfea 100644 --- a/src/ProjectTemplates/ComponentsWebAssembly.ProjectTemplates/ComponentsWebAssembly-CSharp.Client.csproj.in +++ b/src/ProjectTemplates/ComponentsWebAssembly.ProjectTemplates/ComponentsWebAssembly-CSharp.Client.csproj.in @@ -16,6 +16,7 @@ + @@ -23,10 +24,8 @@ - - - - + + diff --git a/src/ProjectTemplates/test/BlazorWasmTemplateTest.cs b/src/ProjectTemplates/test/BlazorWasmTemplateTest.cs index 4939d4d79d..ac959a96ec 100644 --- a/src/ProjectTemplates/test/BlazorWasmTemplateTest.cs +++ b/src/ProjectTemplates/test/BlazorWasmTemplateTest.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Net; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.E2ETesting; @@ -95,9 +96,9 @@ namespace Templates.Test } [Fact] - public async Task BlazorWasmPwaTemplate_Works() + public async Task BlazorWasmStandalonePwaTemplate_Works() { - var project = await ProjectFactory.GetOrCreateProject("blazorpwa", Output); + var project = await ProjectFactory.GetOrCreateProject("blazorstandalonepwa", Output); project.TargetFramework = "netstandard2.1"; var createResult = await project.RunDotNetNewAsync("blazorwasm", args: new[] { "--pwa" }); @@ -111,13 +112,7 @@ namespace Templates.Test await BuildAndRunTest(project.ProjectName, project); - var publishDir = Path.Combine(project.TemplatePublishDir, "wwwroot"); - - // When publishing the PWA template, we generate an assets manifest - // and move service-worker.published.js to overwrite service-worker.js - Assert.False(File.Exists(Path.Combine(publishDir, "service-worker.published.js")), "service-worker.published.js should not be published"); - Assert.True(File.Exists(Path.Combine(publishDir, "service-worker.js")), "service-worker.js should be published"); - Assert.True(File.Exists(Path.Combine(publishDir, "service-worker-assets.js")), "service-worker-assets.js should be published"); + ValidatePublishedServiceWorker(project); using (var serverProcess = RunPublishedStandaloneBlazorProject(project)) { @@ -129,15 +124,86 @@ namespace Templates.Test // 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); + ValidateAppWorksOffline(project, listeningUri, skipFetchData: false); } - private void ValidateAppWorksOffline(Project project, string listeningUri) + [Fact] + public async Task BlazorWasmHostedPwaTemplate_Works() + { + var project = await ProjectFactory.GetOrCreateProject("blazorhostedpwa", Output); + + var createResult = await project.RunDotNetNewAsync("blazorwasm", args: new[] { "--hosted", "--pwa" }); + Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", project, createResult)); + + var serverProject = GetSubProject(project, "Server", $"{project.ProjectName}.Server"); + + var publishResult = await serverProject.RunDotNetPublishAsync(); + Assert.True(0 == publishResult.ExitCode, ErrorMessages.GetFailedProcessMessage("publish", serverProject, publishResult)); + + var buildResult = await serverProject.RunDotNetBuildAsync(); + Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", serverProject, buildResult)); + + await BuildAndRunTest(project.ProjectName, serverProject); + + ValidatePublishedServiceWorker(serverProject); + + string listeningUri; + 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"); + if (BrowserFixture.IsHostAutomationSupported()) + { + aspNetProcess.VisitInBrowser(Browser); + TestBasicNavigation(project.ProjectName); + } + else + { + BrowserFixture.EnforceSupportedConfigurations(); + } + + // 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}"; + } + + // 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); + } + + private void ValidatePublishedServiceWorker(Project project) + { + var publishDir = Path.Combine(project.TemplatePublishDir, "wwwroot"); + + // When publishing the PWA template, we generate an assets manifest + // and move service-worker.published.js to overwrite service-worker.js + Assert.False(File.Exists(Path.Combine(publishDir, "service-worker.published.js")), "service-worker.published.js should not be published"); + Assert.True(File.Exists(Path.Combine(publishDir, "service-worker.js")), "service-worker.js should be published"); + Assert.True(File.Exists(Path.Combine(publishDir, "service-worker-assets.js")), "service-worker-assets.js should be published"); + + // We automatically append the SWAM version as a comment in the published service worker file + var serviceWorkerAssetsManifestContents = ReadFile(publishDir, "service-worker-assets.js"); + var serviceWorkerContents = ReadFile(publishDir, "service-worker.js"); + + // Parse the "version": "..." value from the SWAM, and check it's in the service worker + var serviceWorkerAssetsManifestVersionMatch = new Regex(@"^\s*\""version\"":\s*\""([^\""]+)\""", RegexOptions.Multiline) + .Match(serviceWorkerAssetsManifestContents); + Assert.True(serviceWorkerAssetsManifestVersionMatch.Success); + var serviceWorkerAssetsManifestVersion = serviceWorkerAssetsManifestVersionMatch.Groups[1].Captures[0]; + 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); + TestBasicNavigation(project.ProjectName, skipFetchData: skipFetchData); } [Theory] @@ -333,7 +399,7 @@ namespace Templates.Test } } - private void TestBasicNavigation(string appName, bool usesAuth = false) + private void TestBasicNavigation(string appName, bool usesAuth = false, bool skipFetchData = false) { // Start fresh always if (usesAuth) @@ -399,14 +465,17 @@ namespace Templates.Test Browser.Equal(appName.Trim(), () => Browser.Title.Trim()); } - // 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); + 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); - // 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); + // 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); + } } private string ReadFile(string basePath, string path)