diff --git a/src/WebOptimizer.Core/Taghelpers/LinkInlineHrefTagHelper.cs b/src/WebOptimizer.Core/Taghelpers/LinkInlineHrefTagHelper.cs index 6ed6143..78d5560 100644 --- a/src/WebOptimizer.Core/Taghelpers/LinkInlineHrefTagHelper.cs +++ b/src/WebOptimizer.Core/Taghelpers/LinkInlineHrefTagHelper.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Options; namespace WebOptimizer.Taghelpers @@ -92,11 +93,12 @@ private async Task GetFileContentAsync(string route) } string cleanRoute = route.TrimStart('~'); - string file = HostingEnvironment.WebRootFileProvider.GetFileInfo(cleanRoute).PhysicalPath; + IFileInfo file = HostingEnvironment.WebRootFileProvider.GetFileInfo(cleanRoute); - if (File.Exists(file)) + if (file.Exists) { - using (StreamReader reader = File.OpenText(file)) + using (Stream stream = file.CreateReadStream()) + using (StreamReader reader = new(stream)) { content = await reader.ReadToEndAsync(); AddToCache(cacheKey, content, HostingEnvironment.WebRootFileProvider, cleanRoute); diff --git a/src/WebOptimizer.Core/Taghelpers/ScriptInlineSrcTagHelper.cs b/src/WebOptimizer.Core/Taghelpers/ScriptInlineSrcTagHelper.cs index 7a9d7f1..b8c91f9 100644 --- a/src/WebOptimizer.Core/Taghelpers/ScriptInlineSrcTagHelper.cs +++ b/src/WebOptimizer.Core/Taghelpers/ScriptInlineSrcTagHelper.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Options; namespace WebOptimizer.Taghelpers @@ -83,11 +84,12 @@ protected async Task GetFileContentAsync(string route) } string cleanRoute = route.TrimStart('~'); - string file = HostingEnvironment.WebRootFileProvider.GetFileInfo(cleanRoute).PhysicalPath; + IFileInfo file = HostingEnvironment.WebRootFileProvider.GetFileInfo(cleanRoute); - if (File.Exists(file)) + if (file.Exists) { - using (StreamReader reader = File.OpenText(file)) + using (Stream stream = file.CreateReadStream()) + using (StreamReader reader = new(stream)) { content = await reader.ReadToEndAsync(); AddToCache(cacheKey, content, HostingEnvironment.WebRootFileProvider, cleanRoute); diff --git a/test/WebOptimizer.Core.Test/Mocks/MockChangeToken.cs b/test/WebOptimizer.Core.Test/Mocks/MockChangeToken.cs new file mode 100644 index 0000000..b783554 --- /dev/null +++ b/test/WebOptimizer.Core.Test/Mocks/MockChangeToken.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.Extensions.Primitives; + +namespace WebOptimizer.Core.Test.Mocks +{ + public class MockChangeToken : IChangeToken + { + public bool ActiveChangeCallbacks => false; + + public bool HasChanged => false; + + public IDisposable RegisterChangeCallback(Action callback, object state) + { + return NoopDisposable.Instance; + } + + private class NoopDisposable : IDisposable + { + public static readonly NoopDisposable Instance = new(); + + public void Dispose() + { + } + } + } +} diff --git a/test/WebOptimizer.Core.Test/Mocks/MockDirectoryContents.cs b/test/WebOptimizer.Core.Test/Mocks/MockDirectoryContents.cs new file mode 100644 index 0000000..b7b0f65 --- /dev/null +++ b/test/WebOptimizer.Core.Test/Mocks/MockDirectoryContents.cs @@ -0,0 +1,27 @@ +using System.Collections; +using System.Collections.Generic; +using Microsoft.Extensions.FileProviders; + +namespace WebOptimizer.Core.Test.Mocks +{ + public class MockDirectoryContents : IDirectoryContents + { + private readonly IEnumerable _files; + + public MockDirectoryContents(IEnumerable files) + { + _files = files; + } + + public bool Exists => true; + + public IEnumerator GetEnumerator() + { + return _files.GetEnumerator(); + } + IEnumerator IEnumerable.GetEnumerator() + { + return _files.GetEnumerator(); + } + } +} diff --git a/test/WebOptimizer.Core.Test/Mocks/MockFileInfo.cs b/test/WebOptimizer.Core.Test/Mocks/MockFileInfo.cs index e7f2450..cff5667 100644 --- a/test/WebOptimizer.Core.Test/Mocks/MockFileInfo.cs +++ b/test/WebOptimizer.Core.Test/Mocks/MockFileInfo.cs @@ -12,24 +12,42 @@ internal class MockFileInfo : IFileInfo { private readonly byte[] _data; - public MockFileInfo(string name, DateTimeOffset lastModified, byte[] data) + public MockFileInfo(string fileName, DateTimeOffset lastModified, byte[] data) { _data = data; - Name = name; + Name = fileName; LastModified = lastModified; } + private MockFileInfo(string directoryName, DateTimeOffset lastModified, IList files = null) + { + _data = null; + Name = directoryName; + LastModified = lastModified; + IsDirectory = true; + Files = files; + } + + /// + /// Creates a new instance of representing a directory. + /// + public static MockFileInfo CreateDirectory(string directoryName, DateTimeOffset lastModified, IList files) + { + return new MockFileInfo(directoryName, lastModified, files); + } + public Stream CreateReadStream() { return new MemoryStream(_data, false); } public bool Exists => true; - public bool IsDirectory => false; + public bool IsDirectory { get; } public DateTimeOffset LastModified { get; } public long Length => _data.Length; public string Name { get; } public string PhysicalPath => null; + public IList Files { get; } } } diff --git a/test/WebOptimizer.Core.Test/Mocks/MockFileProvider.cs b/test/WebOptimizer.Core.Test/Mocks/MockFileProvider.cs new file mode 100644 index 0000000..844eede --- /dev/null +++ b/test/WebOptimizer.Core.Test/Mocks/MockFileProvider.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace WebOptimizer.Core.Test.Mocks +{ + // grabbed the logic from https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.TagHelpers/test/GlobbingUrlBuilderTest.cs + // and made it recursive + internal class MockFileProvider : IFileProvider + { + private readonly MockChangeToken _changeToken = new(); + private readonly Dictionary _files; + private readonly Dictionary _directories; + + private MockFileProvider(Dictionary files, Dictionary directories) + { + _files = files; + _directories = directories; + } + + public static IFileProvider Create(MockFileInfo rootNode) + { + if (rootNode.Files == null || !rootNode.Files.Any()) + { + throw new ArgumentException($"{nameof(rootNode)} must have children.", nameof(rootNode)); + } + + Dictionary files = []; + Dictionary directories = []; + + var stack = new Stack<(MockFileInfo fileInfo, string directoryPath)>(); + stack.Push((rootNode, string.Empty)); + + while (stack.Count > 0) + { + var (fileInfo, directoryPath) = stack.Pop(); + + if (fileInfo.IsDirectory) + { + var children = fileInfo.Files; + + var fullPath = string.IsNullOrEmpty(directoryPath) ? fileInfo.Name : directoryPath + "/" + fileInfo.Name; + + directories[fullPath] = new MockDirectoryContents(children); + + foreach (var child in children) + { + stack.Push((child, fullPath)); + } + } + else + { + var fullPath = string.IsNullOrEmpty(directoryPath) ? fileInfo.Name : directoryPath + "/" + fileInfo.Name; + files[fullPath] = fileInfo; + files["/" + fullPath] = fileInfo; + } + } + + return new MockFileProvider(files, directories); + } + + public IFileInfo GetFileInfo(string subpath) + { + return _files.TryGetValue(subpath, out var fileInfo) ? fileInfo : new NotFoundFileInfo(subpath); + } + + public IDirectoryContents GetDirectoryContents(string subpath) + { + return _directories.TryGetValue(subpath, out var directoryContents) ? directoryContents : new NotFoundDirectoryContents(); + } + + public IChangeToken Watch(string filter) + { + return _changeToken; + } + } +} diff --git a/test/WebOptimizer.Core.Test/TagHelpers/LinkInlineHrefTagHelperTest.cs b/test/WebOptimizer.Core.Test/TagHelpers/LinkInlineHrefTagHelperTest.cs new file mode 100644 index 0000000..05fc92f --- /dev/null +++ b/test/WebOptimizer.Core.Test/TagHelpers/LinkInlineHrefTagHelperTest.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; +using Moq; +using WebOptimizer.Core.Test.Mocks; +using WebOptimizer.Taghelpers; +using Xunit; + +namespace WebOptimizer.Core.Test.TagHelpers +{ + public class LinkInlineHrefTagHelperTest + { + /// + /// Tests that inlines the file content read through + /// , even when the file provider exposes no + /// (e.g. embedded or composite providers). + /// + [Fact2] + public async Task InlineHref_FileProviderWithoutPhysicalPath_InlinesContent() + { + var date = new DateTime(2017, 1, 1); + var content = "body { background-color: red; }"; + var root = + MockFileInfo.CreateDirectory("", date, + [ + new MockFileInfo("test.css", date, Encoding.UTF8.GetBytes(content)), + ]); + var fileProvider = MockFileProvider.Create(root); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider).Returns(fileProvider); + var cache = new Mock(); + cache.Setup(c => c.CreateEntry(It.IsAny())) + .Returns((object key) => + { + var cacheEntry = new Mock(); + cacheEntry.Setup(ce => ce.ExpirationTokens).Returns([]); + + return cacheEntry.Object; + }); + + var options = new WebOptimizerOptions(); + var optionsMonitor = new Mock>(); + optionsMonitor.Setup(x => x.CurrentValue).Returns(options); + + var route = "/test.css"; + IAsset asset; + var assetPipeline = new Mock(); + assetPipeline.Setup(ap => ap.TryGetAssetFromRoute(It.IsAny(), out asset)).Returns(false); + + var linkTagHelper = new LinkInlineHrefTagHelper(env.Object, cache.Object, assetPipeline.Object, optionsMonitor.Object, new Mock().Object); + var viewContext = new ViewContext + { + HttpContext = new Mock().SetupAllProperties().Object + }; + linkTagHelper.ViewContext = viewContext; + linkTagHelper.Href = route; + + var tagHelperContext = new Mock( + "link", + new TagHelperAttributeList(), + new Dictionary(), + "unique"); + var attributes = new TagHelperAttributeList { new TagHelperAttribute("href", route) }; + + var tagHelperOutput = new TagHelperOutput("link", attributes, (_, _) => Task.Factory.StartNew( + () => new DefaultTagHelperContent())); + await linkTagHelper.ProcessAsync(tagHelperContext.Object, tagHelperOutput); + + Assert.Equal("style", tagHelperOutput.TagName); + Assert.Equal(TagMode.StartTagAndEndTag, tagHelperOutput.TagMode); + Assert.Equal(content, tagHelperOutput.Content.GetContent()); + + Mock.VerifyAll(env, cache, assetPipeline); + } + + /// + /// Same scenario as but + /// backed by a real over a temporary directory tree + /// instead of a mocked file provider. + /// + [Fact2] + public async Task InlineHref_PhysicalFileProvider_InlinesContent() + { + var content = "body { background-color: red; }"; + string rootPath = Path.Combine(Path.GetTempPath(), "WebOptimizerTest_" + Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(rootPath); + File.WriteAllText(Path.Combine(rootPath, "test.css"), content); + + var fileProvider = new PhysicalFileProvider(rootPath); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider).Returns(fileProvider); + var cache = new Mock(); + cache.Setup(c => c.CreateEntry(It.IsAny())) + .Returns((object key) => + { + var cacheEntry = new Mock(); + cacheEntry.Setup(ce => ce.ExpirationTokens).Returns([]); + + return cacheEntry.Object; + }); + + var options = new WebOptimizerOptions(); + var optionsMonitor = new Mock>(); + optionsMonitor.Setup(x => x.CurrentValue).Returns(options); + + var route = "/test.css"; + IAsset asset; + var assetPipeline = new Mock(); + assetPipeline.Setup(ap => ap.TryGetAssetFromRoute(It.IsAny(), out asset)).Returns(false); + + var linkTagHelper = new LinkInlineHrefTagHelper(env.Object, cache.Object, assetPipeline.Object, optionsMonitor.Object, new Mock().Object); + var viewContext = new ViewContext + { + HttpContext = new Mock().SetupAllProperties().Object + }; + linkTagHelper.ViewContext = viewContext; + linkTagHelper.Href = route; + + var tagHelperContext = new Mock( + "link", + new TagHelperAttributeList(), + new Dictionary(), + "unique"); + var attributes = new TagHelperAttributeList { new TagHelperAttribute("href", route) }; + + var tagHelperOutput = new TagHelperOutput("link", attributes, (_, _) => Task.Factory.StartNew( + () => new DefaultTagHelperContent())); + await linkTagHelper.ProcessAsync(tagHelperContext.Object, tagHelperOutput); + + Assert.Equal("style", tagHelperOutput.TagName); + Assert.Equal(TagMode.StartTagAndEndTag, tagHelperOutput.TagMode); + Assert.Equal(content, tagHelperOutput.Content.GetContent()); + + Mock.VerifyAll(cache, assetPipeline, env); + } + finally + { + if (Directory.Exists(rootPath)) + { + Directory.Delete(rootPath, recursive: true); + } + } + } + + /// + /// Tests that throws a + /// when the referenced file does not exist in the file provider. + /// + [Fact2] + public async Task InlineHref_FileDoesNotExist_ThrowsFileNotFound() + { + var date = new DateTime(2017, 1, 1); + var root = + MockFileInfo.CreateDirectory("", date, + [ + new MockFileInfo("test.css", date, Encoding.UTF8.GetBytes("body { }")), + ]); + var fileProvider = MockFileProvider.Create(root); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider).Returns(fileProvider); + + var options = new WebOptimizerOptions(); + var optionsMonitor = new Mock>(); + optionsMonitor.Setup(x => x.CurrentValue).Returns(options); + + var route = "/missing.css"; + IAsset asset; + var assetPipeline = new Mock(); + assetPipeline.Setup(ap => ap.TryGetAssetFromRoute(It.IsAny(), out asset)).Returns(false); + + var linkTagHelper = new LinkInlineHrefTagHelper(env.Object, new Mock().Object, assetPipeline.Object, optionsMonitor.Object, new Mock().Object); + var viewContext = new ViewContext + { + HttpContext = new Mock().SetupAllProperties().Object + }; + linkTagHelper.ViewContext = viewContext; + linkTagHelper.Href = route; + + var tagHelperContext = new Mock( + "link", + new TagHelperAttributeList(), + new Dictionary(), + "unique"); + var attributes = new TagHelperAttributeList { new TagHelperAttribute("href", route) }; + + var tagHelperOutput = new TagHelperOutput("link", attributes, (_, _) => Task.Factory.StartNew( + () => new DefaultTagHelperContent())); + + await Assert.ThrowsAsync( + () => linkTagHelper.ProcessAsync(tagHelperContext.Object, tagHelperOutput)); + + Mock.VerifyAll(env, assetPipeline); + } + } +} diff --git a/test/WebOptimizer.Core.Test/TagHelpers/ScriptInlineSrcTagHelperTest.cs b/test/WebOptimizer.Core.Test/TagHelpers/ScriptInlineSrcTagHelperTest.cs new file mode 100644 index 0000000..063459b --- /dev/null +++ b/test/WebOptimizer.Core.Test/TagHelpers/ScriptInlineSrcTagHelperTest.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; +using Moq; +using WebOptimizer.Core.Test.Mocks; +using WebOptimizer.Taghelpers; +using Xunit; + +namespace WebOptimizer.Core.Test.TagHelpers +{ + public class ScriptInlineSrcTagHelperTest + { + /// + /// Tests that inlines the file content read through + /// , even when the file provider exposes no + /// (e.g. embedded or composite providers). + /// + [Fact2] + public async Task InlineSrc_FileProviderWithoutPhysicalPath_InlinesContent() + { + var date = new DateTime(2017, 1, 1); + var content = "console.log('hello');"; + var root = + MockFileInfo.CreateDirectory("", date, + [ + new MockFileInfo("test.js", date, Encoding.UTF8.GetBytes(content)), + ]); + var fileProvider = MockFileProvider.Create(root); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider).Returns(fileProvider); + var cache = new Mock(); + cache.Setup(c => c.CreateEntry(It.IsAny())) + .Returns((object key) => + { + var cacheEntry = new Mock(); + cacheEntry.Setup(ce => ce.ExpirationTokens).Returns([]); + + return cacheEntry.Object; + }); + + var options = new WebOptimizerOptions(); + var optionsMonitor = new Mock>(); + optionsMonitor.Setup(x => x.CurrentValue).Returns(options); + + var route = "/test.js"; + IAsset asset; + var assetPipeline = new Mock(); + assetPipeline.Setup(ap => ap.TryGetAssetFromRoute(It.IsAny(), out asset)).Returns(false); + + var scriptTagHelper = new ScriptInlineSrcTagHelper(env.Object, cache.Object, assetPipeline.Object, optionsMonitor.Object, new Mock().Object); + var viewContext = new ViewContext + { + HttpContext = new Mock().SetupAllProperties().Object + }; + scriptTagHelper.ViewContext = viewContext; + scriptTagHelper.Src = route; + + var tagHelperContext = new Mock( + "script", + new TagHelperAttributeList(), + new Dictionary(), + "unique"); + var attributes = new TagHelperAttributeList { new TagHelperAttribute("src", route) }; + + var tagHelperOutput = new TagHelperOutput("script", attributes, (_, _) => Task.Factory.StartNew( + () => new DefaultTagHelperContent())); + await scriptTagHelper.ProcessAsync(tagHelperContext.Object, tagHelperOutput); + + Assert.Equal(TagMode.StartTagAndEndTag, tagHelperOutput.TagMode); + Assert.Equal(content, tagHelperOutput.Content.GetContent()); + + Mock.VerifyAll(env, cache, assetPipeline); + } + + /// + /// Same scenario as but + /// backed by a real over a temporary directory tree + /// instead of a mocked file provider. + /// + [Fact2] + public async Task InlineSrc_PhysicalFileProvider_InlinesContent() + { + var content = "console.log('hello');"; + string rootPath = Path.Combine(Path.GetTempPath(), "WebOptimizerTest_" + Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(rootPath); + File.WriteAllText(Path.Combine(rootPath, "test.js"), content); + + var fileProvider = new PhysicalFileProvider(rootPath); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider).Returns(fileProvider); + var cache = new Mock(); + cache.Setup(c => c.CreateEntry(It.IsAny())) + .Returns((object key) => + { + var cacheEntry = new Mock(); + cacheEntry.Setup(ce => ce.ExpirationTokens).Returns([]); + + return cacheEntry.Object; + }); + + var options = new WebOptimizerOptions(); + var optionsMonitor = new Mock>(); + optionsMonitor.Setup(x => x.CurrentValue).Returns(options); + + var route = "/test.js"; + IAsset asset; + var assetPipeline = new Mock(); + assetPipeline.Setup(ap => ap.TryGetAssetFromRoute(It.IsAny(), out asset)).Returns(false); + + var scriptTagHelper = new ScriptInlineSrcTagHelper(env.Object, cache.Object, assetPipeline.Object, optionsMonitor.Object, new Mock().Object); + var viewContext = new ViewContext + { + HttpContext = new Mock().SetupAllProperties().Object + }; + scriptTagHelper.ViewContext = viewContext; + scriptTagHelper.Src = route; + + var tagHelperContext = new Mock( + "script", + new TagHelperAttributeList(), + new Dictionary(), + "unique"); + var attributes = new TagHelperAttributeList { new TagHelperAttribute("src", route) }; + + var tagHelperOutput = new TagHelperOutput("script", attributes, (_, _) => Task.Factory.StartNew( + () => new DefaultTagHelperContent())); + await scriptTagHelper.ProcessAsync(tagHelperContext.Object, tagHelperOutput); + + Assert.Equal(TagMode.StartTagAndEndTag, tagHelperOutput.TagMode); + Assert.Equal(content, tagHelperOutput.Content.GetContent()); + + Mock.VerifyAll(cache, assetPipeline, env); + } + finally + { + if (Directory.Exists(rootPath)) + { + Directory.Delete(rootPath, recursive: true); + } + } + } + + /// + /// Tests that throws a + /// when the referenced file does not exist in the file provider. + /// + [Fact2] + public async Task InlineSrc_FileDoesNotExist_ThrowsFileNotFound() + { + var date = new DateTime(2017, 1, 1); + var root = + MockFileInfo.CreateDirectory("", date, + [ + new MockFileInfo("test.js", date, Encoding.UTF8.GetBytes("var x = 1;")), + ]); + var fileProvider = MockFileProvider.Create(root); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider).Returns(fileProvider); + + var options = new WebOptimizerOptions(); + var optionsMonitor = new Mock>(); + optionsMonitor.Setup(x => x.CurrentValue).Returns(options); + + var route = "/missing.js"; + IAsset asset; + var assetPipeline = new Mock(); + assetPipeline.Setup(ap => ap.TryGetAssetFromRoute(It.IsAny(), out asset)).Returns(false); + + var scriptTagHelper = new ScriptInlineSrcTagHelper(env.Object, new Mock().Object, assetPipeline.Object, optionsMonitor.Object, new Mock().Object); + var viewContext = new ViewContext + { + HttpContext = new Mock().SetupAllProperties().Object + }; + scriptTagHelper.ViewContext = viewContext; + scriptTagHelper.Src = route; + + var tagHelperContext = new Mock( + "script", + new TagHelperAttributeList(), + new Dictionary(), + "unique"); + var attributes = new TagHelperAttributeList { new TagHelperAttribute("src", route) }; + + var tagHelperOutput = new TagHelperOutput("script", attributes, (_, _) => Task.Factory.StartNew( + () => new DefaultTagHelperContent())); + + await Assert.ThrowsAsync( + () => scriptTagHelper.ProcessAsync(tagHelperContext.Object, tagHelperOutput)); + + Mock.VerifyAll(env, assetPipeline); + } + } +}