Write hash to service worker on publish (#20131)

* Write SWAM version into service worker output

* Update project template

* Add publishing test

* Update src/Components/WebAssembly/Build/src/targets/ServiceWorkerAssetsManifest.targets

Co-Authored-By: Pranav K <prkrishn@hotmail.com>

* Add E2E test for hosted PWA too

* Avoid test clashes

* E2E test fix

* E2E test fix

* E2E test fix

Co-authored-by: Pranav K <prkrishn@hotmail.com>
This commit is contained in:
Steve Sanderson 2020-03-25 20:06:03 +00:00 committed by GitHub
parent b9d7772816
commit bbe9cca327
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 143 additions and 28 deletions

View File

@ -44,7 +44,12 @@
Condition="'%(RelativePath)' != '$(ServiceWorkerAssetsManifest)'"> Condition="'%(RelativePath)' != '$(ServiceWorkerAssetsManifest)'">
<AssetUrl>$([System.String]::Copy('$([System.String]::Copy('%(StaticWebAsset.BasePath)').TrimEnd('/'))/%(StaticWebAsset.RelativePath)').Replace('\','/').TrimStart('/'))</AssetUrl> <AssetUrl>$([System.String]::Copy('$([System.String]::Copy('%(StaticWebAsset.BasePath)').TrimEnd('/'))/%(StaticWebAsset.RelativePath)').Replace('\','/').TrimStart('/'))</AssetUrl>
</ServiceWorkerAssetsManifestItem> </ServiceWorkerAssetsManifestItem>
<!-- Don't include compressed files in the manifest, since their existence is transparent to the client -->
<ServiceWorkerAssetsManifestItem Remove="@(_CompressedStaticWebAsset->'%(FullPath)')" /> <ServiceWorkerAssetsManifestItem Remove="@(_CompressedStaticWebAsset->'%(FullPath)')" />
<!-- Don't include the service worker files in the manifest, as the service worker doesn't need to fetch itself -->
<ServiceWorkerAssetsManifestItem Remove="%(_ServiceWorkerIntermediateFile.FullPath)" />
</ItemGroup> </ItemGroup>
<GetFileHash Files="@(ServiceWorkerAssetsManifestItem)" Algorithm="SHA256" HashEncoding="base64"> <GetFileHash Files="@(ServiceWorkerAssetsManifestItem)" Algorithm="SHA256" HashEncoding="base64">
@ -53,11 +58,10 @@
</Target> </Target>
<!-- <!--
If no ServiceWorkerAssetsManifestVersion was specified, we compute a default value by combining all the asset hashes. Compute a default ServiceWorkerAssetsManifestVersion value by combining all the asset hashes.
This is useful because then clients will only have to repopulate caches if the contents have changed. This is useful because then clients will only have to repopulate caches if the contents have changed.
--> -->
<Target Name="_ComputeDefaultServiceWorkerAssetsManifestVersion" <Target Name="_ComputeDefaultServiceWorkerAssetsManifestVersion" Condition="'$(ServiceWorkerAssetsManifest)' != ''">
Condition="'$(ServiceWorkerAssetsManifestVersion)' == ''">
<PropertyGroup> <PropertyGroup>
<_CombinedHashIntermediatePath>$(_BlazorIntermediateOutputPath)serviceworkerhashes.txt</_CombinedHashIntermediatePath> <_CombinedHashIntermediatePath>$(_BlazorIntermediateOutputPath)serviceworkerhashes.txt</_CombinedHashIntermediatePath>
</PropertyGroup> </PropertyGroup>
@ -65,6 +69,7 @@
<WriteLinesToFile <WriteLinesToFile
File="$(_CombinedHashIntermediatePath)" File="$(_CombinedHashIntermediatePath)"
Lines="@(_ServiceWorkerAssetsManifestItemWithHash->'%(FileHash)')" Lines="@(_ServiceWorkerAssetsManifestItemWithHash->'%(FileHash)')"
WriteOnlyWhenDifferent="true"
Overwrite="true" /> Overwrite="true" />
<GetFileHash Files="$(_CombinedHashIntermediatePath)" Algorithm="SHA256" HashEncoding="base64"> <GetFileHash Files="$(_CombinedHashIntermediatePath)" Algorithm="SHA256" HashEncoding="base64">
@ -72,8 +77,50 @@
</GetFileHash> </GetFileHash>
<PropertyGroup> <PropertyGroup>
<ServiceWorkerAssetsManifestVersion>$([System.String]::Copy('%(_ServiceWorkerAssetsManifestCombinedHash.FileHash)').Substring(0, 8))</ServiceWorkerAssetsManifestVersion> <ServiceWorkerAssetsManifestVersion Condition="'$(ServiceWorkerAssetsManifestVersion)' == ''">$([System.String]::Copy('%(_ServiceWorkerAssetsManifestCombinedHash.FileHash)').Substring(0, 8))</ServiceWorkerAssetsManifestVersion>
</PropertyGroup> </PropertyGroup>
</Target> </Target>
<Target Name="_OmitServiceWorkerContent" BeforeTargets="AssignTargetPaths">
<ItemGroup>
<!-- Don't emit the service worker source files to the output -->
<Content Remove="@(ServiceWorker)" />
<Content Remove="@(ServiceWorker->'%(PublishedContent)')" />
</ItemGroup>
</Target>
<Target Name="_ResolveServiceWorkerOutputs"
BeforeTargets="_ResolveBlazorOutputs"
DependsOnTargets="_ComputeServiceWorkerOutputs; _GenerateServiceWorkerIntermediateFiles">
<ItemGroup>
<_BlazorOutputWithTargetPath Include="@(_ServiceWorkerIntermediateFile)" />
</ItemGroup>
</Target>
<Target Name="_ComputeServiceWorkerOutputs">
<ItemGroup>
<!-- Figure out where we're getting the content for each @(ServiceWorker) entry, depending on whether there's a PublishedContent value -->
<_ServiceWorkerIntermediateFile Include="@(ServiceWorker->'$(IntermediateOutputPath)blazor\serviceworkers\%(Identity)')">
<ContentSourcePath Condition="'%(ServiceWorker.PublishedContent)' != ''">%(ServiceWorker.PublishedContent)</ContentSourcePath>
<ContentSourcePath Condition="'%(ServiceWorker.PublishedContent)' == ''">%(ServiceWorker.Identity)</ContentSourcePath>
<TargetOutputPath>%(ServiceWorker.Identity)</TargetOutputPath>
<TargetOutputPath Condition="$([System.String]::Copy('%(ServiceWorker.Identity)').StartsWith('wwwroot\'))">$([System.String]::Copy('%(ServiceWorker.Identity)').Substring(8))</TargetOutputPath>
</_ServiceWorkerIntermediateFile>
</ItemGroup>
</Target>
<Target Name="_GenerateServiceWorkerIntermediateFiles"
Inputs="@(_ServiceWorkerIntermediateFile->'%(ContentSourcePath)'); $(_CombinedHashIntermediatePath)"
Outputs="@(_ServiceWorkerIntermediateFile)"
DependsOnTargets="_ComputeDefaultServiceWorkerAssetsManifestVersion">
<Copy SourceFiles="%(_ServiceWorkerIntermediateFile.ContentSourcePath)" DestinationFiles="%(_ServiceWorkerIntermediateFile.Identity)" />
<WriteLinesToFile
File="%(_ServiceWorkerIntermediateFile.Identity)"
Lines="/* Manifest version: $(ServiceWorkerAssetsManifestVersion) */"
Condition="'$(ServiceWorkerAssetsManifestVersion)' != ''" />
<ItemGroup>
<FileWrites Include="%(_ServiceWorkerIntermediateFile.Identity)" />
</ItemGroup>
</Target>
</Project> </Project>

View File

@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="${MicrosoftAspNetCoreComponentsWebAssemblyAuthenticationPackageVersion}" Condition="'$(IndividualLocalAuth)' == 'true'" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="${MicrosoftAspNetCoreComponentsWebAssemblyAuthenticationPackageVersion}" Condition="'$(IndividualLocalAuth)' == 'true'" />
<PackageReference Include="Microsoft.Authentication.WebAssembly.Msal" Version="${MicrosoftAuthenticationWebAssemblyMsalPackageVersion}" Condition="'$(OrganizationalAuth)' == 'true' OR '$(IndividualB2CAuth)' == 'true'" /> <PackageReference Include="Microsoft.Authentication.WebAssembly.Msal" Version="${MicrosoftAuthenticationWebAssemblyMsalPackageVersion}" Condition="'$(OrganizationalAuth)' == 'true' OR '$(IndividualB2CAuth)' == 'true'" />
</ItemGroup> </ItemGroup>
<!--#if Hosted --> <!--#if Hosted -->
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Shared\ComponentsWebAssembly-CSharp.Shared.csproj" /> <ProjectReference Include="..\Shared\ComponentsWebAssembly-CSharp.Shared.csproj" />
@ -23,10 +24,8 @@
<!--#endif --> <!--#endif -->
<!--#if PWA --> <!--#if PWA -->
<!-- When publishing, swap service-worker.published.js in place of service-worker.js --> <ItemGroup>
<ItemGroup Condition="'$(DesignTimeBuild)' != 'true'"> <ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />
<Content Remove="wwwroot\service-worker.js" />
<Content Update="wwwroot\service-worker.published.js" Link="wwwroot\service-worker.js" />
</ItemGroup> </ItemGroup>
<!--#endif --> <!--#endif -->

View File

@ -6,6 +6,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.E2ETesting; using Microsoft.AspNetCore.E2ETesting;
@ -95,9 +96,9 @@ namespace Templates.Test
} }
[Fact] [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"; project.TargetFramework = "netstandard2.1";
var createResult = await project.RunDotNetNewAsync("blazorwasm", args: new[] { "--pwa" }); var createResult = await project.RunDotNetNewAsync("blazorwasm", args: new[] { "--pwa" });
@ -111,13 +112,7 @@ namespace Templates.Test
await BuildAndRunTest(project.ProjectName, project); await BuildAndRunTest(project.ProjectName, project);
var publishDir = Path.Combine(project.TemplatePublishDir, "wwwroot"); ValidatePublishedServiceWorker(project);
// 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");
using (var serverProcess = RunPublishedStandaloneBlazorProject(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, // 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. // 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 Browser.Navigate().GoToUrl("about:blank"); // Be sure we're really reloading
Output.WriteLine($"Opening browser without corresponding server at {listeningUri}..."); Output.WriteLine($"Opening browser without corresponding server at {listeningUri}...");
Browser.Navigate().GoToUrl(listeningUri); Browser.Navigate().GoToUrl(listeningUri);
TestBasicNavigation(project.ProjectName); TestBasicNavigation(project.ProjectName, skipFetchData: skipFetchData);
} }
[Theory] [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 // Start fresh always
if (usesAuth) if (usesAuth)
@ -399,14 +465,17 @@ namespace Templates.Test
Browser.Equal(appName.Trim(), () => Browser.Title.Trim()); Browser.Equal(appName.Trim(), () => Browser.Title.Trim());
} }
// Can navigate to the 'fetch data' page if (!skipFetchData)
Browser.FindElement(By.PartialLinkText("Fetch data")).Click(); {
Browser.Contains("fetchdata", () => Browser.Url); // Can navigate to the 'fetch data' page
Browser.Equal("Weather forecast", () => Browser.FindElement(By.TagName("h1")).Text); 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 // Asynchronously loads and displays the table of weather forecasts
Browser.Exists(By.CssSelector("table>tbody>tr")); Browser.Exists(By.CssSelector("table>tbody>tr"));
Browser.Equal(5, () => Browser.FindElements(By.CssSelector("p+table>tbody>tr")).Count); Browser.Equal(5, () => Browser.FindElements(By.CssSelector("p+table>tbody>tr")).Count);
}
} }
private string ReadFile(string basePath, string path) private string ReadFile(string basePath, string path)