Add support for loading satellite assemblies (#20033)

* Add support for loading satellite assemblies

Fixes #17016
This commit is contained in:
Pranav K 2020-03-26 13:58:00 -07:00 committed by GitHub
parent cb6858fe31
commit 82d05ae785
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 959 additions and 175 deletions

File diff suppressed because one or more lines are too long

View File

@ -32,7 +32,7 @@ export interface ResourceGroups {
readonly assembly: ResourceList;
readonly pdb?: ResourceList;
readonly runtime: ResourceList;
readonly satelliteResources?: { [cultureName: string] : ResourceList };
}
export type ResourceList = { [name: string]: string };

View File

@ -207,6 +207,37 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
// of the extensions in the URLs. This allows loading assemblies with arbitrary filenames.
assembliesBeingLoaded.forEach(r => addResourceAsAssembly(r, changeExtension(r.name, '.dll')));
pdbsBeingLoaded.forEach(r => addResourceAsAssembly(r, r.name));
// Wire-up callbacks for satellite assemblies. Blazor will call these as part of the application
// startup sequence to load satellite assemblies for the application's culture.
window['Blazor']._internal.getSatelliteAssemblies = (culturesToLoadDotNetArray: System_Array<System_String>) : System_Object => {
const culturesToLoad = BINDING.mono_array_to_js_array<System_String, string>(culturesToLoadDotNetArray);
const satelliteResources = resourceLoader.bootConfig.resources.satelliteResources;
if (satelliteResources) {
const resourcePromises = Promise.all(culturesToLoad
.filter(culture => satelliteResources.hasOwnProperty(culture))
.map(culture => resourceLoader.loadResources(satelliteResources[culture], fileName => `_framework/_bin/${fileName}`))
.reduce((previous, next) => previous.concat(next), new Array<LoadingResource>())
.map(async resource => (await resource.response).arrayBuffer()));
return BINDING.js_to_mono_obj(
resourcePromises.then(resourcesToLoad => {
if (resourcesToLoad.length) {
window['Blazor']._internal.readSatelliteAssemblies = () => {
const array = BINDING.mono_obj_array_new(resourcesToLoad.length);
for (var i = 0; i < resourcesToLoad.length; i++) {
BINDING.mono_obj_array_set(array, i, BINDING.js_typed_array_to_array(new Uint8Array(resourcesToLoad[i])));
}
return array;
};
}
return resourcesToLoad.length;
}));
}
return BINDING.js_to_mono_obj(Promise.resolve(0));
}
});
module.postRun.push(() => {

View File

@ -1,4 +1,4 @@
import { Pointer, System_String } from '../Platform';
import { Pointer, System_String, System_Array, System_Object } from '../Platform';
// Mono uses this global to hang various debugging-related items on
@ -10,9 +10,12 @@ declare interface MONO {
// Mono uses this global to hold low-level interop APIs
declare interface BINDING {
mono_obj_array_new(length: number): System_Array<System_Object>;
mono_obj_array_set(array: System_Array<System_Object>, index: Number, value: System_Object): void;
js_string_to_mono_string(jsString: string): System_String;
js_typed_array_to_array(array: Uint8Array): Pointer;
js_typed_array_to_array<T>(array: Array<T>): Pointer;
js_typed_array_to_array(array: Uint8Array): System_Object;
js_to_mono_obj(jsObject: any) : System_Object;
mono_array_to_js_array<TInput, TOutput>(array: System_Array<TInput>) : Array<TOutput>;
conv_string(dotnetString: System_String | null): string | null;
bind_static_method(fqn: string, signature?: string): Function;
}

View File

@ -1,5 +1,5 @@
import { BootConfigResult } from './BootConfig';
import { System_String, Pointer } from './Platform';
import { System_String, System_Object } from './Platform';
export class WebAssemblyConfigLoader {
static async initAsync(bootConfigResult: BootConfigResult): Promise<void> {
@ -9,7 +9,7 @@ export class WebAssemblyConfigLoader {
.filter(name => name === 'appsettings.json' || name === `appsettings.${bootConfigResult.applicationEnvironment}.json`)
.map(async name => ({ name, content: await getConfigBytes(name) })));
window['Blazor']._internal.getConfig = (dotNetFileName: System_String) : Pointer | undefined => {
window['Blazor']._internal.getConfig = (dotNetFileName: System_String) : System_Object | undefined => {
const fileName = BINDING.conv_string(dotNetFileName);
const resolvedFile = configFiles.find(f => f.name === fileName);
return resolvedFile ? BINDING.js_typed_array_to_array(resolvedFile.content) : undefined;

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using System.Text;
using Microsoft.Build.Framework;
@ -61,28 +62,56 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Build
cacheBootResources = CacheBootResources,
debugBuild = DebugBuild,
linkerEnabled = LinkerEnabled,
resources = new Dictionary<ResourceType, ResourceHashesByNameDictionary>(),
resources = new ResourcesData(),
config = new List<string>(),
};
// Build a two-level dictionary of the form:
// - BootResourceType (e.g., "assembly")
// - assembly:
// - UriPath (e.g., "System.Text.Json.dll")
// - ContentHash (e.g., "4548fa2e9cf52986")
// - runtime:
// - UriPath (e.g., "dotnet.js")
// - ContentHash (e.g., "3448f339acf512448")
if (Resources != null)
{
var resourceData = result.resources;
foreach (var resource in Resources)
{
var resourceTypeMetadata = resource.GetMetadata("BootManifestResourceType");
if (!Enum.TryParse<ResourceType>(resourceTypeMetadata, out var resourceType))
ResourceHashesByNameDictionary resourceList;
switch (resourceTypeMetadata)
{
throw new NotSupportedException($"Unsupported BootManifestResourceType metadata value: {resourceTypeMetadata}");
}
case "runtime":
resourceList = resourceData.runtime;
break;
case "assembly":
resourceList = resourceData.assembly;
break;
case "pdb":
resourceData.pdb = new ResourceHashesByNameDictionary();
resourceList = resourceData.pdb;
break;
case "satellite":
if (resourceData.satelliteResources is null)
{
resourceData.satelliteResources = new Dictionary<string, ResourceHashesByNameDictionary>(StringComparer.OrdinalIgnoreCase);
}
var resourceCulture = resource.GetMetadata("Culture");
if (resourceCulture is null)
{
Log.LogWarning("Satellite resource {0} does not specify required metadata 'Culture'.", resource);
continue;
}
if (!result.resources.TryGetValue(resourceType, out var resourceList))
{
resourceList = new ResourceHashesByNameDictionary();
result.resources.Add(resourceType, resourceList);
if (!resourceData.satelliteResources.TryGetValue(resourceCulture, out resourceList))
{
resourceList = new ResourceHashesByNameDictionary();
resourceData.satelliteResources.Add(resourceCulture, resourceList);
}
break;
default:
throw new NotSupportedException($"Unsupported BootManifestResourceType metadata value: {resourceTypeMetadata}");
}
var resourceName = GetResourceName(resource);
@ -142,7 +171,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Build
/// and values are SHA-256 hashes formatted in prefixed base-64 style (e.g., 'sha256-abcdefg...')
/// as used for subresource integrity checking.
/// </summary>
public Dictionary<ResourceType, ResourceHashesByNameDictionary> resources { get; set; }
public ResourcesData resources { get; set; } = new ResourcesData();
/// <summary>
/// Gets a value that determines whether to enable caching of the <see cref="resources"/>
@ -166,11 +195,29 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Build
public List<string> config { get; set; }
}
public enum ResourceType
public class ResourcesData
{
assembly,
pdb,
runtime,
/// <summary>
/// .NET Wasm runtime resources (dotnet.wasm, dotnet.js) etc.
/// </summary>
public ResourceHashesByNameDictionary runtime { get; set; } = new ResourceHashesByNameDictionary();
/// <summary>
/// "assembly" (.dll) resources
/// </summary>
public ResourceHashesByNameDictionary assembly { get; set; } = new ResourceHashesByNameDictionary();
/// <summary>
/// "debug" (.pdb) resources
/// </summary>
[DataMember(EmitDefaultValue = false)]
public ResourceHashesByNameDictionary pdb { get; set; }
/// <summary>
/// localization (.satellite resx) resources
/// </summary>
[DataMember(EmitDefaultValue = false)]
public Dictionary<string, ResourceHashesByNameDictionary> satelliteResources { get; set; }
}
#pragma warning restore IDE1006 // Naming Styles
}

View File

@ -5,8 +5,7 @@
<AdditionalMonoLinkerOptions>--disable-opt unreachablebodies --verbose --strip-security true --exclude-feature com -v false -c link -u link -b true</AdditionalMonoLinkerOptions>
<_BlazorJsPath Condition="'$(_BlazorJsPath)' == ''">$(MSBuildThisFileDirectory)..\tools\blazor\blazor.webassembly.js</_BlazorJsPath>
<_BaseBlazorDistPath>dist\</_BaseBlazorDistPath>
<_BaseBlazorRuntimeOutputPath>$(_BaseBlazorDistPath)_framework\</_BaseBlazorRuntimeOutputPath>
<_BaseBlazorRuntimeOutputPath>_framework\</_BaseBlazorRuntimeOutputPath>
<_BlazorRuntimeBinOutputPath>$(_BaseBlazorRuntimeOutputPath)_bin\</_BlazorRuntimeBinOutputPath>
<_BlazorRuntimeWasmOutputPath>$(_BaseBlazorRuntimeOutputPath)wasm\</_BlazorRuntimeWasmOutputPath>
<_BlazorBuiltInBclLinkerDescriptor>$(MSBuildThisFileDirectory)BuiltInBclLinkerDescriptor.xml</_BlazorBuiltInBclLinkerDescriptor>

View File

@ -50,14 +50,14 @@
-->
<ItemGroup>
<!-- Assemblies from packages -->
<_BlazorManagedRuntimeAssemby Include="@(RuntimeCopyLocalItems)" />
<_BlazorManagedRuntimeAssembly Include="@(RuntimeCopyLocalItems)" />
<!-- Assemblies from other references -->
<_BlazorUserRuntimeAssembly Include="@(ReferencePath->WithMetadataValue('CopyLocal', 'true'))" />
<_BlazorUserRuntimeAssembly Include="@(ReferenceDependencyPaths->WithMetadataValue('CopyLocal', 'true'))" />
<_BlazorManagedRuntimeAssemby Include="@(_BlazorUserRuntimeAssembly)" />
<_BlazorManagedRuntimeAssemby Include="@(IntermediateAssembly)" />
<_BlazorManagedRuntimeAssembly Include="@(_BlazorUserRuntimeAssembly)" />
<_BlazorManagedRuntimeAssembly Include="@(IntermediateAssembly)" />
</ItemGroup>
<MakeDir Directories="$(_BlazorIntermediateOutputPath)" />
@ -70,26 +70,57 @@
satellite assemblies, this should include all assemblies needed to run the application.
-->
<ItemGroup>
<_BlazorJSFile Include="$(_BlazorJSPath)" />
<_BlazorJSFile Include="$(_BlazorJSMapPath)" Condition="Exists('$(_BlazorJSMapPath)')" />
<_DotNetWasmRuntimeFile Include="$(ComponentsWebAssemblyRuntimePath)*" />
<!--
ReferenceCopyLocalPaths includes all files that are part of the build out with CopyLocalLockFileAssemblies on.
Remove assemblies that are inputs to calculating the assembly closure. Instead use the resolved outputs, since it is the minimal set.
ReferenceCopyLocalPaths also includes satellite assemblies from referenced projects but are inexpicably missing
any metadata that might allow them to be differentiated. We'll explicitly add those
to _BlazorOutputWithTargetPath so that satellite assemblies from packages, the current project and referenced project
are all treated the same.
-->
<_BlazorCopyLocalPaths Include="@(ReferenceCopyLocalPaths)" Condition="'%(Extension)' == '.dll'" />
<_BlazorCopyLocalPaths Remove="@(_BlazorManagedRuntimeAssemby)" />
<_BlazorCopyLocalPaths Include="@(ReferenceCopyLocalPaths)"
Exclude="@(_BlazorManagedRuntimeAssembly);@(ReferenceSatellitePaths)"
Condition="'%(Extension)' == '.dll'" />
<_BlazorCopyLocalPaths Include="@(IntermediateSatelliteAssembliesWithTargetPath)">
<DestinationSubDirectory>%(IntermediateSatelliteAssembliesWithTargetPath.Culture)\</DestinationSubDirectory>
</_BlazorCopyLocalPaths>
<_BlazorOutputWithTargetPath Include="@(_BlazorCopyLocalPaths)">
<!-- This group is for satellite assemblies. We set the resource name to include a path, e.g. "fr\\SomeAssembly.resources.dll" -->
<BootManifestResourceType Condition="'%(Extension)' == '.dll'">assembly</BootManifestResourceType>
<BootManifestResourceType Condition="'%(Extension)' == '.pdb'">pdb</BootManifestResourceType>
<BootManifestResourceType Condition="'%(_BlazorCopyLocalPaths.Culture)' == '' AND '%(_BlazorCopyLocalPaths.Extension)' == '.dll'">assembly</BootManifestResourceType>
<BootManifestResourceType Condition="'%(_BlazorCopyLocalPaths.Culture)' != '' AND '%(_BlazorCopyLocalPaths.Extension)' == '.dll'">satellite</BootManifestResourceType>
<BootManifestResourceName>%(_BlazorCopyLocalPaths.DestinationSubDirectory)%(FileName)%(Extension)</BootManifestResourceName>
<TargetOutputPath>$(_BlazorRuntimeBinOutputPath)%(_BlazorCopyLocalPaths.DestinationSubDirectory)%(FileName)%(Extension)</TargetOutputPath>
</_BlazorOutputWithTargetPath>
<_BlazorOutputWithTargetPath Include="@(ReferenceSatellitePaths)">
<Culture>$([System.String]::Copy('%(ReferenceSatellitePaths.DestinationSubDirectory)').Trim('\').Trim('/'))</Culture>
<BootManifestResourceType>satellite</BootManifestResourceType>
<BootManifestResourceName>%(ReferenceSatellitePaths.DestinationSubDirectory)%(FileName)%(Extension)</BootManifestResourceName>
<TargetOutputPath>$(_BlazorRuntimeBinOutputPath)%(ReferenceSatellitePaths.DestinationSubDirectory)%(FileName)%(Extension)</TargetOutputPath>
</_BlazorOutputWithTargetPath>
<_BlazorOutputWithTargetPath Include="@(_BlazorResolvedAssembly)">
<BootManifestResourceType Condition="'%(Extension)' == '.dll'">assembly</BootManifestResourceType>
<BootManifestResourceType Condition="'%(Extension)' == '.pdb'">pdb</BootManifestResourceType>
<BootManifestResourceType Condition="'%(_BlazorCopyLocalPaths.Extension)' == '.pdb'">pdb</BootManifestResourceType>
<BootManifestResourceName>%(_BlazorCopyLocalPaths.DestinationSubDirectory)%(FileName)%(Extension)</BootManifestResourceName>
<TargetOutputPath>$(_BlazorRuntimeBinOutputPath)%(_BlazorCopyLocalPaths.DestinationSubDirectory)%(FileName)%(Extension)</TargetOutputPath>
</_BlazorOutputWithTargetPath>
<_BlazorOutputWithTargetPath Include="@(_DotNetWasmRuntimeFile)">
<TargetOutputPath>$(_BlazorRuntimeWasmOutputPath)%(FileName)%(Extension)</TargetOutputPath>
<BootManifestResourceType>runtime</BootManifestResourceType>
<BootManifestResourceName>%(FileName)%(Extension)</BootManifestResourceName>
<TargetOutputPath>$(_BlazorRuntimeBinOutputPath)%(FileName)%(Extension)</TargetOutputPath>
</_BlazorOutputWithTargetPath>
<_BlazorOutputWithTargetPath Include="@(_BlazorJSFile)">
<TargetOutputPath>$(_BaseBlazorRuntimeOutputPath)%(FileName)%(Extension)</TargetOutputPath>
</_BlazorOutputWithTargetPath>
</ItemGroup>
@ -101,42 +132,6 @@
<ItemGroup Condition="'$(BlazorEnableDebugging)' != 'true'">
<_BlazorOutputWithTargetPath Remove="@(_BlazorOutputWithTargetPath)" Condition="'%(Extension)' == '.pdb'" />
</ItemGroup>
<!--
The following itemgroup attempts to extend the set to include satellite assemblies.
The mechanism behind this (or whether it's correct) is a bit unclear so
https://github.com/dotnet/aspnetcore/issues/18951 tracks the need for follow-up.
-->
<ItemGroup>
<!--
ReferenceCopyLocalPaths includes all files that are part of the build out with CopyLocalLockFileAssemblies on.
Remove assemblies that are inputs to calculating the assembly closure. Instead use the resolved outputs, since it is the minimal set.
-->
<_BlazorCopyLocalPaths Include="@(ReferenceCopyLocalPaths)" Condition="'%(Extension)' == '.dll'" />
<_BlazorCopyLocalPaths Remove="@(_BlazorManagedRuntimeAssemby)" Condition="'%(Extension)' == '.dll'" />
<_BlazorOutputWithTargetPath Include="@(_BlazorCopyLocalPaths)">
<BootManifestResourceType Condition="'%(Extension)' == '.dll'">assembly</BootManifestResourceType>
<BootManifestResourceType Condition="'%(Extension)' == '.pdb'">pdb</BootManifestResourceType>
<BootManifestResourceName>%(_BlazorCopyLocalPaths.DestinationSubDirectory)%(FileName)%(Extension)</BootManifestResourceName>
<TargetOutputPath>$(_BlazorRuntimeBinOutputPath)%(_BlazorCopyLocalPaths.DestinationSubDirectory)%(FileName)%(Extension)</TargetOutputPath>
</_BlazorOutputWithTargetPath>
</ItemGroup>
<ItemGroup>
<_DotNetWasmRuntimeFile Include="$(ComponentsWebAssemblyRuntimePath)*" />
<_BlazorOutputWithTargetPath Include="@(_DotNetWasmRuntimeFile)">
<TargetOutputPath>$(_BlazorRuntimeWasmOutputPath)%(FileName)%(Extension)</TargetOutputPath>
<BootManifestResourceType>runtime</BootManifestResourceType>
<BootManifestResourceName>%(FileName)%(Extension)</BootManifestResourceName>
</_BlazorOutputWithTargetPath>
<_BlazorJSFile Include="$(_BlazorJSPath)" />
<_BlazorJSFile Include="$(_BlazorJSMapPath)" Condition="Exists('$(_BlazorJSMapPath)')" />
<_BlazorOutputWithTargetPath Include="@(_BlazorJSFile)">
<TargetOutputPath>$(_BaseBlazorRuntimeOutputPath)%(FileName)%(Extension)</TargetOutputPath>
</_BlazorOutputWithTargetPath>
</ItemGroup>
</Target>
<!--
@ -218,7 +213,7 @@
<Target
Name="_LinkBlazorApplication"
Inputs="$(ProjectAssetsFile);
@(_BlazorManagedRuntimeAssemby);
@(_BlazorManagedRuntimeAssembly);
@(BlazorLinkerDescriptor);
$(MSBuildAllProjects)"
Outputs="$(_BlazorLinkerOutputCache)">
@ -277,7 +272,7 @@
Name="_ResolveBlazorRuntimeDependencies"
Inputs="$(ProjectAssetsFile);
@(IntermediateAssembly);
@(_BlazorManagedRuntimeAssemby)"
@(_BlazorManagedRuntimeAssembly)"
Outputs="$(_BlazorApplicationAssembliesCacheFile)">
<!--
@ -287,7 +282,7 @@
-->
<ResolveBlazorRuntimeDependencies
EntryPoint="@(IntermediateAssembly)"
ApplicationDependencies="@(_BlazorManagedRuntimeAssemby)"
ApplicationDependencies="@(_BlazorManagedRuntimeAssembly)"
WebAssemblyBCLAssemblies="@(_WebAssemblyBCLAssembly)">
<Output TaskParameter="Dependencies" ItemName="_BlazorResolvedAssembly" />

View File

@ -7,7 +7,7 @@
<SourceId>$(PackageId)</SourceId>
<ContentRoot>$([MSBuild]::NormalizeDirectory('$(TargetDir)wwwroot\'))</ContentRoot>
<BasePath>$(StaticWebAssetBasePath)</BasePath>
<RelativePath>$([System.String]::Copy('%(_BlazorOutputWithTargetPath.TargetOutputPath)').Replace('\','/').Replace('dist/',''))</RelativePath>
<RelativePath>$([System.String]::Copy('%(_BlazorOutputWithTargetPath.TargetOutputPath)').Replace('\','/'))</RelativePath>
</StaticWebAsset>
<StaticWebAsset Remove="@(StaticWebAsset)" Condition="'$(BlazorEnableDebugging)' != 'true' and '%(SourceType)' == '' and '%(Extension)' == '.pdb'" />

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Xunit;
using static Microsoft.AspNetCore.Components.WebAssembly.Build.WebAssemblyRuntimePackage;
@ -133,9 +134,19 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Build
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "_bin", "Microsoft.CodeAnalysis.CSharp.dll");
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "_bin", "fr", "Microsoft.CodeAnalysis.CSharp.resources.dll"); // Verify satellite assemblies are present in the build output.
var bootJsonPath = Path.Combine(buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json");
Assert.FileContains(result, bootJsonPath, "\"Microsoft.CodeAnalysis.CSharp.dll\"");
Assert.FileContains(result, bootJsonPath, "\"fr\\/Microsoft.CodeAnalysis.CSharp.resources.dll\"");
var bootJson = JsonSerializer.Deserialize<GenerateBlazorBootJson.BootJsonData>(
File.ReadAllText(Path.Combine(project.DirectoryPath, buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json")),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var satelliteResources = bootJson.resources.satelliteResources;
Assert.NotNull(satelliteResources);
Assert.Contains("es-ES", satelliteResources.Keys);
Assert.Contains("es-ES/classlibrarywithsatelliteassemblies.resources.dll", satelliteResources["es-ES"].Keys);
Assert.Contains("fr", satelliteResources.Keys);
Assert.Contains("fr/Microsoft.CodeAnalysis.CSharp.resources.dll", satelliteResources["fr"].Keys);
Assert.Contains("ja", satelliteResources.Keys);
Assert.Contains("ja/standalone.resources.dll", satelliteResources["ja"].Keys);
}
}
}

View File

@ -2,12 +2,12 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.IO;
using System.Linq;
using System.Runtime.Serialization.Json;
using Microsoft.Build.Framework;
using Moq;
using Xunit;
using BootJsonData = Microsoft.AspNetCore.Components.WebAssembly.Build.GenerateBlazorBootJson.BootJsonData;
using ResourceType = Microsoft.AspNetCore.Components.WebAssembly.Build.GenerateBlazorBootJson.ResourceType;
namespace Microsoft.AspNetCore.Components.WebAssembly.Build
{
@ -23,24 +23,42 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Build
Resources = new[]
{
CreateResourceTaskItem(
ResourceType.assembly,
"assembly",
name: "My.Assembly1.ext", // Can specify filename with no dir
fileHash: "abcdefghikjlmnopqrstuvwxyz"),
CreateResourceTaskItem(
ResourceType.assembly,
"assembly",
name: "dir\\My.Assembly2.ext2", // Can specify Windows-style path
fileHash: "012345678901234567890123456789"),
CreateResourceTaskItem(
ResourceType.pdb,
"pdb",
name: "otherdir/SomePdb.pdb", // Can specify Linux-style path
fileHash: "pdbhashpdbhashpdbhash"),
CreateResourceTaskItem(
ResourceType.runtime,
"runtime",
name: "some-runtime-file", // Can specify path with no extension
fileHash: "runtimehashruntimehash")
fileHash: "runtimehashruntimehash"),
CreateResourceTaskItem(
"satellite",
name: "en-GB\\satellite-assembly1.ext",
fileHash: "hashsatelliteassembly1",
("Culture", "en-GB")),
CreateResourceTaskItem(
"satellite",
name: "fr/satellite-assembly2.dll",
fileHash: "hashsatelliteassembly2",
("Culture", "fr")),
CreateResourceTaskItem(
"satellite",
name: "en-GB\\satellite-assembly3.ext",
fileHash: "hashsatelliteassembly3",
("Culture", "en-GB")),
}
};
@ -52,28 +70,49 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Build
// Assert
var parsedContent = ParseBootData(stream);
Assert.Equal("MyEntrypointAssembly", parsedContent.entryAssembly);
Assert.Collection(parsedContent.resources.Keys,
resourceListKey =>
var resources = parsedContent.resources.assembly;
Assert.Equal(2, resources.Count);
Assert.Equal("sha256-abcdefghikjlmnopqrstuvwxyz", resources["My.Assembly1.ext"]);
Assert.Equal("sha256-012345678901234567890123456789", resources["dir/My.Assembly2.ext2"]); // Paths are converted to use URL-style separators
resources = parsedContent.resources.pdb;
Assert.Single(resources);
Assert.Equal("sha256-pdbhashpdbhashpdbhash", resources["otherdir/SomePdb.pdb"]);
resources = parsedContent.resources.runtime;
Assert.Single(resources);
Assert.Equal("sha256-runtimehashruntimehash", resources["some-runtime-file"]);
var satelliteResources = parsedContent.resources.satelliteResources;
Assert.Collection(
satelliteResources.OrderBy(kvp => kvp.Key),
kvp =>
{
var resources = parsedContent.resources[resourceListKey];
Assert.Equal(ResourceType.assembly, resourceListKey);
Assert.Equal(2, resources.Count);
Assert.Equal("sha256-abcdefghikjlmnopqrstuvwxyz", resources["My.Assembly1.ext"]);
Assert.Equal("sha256-012345678901234567890123456789", resources["dir/My.Assembly2.ext2"]); // Paths are converted to use URL-style separators
Assert.Equal("en-GB", kvp.Key);
Assert.Collection(
kvp.Value.OrderBy(item => item.Key),
item =>
{
Assert.Equal("en-GB/satellite-assembly1.ext", item.Key);
Assert.Equal("sha256-hashsatelliteassembly1", item.Value);
},
item =>
{
Assert.Equal("en-GB/satellite-assembly3.ext", item.Key);
Assert.Equal("sha256-hashsatelliteassembly3", item.Value);
});
},
resourceListKey =>
kvp =>
{
var resources = parsedContent.resources[resourceListKey];
Assert.Equal(ResourceType.pdb, resourceListKey);
Assert.Single(resources);
Assert.Equal("sha256-pdbhashpdbhashpdbhash", resources["otherdir/SomePdb.pdb"]);
},
resourceListKey =>
{
var resources = parsedContent.resources[resourceListKey];
Assert.Equal(ResourceType.runtime, resourceListKey);
Assert.Single(resources);
Assert.Equal("sha256-runtimehashruntimehash", resources["some-runtime-file"]);
Assert.Equal("fr", kvp.Key);
Assert.Collection(
kvp.Value.OrderBy(item => item.Key),
item =>
{
Assert.Equal("fr/satellite-assembly2.dll", item.Key);
Assert.Equal("sha256-hashsatelliteassembly2", item.Value);
});
});
}
@ -137,12 +176,20 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Build
return (BootJsonData)serializer.ReadObject(stream);
}
private static ITaskItem CreateResourceTaskItem(ResourceType type, string name, string fileHash)
private static ITaskItem CreateResourceTaskItem(string type, string name, string fileHash, params (string key, string value)[] values)
{
var mock = new Mock<ITaskItem>();
mock.Setup(m => m.GetMetadata("BootManifestResourceType")).Returns(type.ToString());
mock.Setup(m => m.GetMetadata("BootManifestResourceType")).Returns(type);
mock.Setup(m => m.GetMetadata("BootManifestResourceName")).Returns(name);
mock.Setup(m => m.GetMetadata("FileHash")).Returns(fileHash);
if (values != null)
{
foreach (var (key, value) in values)
{
mock.Setup(m => m.GetMetadata(key)).Returns(value);
}
}
return mock.Object;
}
}

View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Hello" xml:space="preserve">
<value>Hola</value>
</data>
</root>

View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Hello" xml:space="preserve">
<value>Konnichiwa</value>
</data>
</root>

View File

@ -16,33 +16,35 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
// entrypoint result to the JS caller. There's no requirement to do that today, and if we
// do change this it will be non-breaking.
public static void InvokeEntrypoint(string assemblyName, string[] args)
=> InvokeEntrypoint(assemblyName, args, SatelliteResourcesLoader.Init());
internal static async void InvokeEntrypoint(string assemblyName, string[] args, SatelliteResourcesLoader satelliteResourcesLoader)
{
object entrypointResult;
try
{
// Emscripten sets up the culture for the application based on the user's language preferences.
// Before we execute the app's entry point, load satellite assemblies for this culture.
// We'll allow users to configure their app culture as part of MainAsync. Loading satellite assemblies
// for the configured culture will happen as part of WebAssemblyHost.RunAsync.
await satelliteResourcesLoader.LoadCurrentCultureResourcesAsync();
var assembly = Assembly.Load(assemblyName);
var entrypoint = FindUnderlyingEntrypoint(assembly);
var @params = entrypoint.GetParameters().Length == 1 ? new object[] { args ?? Array.Empty<string>() } : new object[] { };
entrypointResult = entrypoint.Invoke(null, @params);
var result = entrypoint.Invoke(null, @params);
if (result is Task resultTask)
{
// In the default case, this Task is backed by the WebAssemblyHost.RunAsync that never completes.
// Awaiting it is allows catching any exception thrown by user code in MainAsync.
await resultTask;
}
}
catch (Exception syncException)
{
HandleStartupException(syncException);
return;
}
// If the entrypoint is async, handle async exceptions in the same way that we would
// have handled sync ones
if (entrypointResult is Task entrypointTask)
{
entrypointTask.ContinueWith(task =>
{
if (task.Exception != null)
{
HandleStartupException(task.Exception);
}
});
}
}
private static MethodBase FindUnderlyingEntrypoint(Assembly assembly)

View File

@ -0,0 +1,109 @@
// 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.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Services;
namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
{
internal class SatelliteResourcesLoader
{
internal const string GetSatelliteAssemblies = "window.Blazor._internal.getSatelliteAssemblies";
internal const string ReadSatelliteAssemblies = "window.Blazor._internal.readSatelliteAssemblies";
private readonly WebAssemblyJSRuntimeInvoker _invoker;
private CultureInfo _previousCulture;
// For unit testing.
internal SatelliteResourcesLoader(WebAssemblyJSRuntimeInvoker invoker)
{
_invoker = invoker;
}
public static SatelliteResourcesLoader Instance { get; private set; }
public static SatelliteResourcesLoader Init()
{
Debug.Assert(Instance is null, "Init should not be called multiple times.");
Instance = new SatelliteResourcesLoader(WebAssemblyJSRuntimeInvoker.Instance);
return Instance;
}
public ValueTask LoadCurrentCultureResourcesAsync()
{
if (_previousCulture != CultureInfo.CurrentCulture)
{
_previousCulture = CultureInfo.CurrentCulture;
return LoadSatelliteAssembliesForCurrentCultureAsync();
}
return default;
}
protected virtual async ValueTask LoadSatelliteAssembliesForCurrentCultureAsync()
{
var culturesToLoad = GetCultures(CultureInfo.CurrentCulture);
if (culturesToLoad.Count == 0)
{
return;
}
// Now that we know the cultures we care about, let WebAssemblyResourceLoader (in JavaScript) load these
// assemblies. We effectively want to resovle a Task<byte[][]> but there is no way to express this
// using interop. We'll instead do this in two parts:
// getSatelliteAssemblies resolves when all satellite assemblies to be loaded in .NET are fetched and available in memory.
var count = (int)await _invoker.InvokeUnmarshalled<string[], object, object, Task<object>>(
GetSatelliteAssemblies,
culturesToLoad.ToArray(),
null,
null);
if (count == 0)
{
return;
}
// readSatelliteAssemblies resolves the assembly bytes
var assemblies = _invoker.InvokeUnmarshalled<object, object, object, object[]>(
ReadSatelliteAssemblies,
null,
null,
null);
for (var i = 0; i < assemblies.Length; i++)
{
Assembly.Load((byte[])assemblies[i]);
}
}
internal static List<string> GetCultures(CultureInfo cultureInfo)
{
var culturesToLoad = new List<string>();
// Once WASM is ready, we have to use .NET's assembly loading to load additional assemblies.
// First calculate all possible cultures that the application might want to load. We do this by
// starting from the current culture and walking up the graph of parents.
// At the end of the the walk, we'll have a list of culture names that look like
// [ "fr-FR", "fr" ]
while (cultureInfo != null && cultureInfo != CultureInfo.InvariantCulture)
{
culturesToLoad.Add(cultureInfo.Name);
if (cultureInfo.Parent == cultureInfo)
{
break;
}
cultureInfo = cultureInfo.Parent;
}
return culturesToLoad;
}
}
}

View File

@ -58,6 +58,8 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
/// </summary>
public IServiceProvider Services => _scope.ServiceProvider;
internal SatelliteResourcesLoader SatelliteResourcesLoader { get; set; } = SatelliteResourcesLoader.Instance;
/// <summary>
/// Disposes the host asynchronously.
/// </summary>
@ -132,6 +134,10 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
await _renderer.AddComponentAsync(rootComponent.ComponentType, rootComponent.Selector);
}
// Users may want to configure the culture based on some ambient state such as local storage, url etc.
// If they have changed the culture since the initial load, fetch satellite assemblies for this selection.
await SatelliteResourcesLoader.LoadCurrentCultureResourcesAsync();
await tcs.Task;
}
}

View File

@ -0,0 +1,6 @@
// 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.CompilerServices;
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")]

View File

@ -2,12 +2,15 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Services;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
@ -33,7 +36,7 @@ static " + returnType + @" Main(" + paramsDecl + @")
}", out var didMainExecute);
// Act
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { }, new TestSatelliteResourcesLoader());
// Assert
Assert.True(didMainExecute());
@ -63,7 +66,7 @@ static async Task" + returnTypeGenericParam + @" Main(" + paramsDecl + @")
// Act/Assert 1: Waits for task
// The fact that we're not blocking here proves that we're not executing the
// metadata-declared entrypoint, as that would block
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { }, new TestSatelliteResourcesLoader());
Assert.False(didMainExecute());
// Act/Assert 2: Continues
@ -88,7 +91,7 @@ public static void Main()
// to handle the exception. We can't assert about what it does here, because that
// would involve capturing console output, which isn't safe in unit tests. Instead
// we'll check this in E2E tests.
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { }, new TestSatelliteResourcesLoader());
Assert.True(didMainExecute());
}
@ -107,7 +110,7 @@ public static async Task Main()
}", out var didMainExecute);
// Act/Assert 1: Waits for task
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { }, new TestSatelliteResourcesLoader());
Assert.False(didMainExecute());
// Act/Assert 2: Continues
@ -149,5 +152,15 @@ namespace SomeApp
return assembly;
}
private class TestSatelliteResourcesLoader : SatelliteResourcesLoader
{
internal TestSatelliteResourcesLoader()
: base(WebAssemblyJSRuntimeInvoker.Instance)
{
}
protected override ValueTask LoadSatelliteAssembliesForCurrentCultureAsync() => default;
}
}
}

View File

@ -0,0 +1,114 @@
// 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.Globalization;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Services;
using Microsoft.AspNetCore.Testing;
using Moq;
using Xunit;
using static Microsoft.AspNetCore.Components.WebAssembly.Hosting.SatelliteResourcesLoader;
namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
{
public class SatelliteResourcesLoaderTest
{
[Theory]
[InlineData("fr-FR", new[] { "fr-FR", "fr" })]
[InlineData("tzm-Latn-DZ", new[] { "tzm-Latn-DZ", "tzm-Latn", "tzm" })]
public void GetCultures_ReturnsCultureClosure(string cultureName, string[] expected)
{
// Arrange
var culture = new CultureInfo(cultureName);
// Act
var actual = SatelliteResourcesLoader.GetCultures(culture);
// Assert
Assert.Equal(expected, actual);
}
[Fact]
public async Task LoadCurrentCultureResourcesAsync_ReadsAssemblies()
{
// Arrange
using var cultureReplacer = new CultureReplacer("en-GB");
var invoker = new Mock<WebAssemblyJSRuntimeInvoker>();
invoker.Setup(i => i.InvokeUnmarshalled<string[], object, object, Task<object>>(GetSatelliteAssemblies, new[] { "en-GB", "en" }, null, null))
.Returns(Task.FromResult<object>(1))
.Verifiable();
invoker.Setup(i => i.InvokeUnmarshalled<object, object, object, object[]>(ReadSatelliteAssemblies, null, null, null))
.Returns(new object[] { File.ReadAllBytes(GetType().Assembly.Location) })
.Verifiable();
var loader = new SatelliteResourcesLoader(invoker.Object);
// Act
await loader.LoadCurrentCultureResourcesAsync();
// Assert
invoker.Verify();
}
[Fact]
public async Task LoadCurrentCultureResourcesAsync_DoesNotReadAssembliesWhenThereAreNone()
{
// Arrange
using var cultureReplacer = new CultureReplacer("en-GB");
var invoker = new Mock<WebAssemblyJSRuntimeInvoker>();
invoker.Setup(i => i.InvokeUnmarshalled<string[], object, object, Task<object>>(GetSatelliteAssemblies, new[] { "en-GB", "en" }, null, null))
.Returns(Task.FromResult<object>(0))
.Verifiable();
var loader = new SatelliteResourcesLoader(invoker.Object);
// Act
await loader.LoadCurrentCultureResourcesAsync();
// Assert
invoker.Verify(i => i.InvokeUnmarshalled<object, object, object, object[]>(ReadSatelliteAssemblies, null, null, null), Times.Never());
}
[Fact]
public async Task LoadCurrentCultureResourcesAsync_AttemptsToReadAssemblies_IfCultureIsChangedBetweenInvocation()
{
// Arrange
using var cultureReplacer = new CultureReplacer("en-GB");
var invoker = new Mock<WebAssemblyJSRuntimeInvoker>();
invoker.Setup(i => i.InvokeUnmarshalled<string[], object, object, Task<object>>(GetSatelliteAssemblies, It.IsAny<string[]>(), null, null))
.Returns(Task.FromResult<object>(0))
.Verifiable();
var loader = new SatelliteResourcesLoader(invoker.Object);
// Act
await loader.LoadCurrentCultureResourcesAsync();
CultureInfo.CurrentCulture = new CultureInfo("fr-fr");
await loader.LoadCurrentCultureResourcesAsync();
invoker.Verify(i => i.InvokeUnmarshalled<object, object, object, Task<object>>(GetSatelliteAssemblies, It.IsAny<string[]>(), null, null),
Times.Exactly(2));
}
[Fact]
public async Task LoadCurrentCultureResourcesAsync_NoOps_WhenInvokedSecondTime_WithSameCulture()
{
// Arrange
using var cultureReplacer = new CultureReplacer("en-GB");
var invoker = new Mock<WebAssemblyJSRuntimeInvoker>();
invoker.Setup(i => i.InvokeUnmarshalled<string[], object, object, Task<object>>(GetSatelliteAssemblies, It.IsAny<string[]>(), null, null))
.Returns(Task.FromResult<object>(0));;
var loader = new SatelliteResourcesLoader(invoker.Object);
// Act
await loader.LoadCurrentCultureResourcesAsync();
await loader.LoadCurrentCultureResourcesAsync();
invoker.Verify(i => i.InvokeUnmarshalled<object, object, object, Task<object>>(GetSatelliteAssemblies, It.IsAny<string[]>(), null, null),
Times.Once());
}
}
}

View File

@ -4,6 +4,7 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Services;
using Microsoft.AspNetCore.Testing;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
@ -20,6 +21,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
// Arrange
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
var host = builder.Build();
host.SatelliteResourcesLoader = new TestSatelliteResourcesLoader();
var cts = new CancellationTokenSource();
@ -38,6 +40,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
// Arrange
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
var host = builder.Build();
host.SatelliteResourcesLoader = new TestSatelliteResourcesLoader();
var cts = new CancellationTokenSource();
var task = host.RunAsyncCore(cts.Token);
@ -59,6 +62,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
builder.Services.AddSingleton<DisposableService>();
var host = builder.Build();
host.SatelliteResourcesLoader = new TestSatelliteResourcesLoader();
var disposable = host.Services.GetRequiredService<DisposableService>();
@ -87,5 +91,15 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
return new ValueTask(Task.CompletedTask);
}
}
private class TestSatelliteResourcesLoader : SatelliteResourcesLoader
{
internal TestSatelliteResourcesLoader()
: base(WebAssemblyJSRuntimeInvoker.Instance)
{
}
protected override ValueTask LoadSatelliteAssembliesForCurrentCultureAsync() => default;
}
}
}

View File

@ -0,0 +1,54 @@
// 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 BasicTestApp;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.Components.E2ETests.Tests;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using OpenQA.Selenium.Support.UI;
using TestServer;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
{
// For now this is limited to server-side execution because we don't have the ability to set the
// culture in client-side Blazor.
public class ServerGlobalizationTest : GlobalizationTest<BasicTestAppServerSiteFixture<InternationalizationStartup>>
{
public ServerGlobalizationTest(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<InternationalizationStartup> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
}
protected override TimeSpan UtcOffset => TimeZoneInfo.Local.BaseUtcOffset;
protected override void InitializeAsyncCore()
{
Navigate(ServerPathBase);
Browser.MountTestComponent<CulturePicker>();
Browser.Exists(By.Id("culture-selector"));
}
protected override void SetCulture(string culture)
{
var selector = new SelectElement(Browser.FindElement(By.Id("culture-selector")));
selector.SelectByValue(culture);
// Click the link to return back to the test page
Browser.Exists(By.ClassName("return-from-culture-setter")).Click();
// That should have triggered a page load, so wait for the main test selector to come up.
Browser.MountTestComponent<GlobalizationBindCases>();
Browser.Exists(By.Id("globalization-cases"));
var cultureDisplay = Browser.Exists(By.Id("culture-name-display"));
Assert.Equal($"Culture is: {culture}", cultureDisplay.Text);
}
}
}

View File

@ -13,11 +13,9 @@ using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
{
// For now this is limited to server-side execution because we don't have the ability to set the
// culture in client-side Blazor.
public class LocalizationTest : ServerTestBase<BasicTestAppServerSiteFixture<InternationalizationStartup>>
public class ServerLocalizationTest : ServerTestBase<BasicTestAppServerSiteFixture<InternationalizationStartup>>
{
public LocalizationTest(
public ServerLocalizationTest(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<InternationalizationStartup> serverFixture,
ITestOutputHelper output)

View File

@ -3,36 +3,31 @@
using System;
using System.Globalization;
using BasicTestApp;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using OpenQA.Selenium.Support.UI;
using TestServer;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
namespace Microsoft.AspNetCore.Components.E2ETests.Tests
{
// For now this is limited to server-side execution because we don't have the ability to set the
// culture in client-side Blazor.
public class GlobalizationTest : ServerTestBase<BasicTestAppServerSiteFixture<InternationalizationStartup>>
public abstract class GlobalizationTest<TServerFixture> : ServerTestBase<TServerFixture>
where TServerFixture : ServerFixture
{
public GlobalizationTest(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<InternationalizationStartup> serverFixture,
ITestOutputHelper output)
public GlobalizationTest(BrowserFixture browserFixture, TServerFixture serverFixture, ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
}
protected override void InitializeAsyncCore()
{
Navigate(ServerPathBase);
Browser.MountTestComponent<CulturePicker>();
Browser.Exists(By.Id("culture-selector"));
}
protected abstract void SetCulture(string culture);
/// <summary>
/// Blazor Server and these tests will use the application's UtcOffset when calculating DateTimeOffset when
/// an offset is not explicitly specified. Blazor WASM always calculates DateTimeOffsets as Utc.
/// We'll use <see cref="UtcOffset"/> to express this difference in calculating expected values in these tests.
/// </summary>
protected abstract TimeSpan UtcOffset { get; }
[Theory]
[InlineData("en-US")]
@ -74,11 +69,11 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
// datetimeoffset
input = Browser.FindElement(By.Id("input_type_text_datetimeoffset"));
display = Browser.FindElement(By.Id("input_type_text_datetimeoffset_value"));
Browser.Equal(new DateTimeOffset(new DateTime(1985, 3, 4)).ToString(cultureInfo), () => display.Text);
Browser.Equal(new DateTimeOffset(new DateTime(1985, 3, 4), UtcOffset).ToString(cultureInfo), () => display.Text);
input.ReplaceText(new DateTimeOffset(new DateTime(2000, 1, 2)).ToString(cultureInfo));
input.ReplaceText(new DateTimeOffset(new DateTime(2000, 1, 2), UtcOffset).ToString(cultureInfo));
input.SendKeys("\t");
Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2)).ToString(cultureInfo), () => display.Text);
Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2), UtcOffset).ToString(cultureInfo), () => display.Text);
}
// The logic is different for verifying culture-invariant fields. The problem is that the logic for what
@ -145,13 +140,13 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
input = Browser.FindElement(By.Id("input_type_date_datetimeoffset"));
display = Browser.FindElement(By.Id("input_type_date_datetimeoffset_value"));
extraInput = Browser.FindElement(By.Id("input_type_date_datetimeoffset_extrainput"));
Browser.Equal(new DateTimeOffset(new DateTime(1985, 3, 4)).ToString(cultureInfo), () => display.Text);
Browser.Equal(new DateTimeOffset(new DateTime(1985, 3, 4)).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), () => input.GetAttribute("value"));
Browser.Equal(new DateTimeOffset(new DateTime(1985, 3, 4), UtcOffset).ToString(cultureInfo), () => display.Text);
Browser.Equal(new DateTimeOffset(new DateTime(1985, 3, 4), UtcOffset).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), () => input.GetAttribute("value"));
extraInput.ReplaceText(new DateTimeOffset(new DateTime(2000, 1, 2)).ToString(cultureInfo));
extraInput.ReplaceText(new DateTimeOffset(new DateTime(2000, 1, 2), UtcOffset).ToString(cultureInfo));
extraInput.SendKeys("\t");
Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2)).ToString(cultureInfo), () => display.Text);
Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2)).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), () => input.GetAttribute("value"));
Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2), UtcOffset).ToString(cultureInfo), () => display.Text);
Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2), UtcOffset).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), () => input.GetAttribute("value"));
}
[Theory]
@ -214,29 +209,13 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
input = Browser.FindElement(By.Id("inputdate_datetimeoffset"));
display = Browser.FindElement(By.Id("inputdate_datetimeoffset_value"));
extraInput = Browser.FindElement(By.Id("inputdate_datetimeoffset_extrainput"));
Browser.Equal(new DateTimeOffset(new DateTime(1985, 3, 4)).ToString(cultureInfo), () => display.Text);
Browser.Equal(new DateTimeOffset(new DateTime(1985, 3, 4)).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), () => input.GetAttribute("value"));
Browser.Equal(new DateTimeOffset(new DateTime(1985, 3, 4), UtcOffset).ToString(cultureInfo), () => display.Text);
Browser.Equal(new DateTimeOffset(new DateTime(1985, 3, 4), UtcOffset).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), () => input.GetAttribute("value"));
extraInput.ReplaceText(new DateTimeOffset(new DateTime(2000, 1, 2)).ToString(cultureInfo));
extraInput.ReplaceText(new DateTimeOffset(new DateTime(2000, 1, 2), UtcOffset).ToString(cultureInfo));
extraInput.SendKeys("\t");
Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2)).ToString(cultureInfo), () => display.Text);
Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2)).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), () => input.GetAttribute("value"));
}
private void SetCulture(string culture)
{
var selector = new SelectElement(Browser.FindElement(By.Id("culture-selector")));
selector.SelectByValue(culture);
// Click the link to return back to the test page
Browser.Exists(By.ClassName("return-from-culture-setter")).Click();
// That should have triggered a page load, so wait for the main test selector to come up.
Browser.MountTestComponent<GlobalizationBindCases>();
Browser.Exists(By.Id("globalization-cases"));
var cultureDisplay = Browser.Exists(By.Id("culture-name-display"));
Assert.Equal($"Culture is: {culture}", cultureDisplay.Text);
Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2), UtcOffset).ToString(cultureInfo), () => display.Text);
Browser.Equal(new DateTimeOffset(new DateTime(2000, 1, 2), UtcOffset).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), () => input.GetAttribute("value"));
}
}
}

View File

@ -0,0 +1,41 @@
// 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 BasicTestApp;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.Components.E2ETests.Tests;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
{
// For now this is limited to server-side execution because we don't have the ability to set the
// culture in client-side Blazor.
public class WebAssemblyGlobalizationTest : GlobalizationTest<ToggleExecutionModeServerFixture<Program>>
{
public WebAssemblyGlobalizationTest(
BrowserFixture browserFixture,
ToggleExecutionModeServerFixture<Program> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
}
protected override TimeSpan UtcOffset => TimeSpan.Zero;
protected override void SetCulture(string culture)
{
Navigate($"{ServerPathBase}/?culture={culture}", noReload: false);
// That should have triggered a page load, so wait for the main test selector to come up.
Browser.MountTestComponent<GlobalizationBindCases>();
Browser.Exists(By.Id("globalization-cases"));
var cultureDisplay = Browser.Exists(By.Id("culture-name-display"));
Assert.Equal($"Culture is: {culture}", cultureDisplay.Text);
}
}
}

View File

@ -0,0 +1,41 @@
// 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 BasicTestApp;
using Microsoft.AspNetCore.Components.E2ETest;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Components.E2ETest.Tests
{
public class WebAssemblyLocalizationTest : ServerTestBase<ToggleExecutionModeServerFixture<Program>>
{
public WebAssemblyLocalizationTest(
BrowserFixture browserFixture,
ToggleExecutionModeServerFixture<Program> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
}
[Theory]
[InlineData("en-US", "Hello!")]
[InlineData("fr-FR", "Bonjour!")]
public void CanSetCultureAndReadLocalizedResources(string culture, string message)
{
Navigate($"{ServerPathBase}/?culture={culture}", noReload: false);
Browser.MountTestComponent<LocalizedText>();
var cultureDisplay = Browser.Exists(By.Id("culture-name-display"));
Assert.Equal($"Culture is: {culture}", cultureDisplay.Text);
var messageDisplay = Browser.FindElement(By.Id("message-display"));
Assert.Equal(message, messageDisplay.Text);
}
}
}

View File

@ -7,7 +7,9 @@ using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Web;
using BasicTestApp.AuthTest;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.Components.WebAssembly.Http;
@ -23,9 +25,6 @@ namespace BasicTestApp
{
await SimulateErrorsIfNeededForTest();
// We want the culture to be en-US so that the tests for bind can work consistently.
CultureInfo.CurrentCulture = new CultureInfo("en-US");
var builder = WebAssemblyHostBuilder.CreateDefault(args);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("WEBASSEMBLY")))
@ -55,7 +54,36 @@ namespace BasicTestApp
return new PrependMessageLoggerFactory("Custom logger", originalLogger);
});
await builder.Build().RunAsync();
var host = builder.Build();
ConfigureCulture(host);
await host.RunAsync();
}
private static void ConfigureCulture(WebAssemblyHost host)
{
// In the absence of a specified value, we want the culture to be en-US so that the tests for bind can work consistently.
var culture = new CultureInfo("en-US");
Uri uri = null;
try
{
uri = new Uri(host.Services.GetService<NavigationManager>().Uri);
}
catch (ArgumentException)
{
// Some of our tests set this application up incorrectly so that querying NavigationManager.Uri throws.
}
if (uri != null && HttpUtility.ParseQueryString(uri.Query)["culture"] is string cultureName)
{
culture = new CultureInfo(cultureName);
}
// CultureInfo.CurrentCulture is async-scoped and will not affect the culture in sibling scopes.
// Use CultureInfo.DefaultThreadCurrentCulture instead to modify the application's default scope.
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
}
// Supports E2E tests in StartupErrorNotificationTest