diff --git a/src/DefaultBuilder/ref/Microsoft.AspNetCore.csproj b/src/DefaultBuilder/ref/Microsoft.AspNetCore.csproj index 12795e2033..dd42476a01 100644 --- a/src/DefaultBuilder/ref/Microsoft.AspNetCore.csproj +++ b/src/DefaultBuilder/ref/Microsoft.AspNetCore.csproj @@ -17,6 +17,7 @@ + diff --git a/src/DefaultBuilder/ref/Microsoft.AspNetCore.netcoreapp3.0.cs b/src/DefaultBuilder/ref/Microsoft.AspNetCore.netcoreapp3.0.cs index 1737054dc0..e5a76886a4 100644 --- a/src/DefaultBuilder/ref/Microsoft.AspNetCore.netcoreapp3.0.cs +++ b/src/DefaultBuilder/ref/Microsoft.AspNetCore.netcoreapp3.0.cs @@ -3,6 +3,10 @@ namespace Microsoft.AspNetCore { + public static partial class StaticWebAssetsWebHostBuilderExtensions + { + public static Microsoft.AspNetCore.Hosting.IWebHostBuilder UseStaticWebAssets(this Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) { throw null; } + } public static partial class WebHost { public static Microsoft.AspNetCore.Hosting.IWebHostBuilder CreateDefaultBuilder() { throw null; } diff --git a/src/DefaultBuilder/src/Microsoft.AspNetCore.csproj b/src/DefaultBuilder/src/Microsoft.AspNetCore.csproj index d02c88f673..76a27b9a71 100644 --- a/src/DefaultBuilder/src/Microsoft.AspNetCore.csproj +++ b/src/DefaultBuilder/src/Microsoft.AspNetCore.csproj @@ -21,6 +21,7 @@ + diff --git a/src/DefaultBuilder/src/Properties/AssemblyInfo.cs b/src/DefaultBuilder/src/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..97ac339e54 --- /dev/null +++ b/src/DefaultBuilder/src/Properties/AssemblyInfo.cs @@ -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("Microsoft.AspNetCore.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/DefaultBuilder/src/StaticWebAssetsFileProvider.cs b/src/DefaultBuilder/src/StaticWebAssetsFileProvider.cs new file mode 100644 index 0000000000..99919f1a1c --- /dev/null +++ b/src/DefaultBuilder/src/StaticWebAssetsFileProvider.cs @@ -0,0 +1,77 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.InteropServices; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore +{ + // A file provider used for serving static web assets from referenced projects and packages during development. + // The file provider maps folders from referenced projects and packages and prepends a prefix to their relative + // paths. + // At publish time the assets end up in the wwwroot folder of the published app under the prefix indicated here + // as the base path. + // For example, for a referenced project mylibrary with content under <>\wwwroot will expose + // static web assets under _content/mylibrary (this is by convention). The path prefix or base path we apply + // is that (_content/mylibrary). + // when the app gets published, the build pipeline puts the static web assets for mylibrary under + // publish/wwwroot/_content/mylibrary/sample-asset.js + // To allow for the same experience during development, StaticWebAssetsFileProvider maps the contents of + // <>\wwwroot\** to _content/mylibrary/** + internal class StaticWebAssetsFileProvider : IFileProvider + { + private static readonly StringComparison FilePathComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + StringComparison.OrdinalIgnoreCase : + StringComparison.Ordinal; + + public StaticWebAssetsFileProvider(string pathPrefix, string contentRoot) + { + BasePath = new PathString(pathPrefix.StartsWith("/") ? pathPrefix : "/" + pathPrefix); + InnerProvider = new PhysicalFileProvider(contentRoot); + } + + public PhysicalFileProvider InnerProvider { get; } + + public PathString BasePath { get; } + + /// + public IDirectoryContents GetDirectoryContents(string subpath) + { + if (!StartsWithBasePath(subpath, out var physicalPath)) + { + return NotFoundDirectoryContents.Singleton; + } + else + { + return InnerProvider.GetDirectoryContents(physicalPath); + } + } + + /// + public IFileInfo GetFileInfo(string subpath) + { + if (!StartsWithBasePath(subpath, out var physicalPath)) + { + return new NotFoundFileInfo(subpath); + } + else + { + return InnerProvider.GetFileInfo(physicalPath); + } + } + + /// + public IChangeToken Watch(string filter) + { + return InnerProvider.Watch(filter); + } + + private bool StartsWithBasePath(string subpath, out PathString rest) + { + return new PathString(subpath).StartsWithSegments(BasePath, FilePathComparison, out rest); + } + } +} diff --git a/src/DefaultBuilder/src/StaticWebAssetsLoader.cs b/src/DefaultBuilder/src/StaticWebAssetsLoader.cs new file mode 100644 index 0000000000..05ee714f0c --- /dev/null +++ b/src/DefaultBuilder/src/StaticWebAssetsLoader.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.AspNetCore +{ + internal class StaticWebAssetsLoader + { + internal const string StaticWebAssetsManifestName = "Microsoft.AspNetCore.StaticWebAssets.xml"; + + internal static void UseStaticWebAssets(IWebHostEnvironment environment) + { + using (var manifest = ResolveManifest(environment)) + { + if (manifest != null) + { + UseStaticWebAssetsCore(environment, manifest); + } + } + } + + internal static void UseStaticWebAssetsCore(IWebHostEnvironment environment, Stream manifest) + { + var staticWebAssetsFileProvider = new List(); + var webRootFileProvider = environment.WebRootFileProvider; + + var additionalFiles = StaticWebAssetsReader.Parse(manifest) + .Select(cr => new StaticWebAssetsFileProvider(cr.BasePath, cr.Path)) + .OfType() // Upcast so we can insert on the resulting list. + .ToList(); + + if (additionalFiles.Count == 0) + { + return; + } + else + { + additionalFiles.Insert(0, webRootFileProvider); + environment.WebRootFileProvider = new CompositeFileProvider(additionalFiles); + } + } + + internal static Stream ResolveManifest(IWebHostEnvironment environment) + { + // We plan to remove the embedded file resolution code path in + // a future preview. + Assembly assembly = null; + try + { + assembly = Assembly.Load(environment.ApplicationName); + } + catch (Exception) + { + } + + if (assembly != null && assembly.GetManifestResourceNames().Any(a => a == StaticWebAssetsManifestName)) + { + return assembly.GetManifestResourceStream(StaticWebAssetsManifestName); + } + else + { + // Fallback to physical file as we plan to use a file on disk instead of the embedded resource. + var filePath = Path.Combine(Path.GetDirectoryName(GetAssemblyLocation(assembly)), $"{environment.ApplicationName}.StaticWebAssets.xml"); + if (File.Exists(filePath)) + { + return File.OpenRead(filePath); + } + else + { + // A missing manifest might simply mean that the feature is not enabled, so we simply + // return early. Misconfigurations will be uncommon given that the entire process is automated + // at build time. + return null; + } + } + } + + internal static string GetAssemblyLocation(Assembly assembly) + { + if (Uri.TryCreate(assembly.CodeBase, UriKind.Absolute, out var result) && + result.IsFile && string.IsNullOrWhiteSpace(result.Fragment)) + { + return result.LocalPath; + } + + return assembly.Location; + } + } +} diff --git a/src/DefaultBuilder/src/StaticWebAssetsReader.cs b/src/DefaultBuilder/src/StaticWebAssetsReader.cs new file mode 100644 index 0000000000..82ec626945 --- /dev/null +++ b/src/DefaultBuilder/src/StaticWebAssetsReader.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml.Linq; + +namespace Microsoft.AspNetCore +{ + internal static class StaticWebAssetsReader + { + private const string ManifestRootElementName = "StaticWebAssets"; + private const string VersionAttributeName = "Version"; + private const string ContentRootElementName = "ContentRoot"; + + internal static IEnumerable Parse(Stream manifest) + { + var document = XDocument.Load(manifest); + if (!string.Equals(document.Root.Name.LocalName, ManifestRootElementName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Invalid manifest format. Manifest root must be '{ManifestRootElementName}'"); + } + + var version = document.Root.Attribute(VersionAttributeName); + if (version == null) + { + throw new InvalidOperationException($"Invalid manifest format. Manifest root element must contain a version '{VersionAttributeName}' attribute"); + } + + if (version.Value != "1.0") + { + throw new InvalidOperationException($"Unknown manifest version. Manifest version must be '1.0'"); + } + + foreach (var element in document.Root.Elements()) + { + if (!string.Equals(element.Name.LocalName, ContentRootElementName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Invalid manifest format. Invalid element '{element.Name.LocalName}'. All {StaticWebAssetsLoader.StaticWebAssetsManifestName} child elements must be '{ContentRootElementName}' elements."); + } + if (!element.IsEmpty) + { + throw new InvalidOperationException($"Invalid manifest format. {ContentRootElementName} can't have content."); + } + + var basePath = ParseRequiredAttribute(element, "BasePath"); + var path = ParseRequiredAttribute(element, "Path"); + yield return new ContentRootMapping(basePath, path); + } + } + + private static string ParseRequiredAttribute(XElement element, string attributeName) + { + var attribute = element.Attribute(attributeName); + if (attribute == null) + { + throw new InvalidOperationException($"Invalid manifest format. Missing {attributeName} attribute in '{ContentRootElementName}' element."); + } + return attribute.Value; + } + + internal readonly struct ContentRootMapping + { + public ContentRootMapping(string basePath, string path) + { + BasePath = basePath; + Path = path; + } + + public string BasePath { get; } + public string Path { get; } + } + } +} diff --git a/src/DefaultBuilder/src/StaticWebAssetsWebHostBuilderExtensions.cs b/src/DefaultBuilder/src/StaticWebAssetsWebHostBuilderExtensions.cs new file mode 100644 index 0000000000..8a754df151 --- /dev/null +++ b/src/DefaultBuilder/src/StaticWebAssetsWebHostBuilderExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Hosting; + +namespace Microsoft.AspNetCore +{ + /// + /// Extensions for configuring static web assets for development. + /// + public static class StaticWebAssetsWebHostBuilderExtensions + { + /// + /// Configures the to use static web assets + /// defined by referenced projects and packages. + /// + /// The . + /// The . + public static IWebHostBuilder UseStaticWebAssets(this IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((context, configBuilder) => + { + StaticWebAssetsLoader.UseStaticWebAssets(context.HostingEnvironment); + }); + + return builder; + } + } +} diff --git a/src/DefaultBuilder/src/WebHost.cs b/src/DefaultBuilder/src/WebHost.cs index e335931042..5e315dc5d1 100644 --- a/src/DefaultBuilder/src/WebHost.cs +++ b/src/DefaultBuilder/src/WebHost.cs @@ -206,6 +206,13 @@ namespace Microsoft.AspNetCore internal static void ConfigureWebDefaults(IWebHostBuilder builder) { + builder.ConfigureAppConfiguration((ctx, cb) => + { + if (ctx.HostingEnvironment.IsDevelopment()) + { + StaticWebAssetsLoader.UseStaticWebAssets(ctx.HostingEnvironment); + } + }); builder.UseKestrel((builderContext, options) => { options.Configure(builderContext.Configuration.GetSection("Kestrel")); diff --git a/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/Microsoft.AspNetCore.TestHost.StaticWebAssets.xml b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/Microsoft.AspNetCore.TestHost.StaticWebAssets.xml new file mode 100644 index 0000000000..f10b7c1a1d --- /dev/null +++ b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/Microsoft.AspNetCore.TestHost.StaticWebAssets.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/Microsoft.AspNetCore.Tests.csproj b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/Microsoft.AspNetCore.Tests.csproj index d23ae11780..6a1ff8c07d 100644 --- a/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/Microsoft.AspNetCore.Tests.csproj +++ b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/Microsoft.AspNetCore.Tests.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.0 @@ -7,6 +7,8 @@ + + diff --git a/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/StaticWebAssets/StaticWebAssetsFileProviderTests.cs b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/StaticWebAssets/StaticWebAssetsFileProviderTests.cs new file mode 100644 index 0000000000..f0066ea305 --- /dev/null +++ b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/StaticWebAssets/StaticWebAssetsFileProviderTests.cs @@ -0,0 +1,88 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using Xunit; + +namespace Microsoft.AspNetCore.Tests +{ + public class StaticWebAssetsFileProviderTests + { + [Fact] + public void StaticWebAssetsFileProvider_ConstructorThrows_WhenPathIsNotFound() + { + // Arrange, Act & Assert + var provider = Assert.Throws(() => new StaticWebAssetsFileProvider("/prefix", "/nonexisting")); + } + + [Fact] + public void StaticWebAssetsFileProvider_Constructor_PrependsPrefixWithSlashIfMissing() + { + // Arrange & Act + var provider = new StaticWebAssetsFileProvider("_content", AppContext.BaseDirectory); + + // Assert + Assert.Equal("/_content", provider.BasePath); + } + + [Fact] + public void StaticWebAssetsFileProvider_Constructor_DoesNotPrependPrefixWithSlashIfPresent() + { + // Arrange & Act + var provider = new StaticWebAssetsFileProvider("/_content", AppContext.BaseDirectory); + + // Assert + Assert.Equal("/_content", provider.BasePath); + } + + [Fact] + public void GetFileInfo_DoesNotMatch_IncompletePrefixSegments() + { + // Arrange + var expectedResult = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var provider = new StaticWebAssetsFileProvider( + "_cont", + Path.GetDirectoryName(new Uri(typeof(StaticWebAssetsFileProviderTests).Assembly.CodeBase).LocalPath)); + + // Act + var file = provider.GetFileInfo("/_content/Microsoft.AspNetCore.TestHost.StaticWebAssets.xml"); + + // Assert + Assert.False(file.Exists, "File exists"); + } + + [Fact] + public void GetFileInfo_Prefix_RespectsOsCaseSensitivity() + { + // Arrange + var expectedResult = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var provider = new StaticWebAssetsFileProvider( + "_content", + Path.GetDirectoryName(new Uri(typeof(StaticWebAssetsFileProviderTests).Assembly.CodeBase).LocalPath)); + + // Act + var file = provider.GetFileInfo("/_CONTENT/Microsoft.AspNetCore.TestHost.StaticWebAssets.xml"); + + // Assert + Assert.Equal(expectedResult, file.Exists); + } + + [Fact] + public void GetDirectoryContents_Prefix_RespectsOsCaseSensitivity() + { + // Arrange + var expectedResult = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var provider = new StaticWebAssetsFileProvider( + "_content", + Path.GetDirectoryName(new Uri(typeof(StaticWebAssetsFileProviderTests).Assembly.CodeBase).LocalPath)); + + // Act + var directory = provider.GetDirectoryContents("/_CONTENT"); + + // Assert + Assert.Equal(expectedResult, directory.Exists); + } + } +} diff --git a/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/StaticWebAssets/StaticWebAssetsLoaderTests.cs b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/StaticWebAssets/StaticWebAssetsLoaderTests.cs new file mode 100644 index 0000000000..bed656a259 --- /dev/null +++ b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/StaticWebAssets/StaticWebAssetsLoaderTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.AspNetCore.Hosting.Internal; +using Microsoft.Extensions.FileProviders; +using Xunit; + +namespace Microsoft.AspNetCore.Tests +{ + public class StaticWebAssetsLoaderTests + { + [Fact] + public void UseStaticWebAssetsCore_CreatesCompositeRoot_WhenThereAreContentRootsInTheManifest() + { + // Arrange + var manifestContent = @$" + +"; + + var manifest = CreateManifest(manifestContent); + var originalRoot = new NullFileProvider(); + var environment = new HostingEnvironment() + { + WebRootFileProvider = originalRoot + }; + + // Act + StaticWebAssetsLoader.UseStaticWebAssetsCore(environment, manifest); + + // Assert + var composite = Assert.IsType(environment.WebRootFileProvider); + Assert.Equal(2, composite.FileProviders.Count()); + Assert.Equal(originalRoot, composite.FileProviders.First()); + } + + [Fact] + public void UseStaticWebAssetsCore_DoesNothing_WhenManifestDoesNotContainEntries() + { + // Arrange + var manifestContent = @$" +"; + + var manifest = CreateManifest(manifestContent); + var originalRoot = new NullFileProvider(); + var environment = new HostingEnvironment() + { + WebRootFileProvider = originalRoot + }; + + // Act + StaticWebAssetsLoader.UseStaticWebAssetsCore(environment, manifest); + + // Assert + Assert.Equal(originalRoot, environment.WebRootFileProvider); + } + + [Fact] + public void ResolveManifest_FindsEmbeddedManifestProvider() + { + // Arrange + var expectedManifest = @" + + +"; + var originalRoot = new NullFileProvider(); + var environment = new HostingEnvironment() + { + ApplicationName = typeof(StaticWebAssetsReaderTests).Assembly.GetName().Name + }; + + // Act + var manifest = StaticWebAssetsLoader.ResolveManifest(environment); + + // Assert + Assert.Equal(expectedManifest, new StreamReader(manifest).ReadToEnd()); + } + + [Fact] + public void ResolveManifest_ManifestFromFile() + { + // Arrange + var expectedManifest = @" + + +"; + + var environment = new HostingEnvironment() + { + ApplicationName = "Microsoft.AspNetCore.TestHost" + }; + + // Act + var manifest = StaticWebAssetsLoader.ResolveManifest(environment); + + // Assert + Assert.Equal(expectedManifest, new StreamReader(manifest).ReadToEnd()); + } + + private Stream CreateManifest(string manifestContent) + { + return new MemoryStream(Encoding.UTF8.GetBytes(manifestContent)); + } + } +} diff --git a/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/StaticWebAssets/StaticWebAssetsReaderTests.cs b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/StaticWebAssets/StaticWebAssetsReaderTests.cs new file mode 100644 index 0000000000..1adffad46c --- /dev/null +++ b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/StaticWebAssets/StaticWebAssetsReaderTests.cs @@ -0,0 +1,147 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using Xunit; + +namespace Microsoft.AspNetCore.Tests +{ + public class StaticWebAssetsReaderTests + { + [Fact] + public void ParseManifest_ThrowsFor_EmptyManifest() + { + // Arrange + var manifestContent = @""; + var manifest = CreateManifest(manifestContent); + + // Act & Assert + var exception = Assert.Throws(() => StaticWebAssetsReader.Parse(manifest).ToArray()); + Assert.StartsWith("Root element is missing.", exception.Message); + } + + [Fact] + public void ParseManifest_ThrowsFor_UnknownRootElement() + { + // Arrange + var manifestContent = @""; + var manifest = CreateManifest(manifestContent); + + // Act & Assert + var exception = Assert.Throws(() => StaticWebAssetsReader.Parse(manifest).ToArray()); + Assert.StartsWith("Invalid manifest", exception.Message); + } + + [Fact] + public void ParseManifest_ThrowsFor_MissingVersion() + { + // Arrange + var manifestContent = @""; + var manifest = CreateManifest(manifestContent); + + // Act & Assert + var exception = Assert.Throws(() => StaticWebAssetsReader.Parse(manifest).ToArray()); + Assert.StartsWith("Invalid manifest", exception.Message); + } + + [Fact] + public void ParseManifest_ThrowsFor_UnknownVersion() + { + // Arrange + var manifestContent = @""; + var manifest = CreateManifest(manifestContent); + + // Act & Assert + var exception = Assert.Throws(() => StaticWebAssetsReader.Parse(manifest).ToArray()); + Assert.StartsWith("Unknown manifest version", exception.Message); + } + + [Fact] + public void ParseManifest_ThrowsFor_InvalidStaticWebAssetsChildren() + { + // Arrange + var manifestContent = @" + +"; + var manifest = CreateManifest(manifestContent); + + // Act & Assert + var exception = Assert.Throws(() => StaticWebAssetsReader.Parse(manifest).ToArray()); + Assert.StartsWith("Invalid manifest", exception.Message); + } + + [Fact] + public void ParseManifest_ThrowsFor_MissingBasePath() + { + // Arrange + var manifestContent = @" + +"; + + var manifest = CreateManifest(manifestContent); + + // Act & Assert + var exception = Assert.Throws(() => StaticWebAssetsReader.Parse(manifest).ToArray()); + Assert.StartsWith("Invalid manifest", exception.Message); + } + + [Fact] + public void ParseManifest_ThrowsFor_MissingPath() + { + // Arrange + var manifestContent = @" + +"; + + var manifest = CreateManifest(manifestContent); + + // Act & Assert + var exception = Assert.Throws(() => StaticWebAssetsReader.Parse(manifest).ToArray()); + Assert.StartsWith("Invalid manifest", exception.Message); + } + + [Fact] + public void ParseManifest_ThrowsFor_ChildContentRootContent() + { + // Arrange + var manifestContent = @" + + +"; + + var manifest = CreateManifest(manifestContent); + + // Act & Assert + var exception = Assert.Throws(() => StaticWebAssetsReader.Parse(manifest).ToArray()); + Assert.StartsWith("Invalid manifest", exception.Message); + } + + [Fact] + public void ParseManifest_ParsesManifest_WithSingleItem() + { + // Arrange + var manifestContent = @" + +"; + + var manifest = CreateManifest(manifestContent); + + // Act + var mappings = StaticWebAssetsReader.Parse(manifest).ToArray(); + + // Assert + var mapping = Assert.Single(mappings); + Assert.Equal("/Path", mapping.Path); + Assert.Equal("/BasePath", mapping.BasePath); + } + + private Stream CreateManifest(string manifestContent) + { + return new MemoryStream(Encoding.UTF8.GetBytes(manifestContent)); + } + } +}