diff --git a/src/WebOptimizer.Core/Asset.cs b/src/WebOptimizer.Core/Asset.cs index 4c50736..0c84262 100644 --- a/src/WebOptimizer.Core/Asset.cs +++ b/src/WebOptimizer.Core/Asset.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.TagHelpers; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; @@ -21,6 +22,11 @@ namespace WebOptimizer { internal class Asset : IAsset { + /// + /// Private const extracted from . + /// Used to join multiple patterns for that class. + /// + internal const string PatternSeparator = ","; private readonly ILogger _logger; internal const string PhysicalFilesKey = "PhysicalFiles"; private readonly object _sync = new(); @@ -50,9 +56,10 @@ public Asset(string route, string contentType, IEnumerable sourceFiles, public async Task ExecuteAsync(HttpContext context, IWebOptimizerOptions options) { var env = (IWebHostEnvironment)context.RequestServices.GetService(typeof(IWebHostEnvironment)); + var cache = (IMemoryCache)context.RequestServices.GetService(typeof(IMemoryCache)); var config = new AssetContext(context, this, options); - IEnumerable files = ExpandGlobs(this, env); + IEnumerable files = ExpandGlobs(this, env, cache); DateTime lastModified = DateTime.MinValue; @@ -84,12 +91,17 @@ public async Task ExecuteAsync(HttpContext context, IWebOptimizerOptions return config.Content.FirstOrDefault().Value; } - public static IEnumerable ExpandGlobs(IAsset asset, IWebHostEnvironment env) + public static IEnumerable ExpandGlobs(IAsset asset, IWebHostEnvironment env, IMemoryCache cache) { var files = new List(); if (asset.SourceFiles.Any()) { + var excludePattern = + asset.ExcludeFiles.Count == 0 + ? null + : string.Join(PatternSeparator, asset.ExcludeFiles); + foreach (string sourceFile in asset.SourceFiles) { var provider = asset.GetFileProvider(env, sourceFile, out string outSourceFile); @@ -103,19 +115,13 @@ public static IEnumerable ExpandGlobs(IAsset asset, IWebHostEnvironment } else { - var virtualFilePaths = provider.GetAllFiles("/"); - - var matcher = new Matcher(); - matcher.AddInclude(outSourceFile); - matcher.AddExcludePatterns(asset.ExcludeFiles); - PatternMatchingResult globbingResult = matcher.Match(virtualFilePaths); - - IEnumerable fileMatches = globbingResult.Files.Select(f => f.Path); + var globbingUrlBuilder = new GlobbingUrlBuilder(provider, cache, requestPathBase: /*context.Request.PathBase*/ null); + IEnumerable fileMatches = globbingUrlBuilder.BuildUrlList(staticUrl: null, includePattern: outSourceFile, excludePattern: excludePattern); var sourceIsRooted = outSourceFile.StartsWith('/'); - if (sourceIsRooted) + if (!sourceIsRooted) { - fileMatches = fileMatches.Select(f => "/" + f); + fileMatches = fileMatches.Select(f => f.TrimStart('/')); } if (!fileMatches.Any()) @@ -179,7 +185,7 @@ public string GenerateCacheKey(HttpContext context, IWebOptimizerOptions options if (!Items.ContainsKey(PhysicalFilesKey)) { - physicalFiles = ExpandGlobs(this, env); + physicalFiles = ExpandGlobs(this, env, cache); } else { diff --git a/src/WebOptimizer.Core/Taghelpers/LinkTagHelper.cs b/src/WebOptimizer.Core/Taghelpers/LinkTagHelper.cs index 1064eba..9a83e47 100644 --- a/src/WebOptimizer.Core/Taghelpers/LinkTagHelper.cs +++ b/src/WebOptimizer.Core/Taghelpers/LinkTagHelper.cs @@ -105,7 +105,7 @@ private void WriteIndividualTags(TagHelperOutput output, IAsset asset) attrs.Add(attr); } - IEnumerable sourceFiles = Asset.ExpandGlobs(asset, HostingEnvironment); + IEnumerable sourceFiles = Asset.ExpandGlobs(asset, HostingEnvironment, Cache); foreach (string file in sourceFiles) { diff --git a/src/WebOptimizer.Core/Taghelpers/ScriptTagHelper.cs b/src/WebOptimizer.Core/Taghelpers/ScriptTagHelper.cs index a613127..5c4f655 100644 --- a/src/WebOptimizer.Core/Taghelpers/ScriptTagHelper.cs +++ b/src/WebOptimizer.Core/Taghelpers/ScriptTagHelper.cs @@ -102,7 +102,7 @@ private void WriteIndividualTags(TagHelperOutput output, IAsset asset) attrs.Add(attr); } - IEnumerable sourceFiles = Asset.ExpandGlobs(asset, HostingEnvironment); + IEnumerable sourceFiles = Asset.ExpandGlobs(asset, HostingEnvironment, Cache); foreach (string file in sourceFiles) { diff --git a/test/WebOptimizer.Core.Test/AssetTest.cs b/test/WebOptimizer.Core.Test/AssetTest.cs index 7fdaa6e..b885671 100644 --- a/test/WebOptimizer.Core.Test/AssetTest.cs +++ b/test/WebOptimizer.Core.Test/AssetTest.cs @@ -1,12 +1,17 @@ -using System.IO; -using System.Linq; +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.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Moq; +using WebOptimizer.Core.Test.Mocks; using Xunit; namespace WebOptimizer.Test @@ -94,5 +99,314 @@ public void AssetToString() Assert.Equal(asset.Route, asset.ToString()); } + + /// + /// Tests that asset's correctly reads all files, + /// including those in nested directories, when using a glob pattern. + /// + /// Also, tests from and checks + /// if it works nicely with nested directories. + /// + [Fact2] + public async Task ExecuteAsync_GlobMatchesNestedDirectories_ReadsAllFiles() + { + var date = new DateTime(2017, 1, 1); + var root = + MockFileInfo.CreateDirectory("", date, + [ + new MockFileInfo("file1.css", date, Encoding.UTF8.GetBytes("body { background-color: red; }")), + new MockFileInfo("file2.css", date, Encoding.UTF8.GetBytes("body { background-color: blue; }")), + MockFileInfo.CreateDirectory("sub", date, + [ + new MockFileInfo("file3.css", date, Encoding.UTF8.GetBytes("body { background-color: green; }")), + ]), + ]); + var fileProvider = MockFileProvider.Create(root); + + var logger = new Mock>(); + var asset = new Asset("/all.css", "text/css", ["**/*.css"], logger.Object); + asset.Concatenate(); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider) + .Returns(fileProvider); + + var cache = new MemoryCache(new MemoryCacheOptions()); + + var context = new Mock(); + context.SetupAllProperties(); + context.Setup(c => c.RequestServices.GetService(typeof(IWebHostEnvironment))) + .Returns(env.Object); + context.Setup(c => c.RequestServices.GetService(typeof(IMemoryCache))) + .Returns(cache); + context.Setup(c => c.Response.Headers) + .Returns(new HeaderDictionary()); + + var options = new WebOptimizerOptions(); + + byte[] result = await asset.ExecuteAsync(context.Object, options); + string content = Encoding.UTF8.GetString(result); + + Assert.Contains("background-color: red", content); + Assert.Contains("background-color: blue", content); + Assert.Contains("background-color: green", content); + + Mock.VerifyAll(env, context); + } + + /// + /// Same scenario as but + /// backed by a real over a temporary directory tree + /// instead of a mocked file provider. + /// + [Fact2] + public async Task ExecuteAsync_GlobMatchesNestedDirectories_PhysicalFileProvider_ReadsAllFiles() + { + string root = Path.Combine(Path.GetTempPath(), "WebOptimizerTest_" + Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(Path.Combine(root, "sub")); + File.WriteAllText(Path.Combine(root, "file1.css"), "body { background-color: red; }"); + File.WriteAllText(Path.Combine(root, "file2.css"), "body { background-color: blue; }"); + File.WriteAllText(Path.Combine(root, "sub", "file3.css"), "body { background-color: green; }"); + + var fileProvider = new PhysicalFileProvider(root); + + var logger = new Mock>(); + var asset = new Asset("/all.css", "text/css", ["**/*.css"], logger.Object); + asset.Concatenate(); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider) + .Returns(fileProvider); + + var cache = new MemoryCache(new MemoryCacheOptions()); + + var context = new Mock(); + context.SetupAllProperties(); + context.Setup(c => c.RequestServices.GetService(typeof(IWebHostEnvironment))) + .Returns(env.Object); + context.Setup(c => c.RequestServices.GetService(typeof(IMemoryCache))) + .Returns(cache); + context.Setup(c => c.Response.Headers) + .Returns(new HeaderDictionary()); + + var options = new WebOptimizerOptions(); + + byte[] result = await asset.ExecuteAsync(context.Object, options); + string content = Encoding.UTF8.GetString(result); + + Assert.Contains("background-color: red", content); + Assert.Contains("background-color: blue", content); + Assert.Contains("background-color: green", content); + + Mock.VerifyAll(context, env); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + [Fact2] + public async Task ExecuteAsync_ExplicitSourceFiles_PreservesSourceFilesOrder() + { + var date = new DateTime(2017, 1, 1); + var root = MockFileInfo.CreateDirectory("", date, + [ + new MockFileInfo("a.css", date, Encoding.UTF8.GetBytes(".a { color: red; }")), + new MockFileInfo("b.css", date, Encoding.UTF8.GetBytes(".b { color: blue; }")), + ]); + var fileProvider = MockFileProvider.Create(root); + + var logger = new Mock>(); + var asset = new Asset("/all.css", "text/css", ["b.css", "a.css"], logger.Object); + asset.Concatenate(); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider) + .Returns(fileProvider); + + var cache = new MemoryCache(new MemoryCacheOptions()); + + var context = new Mock(); + context.SetupAllProperties(); + context.Setup(c => c.RequestServices.GetService(typeof(IWebHostEnvironment))) + .Returns(env.Object); + context.Setup(c => c.RequestServices.GetService(typeof(IMemoryCache))) + .Returns(cache); + context.Setup(c => c.Response.Headers) + .Returns(new HeaderDictionary()); + + var options = new WebOptimizerOptions(); + + byte[] result = await asset.ExecuteAsync(context.Object, options); + string content = Encoding.UTF8.GetString(result); + + int indexB = content.IndexOf(".b {", StringComparison.Ordinal); + int indexA = content.IndexOf(".a {", StringComparison.Ordinal); + Assert.True(indexB >= 0 && indexA >= 0, "both files must be bundled"); + Assert.True(indexB < indexA, "b.css must come before a.css, matching SourceFiles order"); + + Mock.VerifyAll(env, context); + } + + [Fact2] + public async Task ExecuteAsync_MultipleGlobPatterns_PreservesPatternOrder() + { + var date = new DateTime(2017, 1, 1); + var root = MockFileInfo.CreateDirectory("", date, + [ + new MockFileInfo("root.css", date, Encoding.UTF8.GetBytes(".root { color: red; }")), + MockFileInfo.CreateDirectory("sub", date, + [ + new MockFileInfo("nested.css", date, Encoding.UTF8.GetBytes(".nested { color: blue; }")), + ]), + ]); + var fileProvider = MockFileProvider.Create(root); + + var logger = new Mock>(); + var asset = new Asset("/all.css", "text/css", ["sub/*.css", "*.css"], logger.Object); + asset.Concatenate(); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider) + .Returns(fileProvider); + + var cache = new MemoryCache(new MemoryCacheOptions()); + + var context = new Mock(); + context.SetupAllProperties(); + context.Setup(c => c.RequestServices.GetService(typeof(IWebHostEnvironment))) + .Returns(env.Object); + context.Setup(c => c.RequestServices.GetService(typeof(IMemoryCache))) + .Returns(cache); + context.Setup(c => c.Response.Headers) + .Returns(new HeaderDictionary()); + + var options = new WebOptimizerOptions(); + + byte[] result = await asset.ExecuteAsync(context.Object, options); + string content = Encoding.UTF8.GetString(result); + + int indexNested = content.IndexOf(".nested {", StringComparison.Ordinal); + int indexRoot = content.IndexOf(".root {", StringComparison.Ordinal); + Assert.True(indexNested >= 0 && indexRoot >= 0, "both files must be bundled"); + Assert.True(indexNested < indexRoot, "sub/*.css pattern is listed first, so its file must come first"); + + Mock.VerifyAll(env, context); + } + + [Fact2] + public void ExpandGlobs_ExplicitSourceFiles_PreservesSourceFilesOrder() + { + var date = new DateTime(2017, 1, 1); + var root = MockFileInfo.CreateDirectory("", date, + [ + new MockFileInfo("a.css", date, Encoding.UTF8.GetBytes(".a { color: red; }")), + new MockFileInfo("b.css", date, Encoding.UTF8.GetBytes(".b { color: blue; }")), + ]); + var fileProvider = MockFileProvider.Create(root); + + var logger = new Mock>(); + var asset = new Asset("/all.css", "text/css", ["b.css", "a.css"], logger.Object); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider) + .Returns(fileProvider); + + var cache = new MemoryCache(new MemoryCacheOptions()); + + IEnumerable files = Asset.ExpandGlobs(asset, env.Object, cache); + + Assert.Equal(new[] { "b.css", "a.css" }, files); + + Mock.VerifyAll(env); + } + + [Fact2] + public void ExpandGlobs_MultipleGlobPatterns_PreservesPatternOrder() + { + var date = new DateTime(2017, 1, 1); + var root = MockFileInfo.CreateDirectory("", date, + [ + new MockFileInfo("root.css", date, Encoding.UTF8.GetBytes(".root { color: red; }")), + MockFileInfo.CreateDirectory("sub", date, + [ + new MockFileInfo("nested.css", date, Encoding.UTF8.GetBytes(".nested { color: blue; }")), + ]), + ]); + var fileProvider = MockFileProvider.Create(root); + + var logger = new Mock>(); + var asset = new Asset("/all.css", "text/css", ["sub/*.css", "*.css"], logger.Object); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider) + .Returns(fileProvider); + + var cache = new MemoryCache(new MemoryCacheOptions()); + + IEnumerable files = Asset.ExpandGlobs(asset, env.Object, cache); + + Assert.Equal(new[] { "sub/nested.css", "root.css" }, files); + + Mock.VerifyAll(env); + } + + /// + /// maps every entry to a group of + /// matched files and keeps those groups in entry order. The order *within* a glob group is an + /// implementation detail and is not asserted. + /// + [Fact2] + public void ExpandGlobs_MixedExplicitAndGlobPatterns_KeepsEntryGroupOrder() + { + var date = new DateTime(2017, 1, 1); + var root = MockFileInfo.CreateDirectory("", date, + [ + new MockFileInfo("alpha.css", date, Encoding.UTF8.GetBytes(".alpha { color: red; }")), + new MockFileInfo("zeta.css", date, Encoding.UTF8.GetBytes(".zeta { color: blue; }")), + new MockFileInfo("style-a.css", date, Encoding.UTF8.GetBytes(".style-a { color: #FF0000; }")), + new MockFileInfo("style-b.css", date, Encoding.UTF8.GetBytes(".style-b { color: #00FF00; }")), + MockFileInfo.CreateDirectory("sub", date, + [ + new MockFileInfo("inner.css", date, Encoding.UTF8.GetBytes(".inner { color: green; }")), + ]), + ]); + var fileProvider = MockFileProvider.Create(root); + + var logger = new Mock>(); + var asset = new Asset("/all.css", "text/css", ["zeta.css", "sub/*.css", "style-*.css", "alpha.css"], logger.Object); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider) + .Returns(fileProvider); + + var cache = new MemoryCache(new MemoryCacheOptions()); + + List files = [.. Asset.ExpandGlobs(asset, env.Object, cache)]; + // zeta.css, sub/inner.css, (style-a.css, style-b.css - any order), alpha.css + + Assert.Equal(5, files.Count); + + int indexZeta = files.IndexOf("zeta.css"); + int indexInner = files.IndexOf("sub/inner.css"); + int indexStyleA = files.IndexOf("style-a.css"); + int indexStyleB = files.IndexOf("style-b.css"); + int indexAlpha = files.IndexOf("alpha.css"); + + Assert.True(indexZeta < indexInner, "zeta.css group precedes sub/*.css group"); + Assert.True(indexInner < indexStyleA && indexInner < indexStyleB, + "sub/*.css group precedes style-*.css group"); + Assert.True(indexStyleA < indexAlpha && indexStyleB < indexAlpha, + "style-*.css group precedes alpha.css group"); + + Mock.VerifyAll(env); + } } } \ No newline at end of file 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/LinkTagHelperTest.cs b/test/WebOptimizer.Core.Test/TagHelpers/LinkTagHelperTest.cs index 13b4790..951f62e 100644 --- a/test/WebOptimizer.Core.Test/TagHelpers/LinkTagHelperTest.cs +++ b/test/WebOptimizer.Core.Test/TagHelpers/LinkTagHelperTest.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Html; @@ -12,6 +13,7 @@ using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Moq; +using WebOptimizer.Core.Test.Mocks; using WebOptimizer.Taghelpers; using Xunit; @@ -178,27 +180,28 @@ public void CdnUrl_RouteIsNotAsset_Success(string cdnUrl, string pathBase) [InlineData("", "/myapp")] public void CdnUrl_RouteIsAsset_TagHelperBundlingDisabled_Success(string cdnUrl, string pathBase) { - var fileInfo = new Mock(); - fileInfo.SetupGet(fi => fi.Exists).Returns(true); - var fileProvider = new Mock(); - fileProvider.Setup(fp => fp.GetFileInfo(It.IsAny())).Returns(fileInfo.Object); + var root = + MockFileInfo.CreateDirectory("", new DateTime(2017, 1, 1), + [ + new MockFileInfo("file1.css", new DateTime(2017, 1, 1), Encoding.UTF8.GetBytes("body { background-color: red; }")), + new MockFileInfo("file2.css", new DateTime(2017, 1, 1), Encoding.UTF8.GetBytes("body { background-color: blue; }")), + MockFileInfo.CreateDirectory("sub", new DateTime(2017, 1, 1), + [ + new MockFileInfo("file3.css", new DateTime(2017, 1, 1), Encoding.UTF8.GetBytes("body { background-color: green; }")), + ]), + ]); + var fileProvider = MockFileProvider.Create(root); + var env = new Mock(); - env.Setup(e => e.WebRootFileProvider).Returns(fileProvider.Object); + env.Setup(e => e.WebRootFileProvider).Returns(fileProvider); var cache = new Mock(); object cacheValue = "/file1.css?v=abc123"; cache.Setup(c => c.TryGetValue("file1.css", out cacheValue)).Returns(true); object cacheValue2 = "/file2.css?v=def456"; cache.Setup(c => c.TryGetValue("file2.css", out cacheValue2)).Returns(true); + object cacheValue3 = "/sub/file3.css?v=ghi789"; + cache.Setup(c => c.TryGetValue("sub/file3.css", out cacheValue3)).Returns(true); var context = new Mock().SetupAllProperties(); - StringValues ae = "gzip, deflate"; - - context.SetupSequence(c => c.Request.Headers.TryGetValue("Accept-Encoding", out ae)) - .Returns(false) - .Returns(true); - context.Setup(c => c.RequestServices.GetService(typeof(IWebHostEnvironment))) - .Returns(env.Object); - context.Setup(c => c.RequestServices.GetService(typeof(IMemoryCache))) - .Returns(cache.Object); context.SetupGet(c => c.Request.PathBase).Returns(pathBase); var options = new WebOptimizerOptions @@ -207,7 +210,6 @@ public void CdnUrl_RouteIsAsset_TagHelperBundlingDisabled_Success(string cdnUrl, CdnUrl = cdnUrl }; var optionsFactory = new Mock>(); - optionsFactory.Setup(x => x.Create(It.IsAny())).Returns(options); var sources = new List>(); var optionsMonitorCache = new Mock>(); @@ -217,11 +219,9 @@ public void CdnUrl_RouteIsAsset_TagHelperBundlingDisabled_Success(string cdnUrl, var route = "/testbundle"; var asset = new Mock().SetupAllProperties(); - asset.SetupGet(a => a.ContentType).Returns("text/css"); - asset.SetupGet(a => a.Route).Returns(route); - asset.SetupGet(a => a.SourceFiles).Returns(new List(["file1.css", "file2.css"])); + asset.SetupGet(a => a.SourceFiles).Returns(new List(["file1.css", "file2.css", "sub/file3.css"])); asset.SetupGet(a => a.ExcludeFiles).Returns([]); - asset.SetupGet(a => a.Items).Returns(new Dictionary { { "fileprovider", fileProvider.Object } }); + asset.SetupGet(a => a.Items).Returns(new Dictionary { { "fileprovider", fileProvider } }); var assetObject = asset.Object; var assetPipeline = new Mock(); assetPipeline.Setup(ap => ap.TryGetAssetFromRoute(route, out assetObject)).Returns(true); @@ -245,9 +245,12 @@ public void CdnUrl_RouteIsAsset_TagHelperBundlingDisabled_Success(string cdnUrl, () => new DefaultTagHelperContent())); linkTagHelper.Process(tagHelperContext.Object, tagHelperOutput); string[] linkTags = tagHelperOutput.PostElement.GetContent().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); - Assert.Equal(2, linkTags.Length); + Assert.Equal(3, linkTags.Length); Assert.Contains($"href=\"{options.CdnUrl}{pathBase}{cacheValue}\"", linkTags[0]); Assert.Contains($"href=\"{options.CdnUrl}{pathBase}{cacheValue2}\"", linkTags[1]); + Assert.Contains($"href=\"{options.CdnUrl}{pathBase}{cacheValue3}\"", linkTags[2]); + + Mock.VerifyAll(context, cache, asset, assetPipeline); } [Theory2] @@ -413,4 +416,4 @@ public void RelativeUrl_RouteIsNotAsset_DoesAddCdnAndPath() Assert.Equal($"{options.CdnUrl}{pathBase}{relativeUrl}", hrefValue); } } -} \ No newline at end of file +} diff --git a/test/WebOptimizer.Core.Test/TagHelpers/ScriptTagHelperTest.cs b/test/WebOptimizer.Core.Test/TagHelpers/ScriptTagHelperTest.cs index 87264ba..dc5dfa4 100644 --- a/test/WebOptimizer.Core.Test/TagHelpers/ScriptTagHelperTest.cs +++ b/test/WebOptimizer.Core.Test/TagHelpers/ScriptTagHelperTest.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Html; @@ -12,6 +13,7 @@ using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Moq; +using WebOptimizer.Core.Test.Mocks; using WebOptimizer.Taghelpers; using Xunit; @@ -173,37 +175,37 @@ public void CdnUrl_RouteIsNotAsset_Success(string cdnUrl, string pathBase) [InlineData("", "/myapp")] public void CdnUrl_RouteIsAsset_TagHelperBundlingDisabled_Success(string cdnUrl, string pathBase) { - var fileInfo = new Mock(); - fileInfo.SetupGet(fi => fi.Exists).Returns(true); - var fileProvider = new Mock(); - fileProvider.Setup(fp => fp.GetFileInfo(It.IsAny())).Returns(fileInfo.Object); + var root = + MockFileInfo.CreateDirectory("", new DateTime(2017, 1, 1), + [ + new MockFileInfo("file1.js", new DateTime(2017, 1, 1), Encoding.UTF8.GetBytes("console.log('file1.js');")), + new MockFileInfo("file2.js", new DateTime(2017, 1, 1), Encoding.UTF8.GetBytes("console.log('file2.js');")), + MockFileInfo.CreateDirectory("sub", new DateTime(2017, 1, 1), + [ + new MockFileInfo("file3.js", new DateTime(2017, 1, 1), Encoding.UTF8.GetBytes("console.log('file3.js');")), + ]), + ]); + var fileProvider = MockFileProvider.Create(root); + var env = new Mock(); - env.Setup(e => e.WebRootFileProvider).Returns(fileProvider.Object); + env.Setup(e => e.WebRootFileProvider).Returns(fileProvider); var cache = new Mock(); object cacheValue = "/file1.js?v=abc123"; cache.Setup(c => c.TryGetValue("file1.js", out cacheValue)).Returns(true); object cacheValue2 = "/file2.js?v=def456"; cache.Setup(c => c.TryGetValue("file2.js", out cacheValue2)).Returns(true); + object cacheValue3 = "/sub/file3.js?v=ghi789"; + cache.Setup(c => c.TryGetValue("sub/file3.js", out cacheValue3)).Returns(true); var context = new Mock().SetupAllProperties(); - StringValues ae = "gzip, deflate"; - - context.SetupSequence(c => c.Request.Headers.TryGetValue("Accept-Encoding", out ae)) - .Returns(false) - .Returns(true); - context.Setup(c => c.RequestServices.GetService(typeof(IWebHostEnvironment))) - .Returns(env.Object); - context.Setup(c => c.RequestServices.GetService(typeof(IMemoryCache))) - .Returns(cache.Object); context.SetupGet(c => c.Request.PathBase).Returns(pathBase); - + var options = new WebOptimizerOptions { EnableTagHelperBundling = false, CdnUrl = cdnUrl }; var optionsFactory = new Mock>(); - optionsFactory.Setup(x => x.Create(It.IsAny())).Returns(options); - + var sources = new List>(); var optionsMonitorCache = new Mock>(); @@ -212,11 +214,9 @@ public void CdnUrl_RouteIsAsset_TagHelperBundlingDisabled_Success(string cdnUrl, var route = "/testbundle"; var asset = new Mock().SetupAllProperties(); - asset.SetupGet(a => a.ContentType).Returns("text/javascript"); - asset.SetupGet(a => a.Route).Returns(route); - asset.SetupGet(a => a.SourceFiles).Returns(new List(["file1.js", "file2.js"])); + asset.SetupGet(a => a.SourceFiles).Returns(new List(["file1.js", "file2.js", "sub/file3.js"])); asset.SetupGet(a => a.ExcludeFiles).Returns([]); - asset.SetupGet(a => a.Items).Returns(new Dictionary{ {"fileprovider", fileProvider.Object}}); + asset.SetupGet(a => a.Items).Returns(new Dictionary{ {"fileprovider", fileProvider}}); var assetObject = asset.Object; var assetPipeline = new Mock(); assetPipeline.Setup(ap => ap.TryGetAssetFromRoute(route, out assetObject)).Returns(true); @@ -240,9 +240,12 @@ public void CdnUrl_RouteIsAsset_TagHelperBundlingDisabled_Success(string cdnUrl, () => new DefaultTagHelperContent())); scriptTagHelper.Process(tagHelperContext.Object, tagHelperOutput); string[] scriptTags = tagHelperOutput.PostElement.GetContent().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); - Assert.Equal(2, scriptTags.Length); + Assert.Equal(3, scriptTags.Length); Assert.Contains($"src=\"{options.CdnUrl}{pathBase}{cacheValue}\"", scriptTags[0]); Assert.Contains($"src=\"{options.CdnUrl}{pathBase}{cacheValue2}\"", scriptTags[1]); + Assert.Contains($"src=\"{options.CdnUrl}{pathBase}{cacheValue3}\"", scriptTags[2]); + + Mock.VerifyAll(context, cache, asset, assetPipeline); } [Theory2]