Issue #2141 - Script & Link tag helpers should support generating cache-busting file version hash in URL.

This commit is contained in:
sornaks 2015-03-20 13:35:14 -07:00
parent 0462dd6be3
commit 0e783ace58
11 changed files with 1111 additions and 56 deletions

View File

@ -88,7 +88,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
{
if (!context.AllAttributes.ContainsKey(attribute) ||
context.AllAttributes[attribute] == null ||
string.IsNullOrWhiteSpace(context.AllAttributes[attribute] as string))
(typeof(string).IsAssignableFrom(context.AllAttributes[attribute].GetType()) &&
string.IsNullOrWhiteSpace(context.AllAttributes[attribute] as string)))
{
// Missing attribute!
missingAttributes.Add(attribute);

View File

@ -0,0 +1,85 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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.Security.Cryptography;
using Microsoft.AspNet.FileProviders;
using Microsoft.AspNet.Http;
using Microsoft.AspNet.WebUtilities;
using Microsoft.Framework.Caching.Memory;
using Microsoft.Framework.Internal;
namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
{
/// <summary>
/// Provides version hash for a specified file.
/// </summary>
public class FileVersionProvider
{
private const string VersionKey = "v";
private readonly IFileProvider _fileProvider;
private readonly IMemoryCache _cache;
private readonly PathString _requestPathBase;
/// <summary>
/// Creates a new instance of <see cref="FileVersionProvider"/>.
/// </summary>
/// <param name="fileProvider">The file provider to get and watch files.</param>
/// <param name="applicationName">Name of the application.</param>
/// <param name="cache"><see cref="IMemoryCache"/> where versioned urls of files are cached.</param>
public FileVersionProvider(
[NotNull] IFileProvider fileProvider,
[NotNull] IMemoryCache cache,
[NotNull] PathString requestPathBase)
{
_fileProvider = fileProvider;
_cache = cache;
_requestPathBase = requestPathBase;
}
/// <summary>
/// Adds version query parameter to the specified file path.
/// </summary>
/// <param name="path">The path of the file to which version should be added.</param>
/// <returns>Path containing the version query string.</returns>
/// <remarks>
/// The version query string is appended as with the key "v".
/// </remarks>
public string AddFileVersionToPath([NotNull] string path)
{
var resolvedPath = path;
var fileInfo = _fileProvider.GetFileInfo(resolvedPath);
if (!fileInfo.Exists)
{
if (_requestPathBase.HasValue &&
resolvedPath.StartsWith(_requestPathBase.Value, StringComparison.OrdinalIgnoreCase))
{
resolvedPath = resolvedPath.Substring(_requestPathBase.Value.Length);
fileInfo = _fileProvider.GetFileInfo(resolvedPath);
}
if (!fileInfo.Exists)
{
// if the file is not in the current server.
return path;
}
}
return _cache.GetOrSet(path, cacheGetOrSetContext =>
{
var trigger = _fileProvider.Watch(resolvedPath);
cacheGetOrSetContext.AddExpirationTrigger(trigger);
return QueryHelpers.AddQueryString(path, VersionKey, GetHashForFile(fileInfo));
});
}
private string GetHashForFile(IFileInfo fileInfo)
{
using (var sha256 = SHA256.Create())
{
var hash = sha256.ComputeHash(fileInfo.CreateReadStream());
return WebEncoders.Base64UrlEncode(hash);
}
}
}
}

View File

@ -5,12 +5,12 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using Microsoft.AspNet.Hosting;
using Microsoft.AspNet.Mvc.TagHelpers.Internal;
using Microsoft.AspNet.Razor.Runtime.TagHelpers;
using Microsoft.Framework.Caching.Memory;
using Microsoft.Framework.Logging;
using Microsoft.Framework.Runtime;
using Microsoft.Framework.WebEncoders;
namespace Microsoft.AspNet.Mvc.TagHelpers
@ -29,6 +29,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
[TargetElement("link", Attributes = FallbackTestClassAttributeName)]
[TargetElement("link", Attributes = FallbackTestPropertyAttributeName)]
[TargetElement("link", Attributes = FallbackTestValueAttributeName)]
[TargetElement("link", Attributes = FileVersionAttributeName)]
public class LinkTagHelper : TagHelper
{
private const string HrefIncludeAttributeName = "asp-href-include";
@ -40,8 +41,14 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
private const string FallbackTestPropertyAttributeName = "asp-fallback-test-property";
private const string FallbackTestValueAttributeName = "asp-fallback-test-value";
private const string FallbackJavaScriptResourceName = "compiler/resources/LinkTagHelper_FallbackJavaScript.js";
private const string FileVersionAttributeName = "asp-file-version";
private const string HrefAttributeName = "href";
private FileVersionProvider _fileVersionProvider;
private static readonly ModeAttributes<Mode>[] ModeDetails = new[] {
// Regular src with file version alone
ModeAttributes.Create(Mode.FileVersion, new[] { FileVersionAttributeName }),
// Globbed Href (include only) no static href
ModeAttributes.Create(Mode.GlobbedHref, new [] { HrefIncludeAttributeName }),
// Globbed Href (include & exclude), no static href
@ -76,15 +83,19 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
private enum Mode
{
/// <summary>
/// Just adding a file version for the generated urls.
/// </summary>
FileVersion = 0,
/// <summary>
/// Just performing file globbing search for the href, rendering a separate &lt;link&gt; for each match.
/// </summary>
GlobbedHref = 0,
GlobbedHref = 1,
/// <summary>
/// Rendering a fallback block if primary stylesheet fails to load. Will also do globbing for both the
/// primary and fallback hrefs if the appropriate properties are set.
/// </summary>
Fallback = 1,
Fallback = 2,
}
/// <summary>
@ -108,6 +119,15 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
[HtmlAttributeName(FallbackHrefAttributeName)]
public string FallbackHref { get; set; }
/// <summary>
/// Value indicating if file version should be appended to the href urls.
/// </summary>
/// <remarks>
/// If <c>true</c> then a query string "v" with the encoded content of the file is added.
/// </remarks>
[HtmlAttributeName(FileVersionAttributeName)]
public bool? FileVersion { get; set; }
/// <summary>
/// A comma separated list of globbed file patterns of CSS stylesheets to fallback to in the case the primary
/// one fails.
@ -149,7 +169,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
[HtmlAttributeName(FallbackTestValueAttributeName)]
public string FallbackTestValue { get; set; }
// Properties are protected to ensure subclasses are correctly activated. Internal for ease of use when testing.
// Properties are protected to ensure subclasses are correctly activated.
// Internal for ease of use when testing.
[Activate]
protected internal ILoggerFactory LoggerFactory { get; set; }
@ -194,9 +215,10 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
var builder = new DefaultTagHelperContent();
if (mode == Mode.Fallback && string.IsNullOrEmpty(HrefInclude))
if (mode == Mode.Fallback && string.IsNullOrEmpty(HrefInclude) || mode == Mode.FileVersion)
{
// No globbing to do, just build a <link /> tag to match the original one in the source file
// No globbing to do, just build a <link /> tag to match the original one in the source file.
// Or just add file version to the link tag.
BuildLinkTag(attributes, builder);
}
else
@ -218,14 +240,14 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
{
// Build a <link /> tag for each matched href as well as the original one in the source file
string staticHref;
attributes.TryGetValue("href", out staticHref);
attributes.TryGetValue(HrefAttributeName, out staticHref);
EnsureGlobbingUrlBuilder();
var urls = GlobbingUrlBuilder.BuildUrlList(staticHref, HrefInclude, HrefExclude);
foreach (var url in urls)
{
attributes["href"] = HtmlEncoder.HtmlEncode(url);
attributes[HrefAttributeName] = url;
BuildLinkTag(attributes, builder);
}
}
@ -233,10 +255,19 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
private void BuildFallbackBlock(TagHelperContent builder)
{
EnsureGlobbingUrlBuilder();
var fallbackHrefs = GlobbingUrlBuilder.BuildUrlList(FallbackHref, FallbackHrefInclude, FallbackHrefExclude);
var fallbackHrefs =
GlobbingUrlBuilder.BuildUrlList(FallbackHref, FallbackHrefInclude, FallbackHrefExclude).ToArray();
if (fallbackHrefs.Any())
if (fallbackHrefs.Length > 0)
{
if (ShouldAddFileVersion())
{
for (var i=0; i < fallbackHrefs.Length; i++)
{
fallbackHrefs[i] = _fileVersionProvider.AddFileVersionToPath(fallbackHrefs[i]);
}
}
builder.Append(Environment.NewLine);
// Build the <meta /> tag that's used to test for the presence of the stylesheet
@ -269,17 +300,46 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
}
}
private static void BuildLinkTag(IDictionary<string, string> attributes, TagHelperContent builder)
private void EnsureFileVersionProvider()
{
if (_fileVersionProvider == null)
{
_fileVersionProvider = new FileVersionProvider(
HostingEnvironment.WebRootFileProvider,
Cache,
ViewContext.HttpContext.Request.PathBase);
}
}
private void BuildLinkTag(IDictionary<string, string> attributes, TagHelperContent builder)
{
EnsureFileVersionProvider();
builder.Append("<link ");
foreach (var attribute in attributes)
{
builder.Append(
string.Format(CultureInfo.InvariantCulture, "{0}=\"{1}\" ", attribute.Key, attribute.Value));
var attributeValue = attribute.Value;
if (string.Equals(attribute.Key, HrefAttributeName, StringComparison.OrdinalIgnoreCase))
{
attributeValue = HtmlEncoder.HtmlEncode(
ShouldAddFileVersion() ?
_fileVersionProvider.AddFileVersionToPath(attributeValue) :
attributeValue);
}
builder
.Append(attribute.Key)
.Append("=\"")
.Append(attributeValue)
.Append("\" ");
}
builder.Append("/>");
}
private bool ShouldAddFileVersion()
{
return FileVersion ?? false;
}
}
}

View File

@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNet.Hosting;
@ -11,6 +10,7 @@ using Microsoft.AspNet.Mvc.TagHelpers.Internal;
using Microsoft.AspNet.Razor.Runtime.TagHelpers;
using Microsoft.Framework.Caching.Memory;
using Microsoft.Framework.Logging;
using Microsoft.Framework.Runtime;
using Microsoft.Framework.WebEncoders;
namespace Microsoft.AspNet.Mvc.TagHelpers
@ -27,6 +27,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
[TargetElement("script", Attributes = FallbackSrcIncludeAttributeName)]
[TargetElement("script", Attributes = FallbackSrcExcludeAttributeName)]
[TargetElement("script", Attributes = FallbackTestExpressionAttributeName)]
[TargetElement("script", Attributes = FileVersionAttributeName)]
public class ScriptTagHelper : TagHelper
{
private const string SrcIncludeAttributeName = "asp-src-include";
@ -36,8 +37,13 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
private const string FallbackSrcExcludeAttributeName = "asp-fallback-src-exclude";
private const string FallbackTestExpressionAttributeName = "asp-fallback-test";
private const string SrcAttributeName = "src";
private const string FileVersionAttributeName = "asp-file-version";
private FileVersionProvider _fileVersionProvider;
private static readonly ModeAttributes<Mode>[] ModeDetails = new[] {
// Regular src with file version alone
ModeAttributes.Create(Mode.FileVersion, new[] { FileVersionAttributeName }),
// Globbed src (include only)
ModeAttributes.Create(Mode.GlobbedSrc, new [] { SrcIncludeAttributeName }),
// Globbed src (include & exclude)
@ -66,15 +72,19 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
private enum Mode
{
/// <summary>
/// Just adding a file version for the generated urls.
/// </summary>
FileVersion = 0,
/// <summary>
/// Just performing file globbing search for the src, rendering a separate &lt;script&gt; for each match.
/// </summary>
GlobbedSrc = 0,
GlobbedSrc = 1,
/// <summary>
/// Rendering a fallback block if primary javascript fails to load. Will also do globbing for both the
/// primary and fallback srcs if the appropriate properties are set.
/// </summary>
Fallback = 1
Fallback = 2
}
/// <summary>
@ -98,6 +108,15 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
[HtmlAttributeName(FallbackSrcAttributeName)]
public string FallbackSrc { get; set; }
/// <summary>
/// Value indicating if file version should be appended to src urls.
/// </summary>
/// <remarks>
/// A query string "v" with the encoded content of the file is added.
/// </remarks>
[HtmlAttributeName(FileVersionAttributeName)]
public bool? FileVersion { get; set; }
/// <summary>
/// A comma separated list of globbed file patterns of JavaScript scripts to fallback to in the case the
/// primary one fails.
@ -137,12 +156,12 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
[Activate]
protected internal IMemoryCache Cache { get; set; }
// Internal for ease of use when testing.
protected internal GlobbingUrlBuilder GlobbingUrlBuilder { get; set; }
[Activate]
protected internal IHtmlEncoder HtmlEncoder { get; set; }
// Internal for ease of use when testing.
protected internal GlobbingUrlBuilder GlobbingUrlBuilder { get; set; }
/// <inheritdoc />
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
@ -167,9 +186,10 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
var builder = new DefaultTagHelperContent();
var originalContent = await context.GetChildContentAsync();
if (mode == Mode.Fallback && string.IsNullOrEmpty(SrcInclude))
if (mode == Mode.Fallback && string.IsNullOrEmpty(SrcInclude) || mode == Mode.FileVersion)
{
// No globbing to do, just build a <script /> tag to match the original one in the source file
// Or just add file version to the script tag.
BuildScriptTag(originalContent, attributes, builder);
}
else
@ -194,14 +214,14 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
{
// Build a <script> tag for each matched src as well as the original one in the source file
string staticSrc;
attributes.TryGetValue("src", out staticSrc);
attributes.TryGetValue(SrcAttributeName, out staticSrc);
EnsureGlobbingUrlBuilder();
var urls = GlobbingUrlBuilder.BuildUrlList(staticSrc, SrcInclude, SrcExclude);
foreach (var url in urls)
{
attributes["src"] = HtmlEncoder.HtmlEncode(url);
attributes[SrcAttributeName] = url;
var content =
string.Equals(url, staticSrc, StringComparison.OrdinalIgnoreCase) ? originalContent : null;
BuildScriptTag(content, attributes, builder);
@ -211,6 +231,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
private void BuildFallbackBlock(IDictionary<string, string> attributes, DefaultTagHelperContent builder)
{
EnsureGlobbingUrlBuilder();
EnsureFileVersionProvider();
var fallbackSrcs = GlobbingUrlBuilder.BuildUrlList(FallbackSrc, FallbackSrcInclude, FallbackSrcExclude);
@ -226,9 +247,13 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
{
builder.Append("<script");
if (!attributes.ContainsKey("src"))
if (!attributes.ContainsKey(SrcAttributeName))
{
AppendSrc(builder, "src", src);
AppendAttribute(
builder,
SrcAttributeName,
HtmlEncoder.HtmlEncode(ShouldAddFileVersion() ? _fileVersionProvider.AddFileVersionToPath(src) : src),
escapteQuotes: true);
}
foreach (var attribute in attributes)
@ -238,21 +263,22 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
var encodedKey = JavaScriptStringEncoder.Default.JavaScriptStringEncode(attribute.Key);
var encodedValue = JavaScriptStringEncoder.Default.JavaScriptStringEncode(attribute.Value);
builder.Append(string.Format(
CultureInfo.InvariantCulture,
" {0}=\\\"{1}\\\"",
encodedKey,
encodedValue));
AppendAttribute(builder, encodedKey, encodedValue, escapteQuotes: true);
}
else
{
AppendSrc(builder, attribute.Key, src);
AppendAttribute(
builder,
attribute.Key,
HtmlEncoder.HtmlEncode(
ShouldAddFileVersion() ? _fileVersionProvider.AddFileVersionToPath(src) : src),
escapteQuotes: true);
}
}
builder.Append("><\\/script>");
}
builder.Append("\"));</script>");
}
}
@ -268,17 +294,37 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
}
}
private static void BuildScriptTag(
private void EnsureFileVersionProvider()
{
if (_fileVersionProvider == null)
{
_fileVersionProvider = new FileVersionProvider(
HostingEnvironment.WebRootFileProvider,
Cache,
ViewContext.HttpContext.Request.PathBase);
}
}
private void BuildScriptTag(
TagHelperContent content,
IDictionary<string, string> attributes,
TagHelperContent builder)
{
EnsureFileVersionProvider();
builder.Append("<script");
foreach (var attribute in attributes)
{
builder.Append(
string.Format(CultureInfo.InvariantCulture, " {0}=\"{1}\"", attribute.Key, attribute.Value));
string attributeValue = attribute.Value;
if (string.Equals(attribute.Key, SrcAttributeName, StringComparison.OrdinalIgnoreCase))
{
attributeValue = HtmlEncoder.HtmlEncode(
ShouldAddFileVersion() ?
_fileVersionProvider.AddFileVersionToPath(attribute.Value) :
attributeValue);
}
AppendAttribute(builder, attribute.Key, attributeValue, escapteQuotes: false);
}
builder.Append(">")
@ -286,15 +332,20 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
.Append("</script>");
}
private void AppendSrc(TagHelperContent content, string srcKey, string srcValue)
private bool ShouldAddFileVersion()
{
// Append src attribute in the original place and replace the content the fallback content
// No need to encode the key because we know it is "src".
content.Append(" ")
.Append(srcKey)
.Append("=\\\"")
.Append(HtmlEncoder.HtmlEncode(srcValue))
.Append("\\\"");
return FileVersion ?? false;
}
private void AppendAttribute(TagHelperContent content, string key, string value, bool escapteQuotes)
{
content
.Append(" ")
.Append(key)
.Append(escapteQuotes ? "=\\\"" : "=\"")
.Append(value)
.Append(escapteQuotes ? "\\\"" : "\"");
}
}
}

View File

@ -110,6 +110,19 @@
<!-- Fallback with missing attribute -->
<link href="/site.min.css" rel="stylesheet" data-extra="test" />
<!-- Plain link tag with file version -->
<link href="/site.css?v=XY7YsMemPf8AGU4SIX9ED9eOjK1LOQWu2dmCNmh-pQc" rel="stylesheet" />
<!-- Globbed link tag with existing file and file version -->
<link rel="stylesheet" href="/site.css?v=XY7YsMemPf8AGU4SIX9ED9eOjK1LOQWu2dmCNmh-pQc" />
<!-- Fallback with file version -->
<link href="/site.min.css" rel="stylesheet" data-extra="test" />
<meta name="x-stylesheet-fallback-test" class="hidden" /><script>!function(a,b,c){var d,e=document,f=e.getElementsByTagName("SCRIPT"),g=f[f.length-1].previousElementSibling,h=e.defaultView&&e.defaultView.getComputedStyle?e.defaultView.getComputedStyle(g):g.currentStyle;if(h&&h[a]!==b)for(d=0;d<c.length;d++)e.write('<link rel="stylesheet" href="'+c[d]+'"/>')}("visibility","hidden",["\/site.css?v=XY7YsMemPf8AGU4SIX9ED9eOjK1LOQWu2dmCNmh-pQc"]);</script>
<!-- Globbed link tag with existing file, static href and file version -->
<link href="/site.css?v=XY7YsMemPf8AGU4SIX9ED9eOjK1LOQWu2dmCNmh-pQc" rel="stylesheet" /><link href="/sub/site2.css?v=30cxPex0tA9xEatW7f1Qhnn8tVLAHgE6xwIZhESq0y0" rel="stylesheet" /><link href="/sub/site3.css?v=fSxxOr1Q4Dq2uPuzlju5UYGuK0SKABI-ghvaIGEsZDc" rel="stylesheet" /><link href="/sub/site3.min.css?v=s8JMmAZxBn0dzuhRtQ0wgOvNBK4XRJRWEC2wfzsVF9M" rel="stylesheet" />
</head>
<body>

View File

@ -92,5 +92,25 @@
<script src="/site.js">
// Globbed script tag with existing file and static src should dedupe
</script>
<script src="/blank.js">
// TagHelper script with comment in body, and file version.
</script>
<script>(false||document.write("<script src=\"/site.js?v=jx1PJjLX32-xgQQx2BxnckU9QH9DVKkm4-M5bSK869I\"><\/script>"));</script>
<script src="/blank.js">
// Fallback to globbed src with file version.
</script>
<script>(false||document.write("<script src=\"/site.js?v=jx1PJjLX32-xgQQx2BxnckU9QH9DVKkm4-M5bSK869I\"><\/script>"));</script>
<script src="/site.js?v=jx1PJjLX32-xgQQx2BxnckU9QH9DVKkm4-M5bSK869I">
// Regular script with comment in body, and file version.
</script>
<!-- Globbed script tag with existing files and version -->
<script src="/site.js?v=jx1PJjLX32-xgQQx2BxnckU9QH9DVKkm4-M5bSK869I"></script><script src="/sub/site2.js?v=pwJaxaQxnb-rPAdF2JlAp4xiPNq1XuJFd6TyOOfNF-0"></script><script src="/sub/site3.js?v=lmeAMiqm76lnGyqHhu6PIBHAC0Vt46mgVB_KaG_gGdA"></script>
<!-- Globbed script tag with existing file, exclude and version -->
<script src="/site.js?v=jx1PJjLX32-xgQQx2BxnckU9QH9DVKkm4-M5bSK869I"></script><script src="/sub/site2.js?v=pwJaxaQxnb-rPAdF2JlAp4xiPNq1XuJFd6TyOOfNF-0"></script>
</body>
</html>

View File

@ -0,0 +1,214 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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.Text;
using Microsoft.AspNet.FileProviders;
using Microsoft.AspNet.Hosting;
using Microsoft.AspNet.Http;
using Microsoft.Framework.Caching.Memory;
using Microsoft.Framework.Expiration.Interfaces;
using Moq;
using Xunit;
namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
{
public class FileVersionProviderTest
{
[Theory]
[InlineData("/hello/world", "/hello/world?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk")]
[InlineData("/hello/world?q=test", "/hello/world?q=test&v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk")]
[InlineData("/hello/world?q=foo&bar", "/hello/world?q=foo&bar&v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk")]
public void AddsVersionToFiles_WhenCacheIsAbsent(string filePath, string expected)
{
// Arrange
var hostingEnvironment = GetMockHostingEnvironment(filePath);
var fileVersionProvider = new FileVersionProvider(
hostingEnvironment.WebRootFileProvider,
GetMockCache(),
GetRequestPathBase());
// Act
var result = fileVersionProvider.AddFileVersionToPath(filePath);
// Assert
Assert.Equal(expected, result);
}
[Theory]
[InlineData("/testApp/hello/world", true, "/testApp")]
[InlineData("/testApp/foo/bar/hello/world", true, "/testApp/foo/bar")]
[InlineData("/test/testApp/hello/world", false, "/testApp")]
public void AddsVersionToFiles_PathContainingAppName(
string filePath,
bool pathStartsWithAppBase,
string requestPathBase)
{
// Arrange
var hostingEnvironment = GetMockHostingEnvironment(filePath, pathStartsWithAppBase);
var fileVersionProvider = new FileVersionProvider(
hostingEnvironment.WebRootFileProvider,
GetMockCache(),
GetRequestPathBase(requestPathBase));
// Act
var result = fileVersionProvider.AddFileVersionToPath(filePath);
// Assert
Assert.Equal(filePath + "?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", result);
}
[Fact]
public void DoesNotAddVersion_IfFileNotFound()
{
// Arrange
var filePath = "http://contoso.com/hello/world";
var hostingEnvironment = GetMockHostingEnvironment(filePath, false, true);
var fileVersionProvider = new FileVersionProvider(
hostingEnvironment.WebRootFileProvider,
GetMockCache(),
GetRequestPathBase());
// Act
var result = fileVersionProvider.AddFileVersionToPath(filePath);
// Assert
Assert.Equal("http://contoso.com/hello/world", result);
}
[Fact]
public void ReturnsValueFromCache()
{
// Arrange
var filePath = "/hello/world";
var hostingEnvironment = GetMockHostingEnvironment(filePath);
var fileVersionProvider = new FileVersionProvider(
hostingEnvironment.WebRootFileProvider,
GetMockCache("FromCache"),
GetRequestPathBase());
// Act
var result = fileVersionProvider.AddFileVersionToPath(filePath);
// Assert
Assert.Equal("FromCache", result);
}
[Theory]
[InlineData("/hello/world", "/hello/world", null)]
[InlineData("/testApp/hello/world", "/hello/world", "/testApp")]
[InlineData("/hello/world", "/hello/world", null)]
public void SetsValueInCache(string filePath, string watchPath, string requestPathBase)
{
// Arrange
var trigger = new Mock<IExpirationTrigger>();
var hostingEnvironment = GetMockHostingEnvironment(filePath, requestPathBase != null);
Mock.Get(hostingEnvironment.WebRootFileProvider)
.Setup(f => f.Watch(watchPath)).Returns(trigger.Object);
object cacheValue = null;
var cache = new Mock<IMemoryCache>();
cache.CallBase = true;
cache.Setup(c => c.TryGetValue(It.IsAny<string>(), It.IsAny<IEntryLink>(), out cacheValue))
.Returns(cacheValue != null);
var cacheSetContext = new Mock<ICacheSetContext>();
cacheSetContext.Setup(c => c.AddExpirationTrigger(trigger.Object)).Verifiable();
cache.Setup(c => c.Set(
/*key*/ filePath,
/*link*/ It.IsAny<IEntryLink>(),
/*state*/ It.IsAny<object>(),
/*create*/ It.IsAny<Func<ICacheSetContext, object>>()))
.Returns<string, IEntryLink, object, Func<ICacheSetContext, object>>(
(key, link, state, create) =>
{
cacheSetContext.Setup(c => c.State).Returns(state);
return create(cacheSetContext.Object);
})
.Verifiable();
var fileVersionProvider = new FileVersionProvider(
hostingEnvironment.WebRootFileProvider,
cache.Object,
GetRequestPathBase(requestPathBase));
// Act
var result = fileVersionProvider.AddFileVersionToPath(filePath);
// Assert
Assert.Equal(filePath + "?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", result);
cacheSetContext.VerifyAll();
cache.VerifyAll();
}
private IHostingEnvironment GetMockHostingEnvironment(
string filePath,
bool pathStartsWithAppName = false,
bool fileDoesNotExist = false)
{
var existingMockFile = new Mock<IFileInfo>();
existingMockFile.SetupGet(f => f.Exists).Returns(true);
existingMockFile
.Setup(m => m.CreateReadStream())
.Returns(() => new MemoryStream(Encoding.UTF8.GetBytes("Hello World!")));
var nonExistingMockFile = new Mock<IFileInfo>();
nonExistingMockFile.SetupGet(f => f.Exists).Returns(false);
nonExistingMockFile
.Setup(m => m.CreateReadStream())
.Returns(() => new MemoryStream(Encoding.UTF8.GetBytes("Hello World!")));
var mockFileProvider = new Mock<IFileProvider>();
if (pathStartsWithAppName)
{
mockFileProvider.Setup(fp => fp.GetFileInfo(filePath)).Returns(nonExistingMockFile.Object);
mockFileProvider.Setup(fp => fp.GetFileInfo(It.Is<string>(str => str != filePath)))
.Returns(existingMockFile.Object);
}
else
{
mockFileProvider.Setup(fp => fp.GetFileInfo(It.IsAny<string>()))
.Returns(fileDoesNotExist? nonExistingMockFile.Object : existingMockFile.Object);
}
var hostingEnvironment = new Mock<IHostingEnvironment>();
hostingEnvironment.Setup(h => h.WebRootFileProvider).Returns(mockFileProvider.Object);
return hostingEnvironment.Object;
}
private static IMemoryCache GetMockCache(object result = null)
{
var cache = new Mock<IMemoryCache>();
cache.CallBase = true;
cache.Setup(c => c.TryGetValue(It.IsAny<string>(), It.IsAny<IEntryLink>(), out result))
.Returns(result != null);
var cacheSetContext = new Mock<ICacheSetContext>();
cacheSetContext.Setup(c => c.AddExpirationTrigger(It.IsAny<IExpirationTrigger>()));
cache
.Setup(
c => c.Set(
/*key*/ It.IsAny<string>(),
/*link*/ It.IsAny<IEntryLink>(),
/*state*/ It.IsAny<object>(),
/*create*/ It.IsAny<Func<ICacheSetContext, object>>()))
.Returns((
string input,
IEntryLink entryLink,
object state,
Func<ICacheSetContext, object> create) =>
{
{
cacheSetContext.Setup(c => c.State).Returns(state);
return create(cacheSetContext.Object);
}
});
return cache.Object;
}
private static PathString GetRequestPathBase(string requestPathBase = null)
{
return new PathString(requestPathBase);
}
}
}

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNet.FileProviders;
using Microsoft.AspNet.Hosting;
@ -14,7 +15,10 @@ using Microsoft.AspNet.Mvc.Rendering;
using Microsoft.AspNet.Mvc.TagHelpers.Internal;
using Microsoft.AspNet.Razor.Runtime.TagHelpers;
using Microsoft.AspNet.Routing;
using Microsoft.Framework.Caching.Memory;
using Microsoft.Framework.Expiration.Interfaces;
using Microsoft.Framework.Logging;
using Microsoft.Framework.Runtime;
using Microsoft.Framework.WebEncoders;
using Moq;
using Xunit;
@ -82,6 +86,79 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
tagHelper.FallbackTestProperty = "visibility";
tagHelper.FallbackTestValue = "hidden";
}
},
// File Version
{
new Dictionary<string, object>
{
["asp-file-version"] = "true"
},
tagHelper =>
{
tagHelper.FileVersion = true;
}
},
{
new Dictionary<string, object>
{
["asp-href-include"] = "*.css",
["asp-file-version"] = "true"
},
tagHelper =>
{
tagHelper.HrefInclude = "*.css";
tagHelper.FileVersion = true;
}
},
{
new Dictionary<string, object>
{
["asp-href-include"] = "*.css",
["asp-href-exclude"] = "*.min.css",
["asp-file-version"] = "true"
},
tagHelper =>
{
tagHelper.HrefInclude = "*.css";
tagHelper.HrefExclude = "*.min.css";
tagHelper.FileVersion = true;
}
},
{
new Dictionary<string, object>
{
["asp-fallback-href"] = "test.css",
["asp-fallback-test-class"] = "hidden",
["asp-fallback-test-property"] = "visibility",
["asp-fallback-test-value"] = "hidden",
["asp-file-version"] = "true"
},
tagHelper =>
{
tagHelper.FallbackHref = "test.css";
tagHelper.FallbackTestClass = "hidden";
tagHelper.FallbackTestProperty = "visibility";
tagHelper.FallbackTestValue = "hidden";
tagHelper.FileVersion = true;
}
},
{
new Dictionary<string, object>
{
["asp-fallback-href-include"] = "*.css",
["asp-fallback-test-class"] = "hidden",
["asp-fallback-test-property"] = "visibility",
["asp-fallback-test-value"] = "hidden",
["asp-file-version"] = "true"
},
tagHelper =>
{
tagHelper.FallbackHrefInclude = "*.css";
tagHelper.FallbackTestClass = "hidden";
tagHelper.FallbackTestProperty = "visibility";
tagHelper.FallbackTestValue = "hidden";
tagHelper.FileVersion = true;
}
}
};
}
@ -105,6 +182,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
Cache = MakeCache()
};
setProperties(helper);
@ -151,7 +229,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
FallbackHref = "test.css",
FallbackTestClass = "hidden",
FallbackTestProperty = "visibility",
FallbackTestValue = "hidden"
FallbackTestValue = "hidden",
Cache = MakeCache(),
};
// Act
@ -250,7 +329,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
{
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext
ViewContext = viewContext,
Cache = MakeCache(),
};
setProperties(helper);
@ -275,7 +355,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
{
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext
ViewContext = viewContext,
Cache = MakeCache(),
};
// Act
@ -315,7 +396,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
HrefInclude = "**/*.css"
HrefInclude = "**/*.css",
Cache = MakeCache(),
};
// Act
@ -355,7 +437,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
HrefInclude = "**/*.css"
HrefInclude = "**/*.css",
Cache = MakeCache(),
};
// Act
@ -366,9 +449,134 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
"<link href=\"HtmlEncode[[/base.css]]\" rel=\"stylesheet\" />", output.Content.GetContent());
}
private static ViewContext MakeViewContext()
[Fact]
public void RendersLinkTags_AddsFileVersion()
{
// Arrange
var context = MakeTagHelperContext(
attributes: new Dictionary<string, object>
{
["href"] = "/css/site.css",
["rel"] = "stylesheet",
["asp-file-version"] = "true"
});
var output = MakeTagHelperOutput("link", attributes: new Dictionary<string, string>
{
["href"] = "/css/site.css",
["rel"] = "stylesheet"
});
var logger = new Mock<ILogger<LinkTagHelper>>();
var hostingEnvironment = MakeHostingEnvironment();
var viewContext = MakeViewContext();
var helper = new LinkTagHelper
{
HtmlEncoder = new TestHtmlEncoder(),
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
HrefInclude = "**/*.css",
FileVersion = true,
Cache = MakeCache(),
};
// Act
helper.Process(context, output);
// Assert
Assert.Equal("<link href=\"HtmlEncode[[/css/site.css?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk]]\"" +
" rel=\"stylesheet\" />", output.Content.GetContent());
}
[Fact]
public void RendersLinkTags_AddsFileVersion_WithRequestPathBase()
{
// Arrange
var context = MakeTagHelperContext(
attributes: new Dictionary<string, object>
{
["href"] = "/bar/css/site.css",
["rel"] = "stylesheet",
["asp-file-version"] = "true"
});
var output = MakeTagHelperOutput("link", attributes: new Dictionary<string, string>
{
["href"] = "/bar/css/site.css",
["rel"] = "stylesheet"
});
var logger = new Mock<ILogger<LinkTagHelper>>();
var hostingEnvironment = MakeHostingEnvironment();
var viewContext = MakeViewContext("/bar");
var helper = new LinkTagHelper
{
HtmlEncoder = new TestHtmlEncoder(),
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
HrefInclude = "**/*.css",
FileVersion = true,
Cache = MakeCache(),
};
// Act
helper.Process(context, output);
// Assert
Assert.Equal("<link href=\"HtmlEncode[[/bar/css/site.css?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-" +
"j1ncoSt3SABJtkGk]]\" rel=\"stylesheet\" />", output.Content.GetContent());
}
[Fact]
public void RendersLinkTags_GlobbedHref_AddsFileVersion()
{
// Arrange
var context = MakeTagHelperContext(
attributes: new Dictionary<string, object>
{
["href"] = "/css/site.css",
["rel"] = "stylesheet",
["asp-href-include"] = "**/*.css",
["asp-file-version"] = "true"
});
var output = MakeTagHelperOutput("link", attributes: new Dictionary<string, string>
{
["href"] = "/css/site.css",
["rel"] = "stylesheet"
});
var logger = new Mock<ILogger<LinkTagHelper>>();
var hostingEnvironment = MakeHostingEnvironment();
var viewContext = MakeViewContext();
var globbingUrlBuilder = new Mock<GlobbingUrlBuilder>();
globbingUrlBuilder.Setup(g => g.BuildUrlList("/css/site.css", "**/*.css", null))
.Returns(new[] { "/css/site.css", "/base.css" });
var helper = new LinkTagHelper
{
HtmlEncoder = new TestHtmlEncoder(),
GlobbingUrlBuilder = globbingUrlBuilder.Object,
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
HrefInclude = "**/*.css",
FileVersion = true,
Cache = MakeCache(),
};
// Act
helper.Process(context, output);
// Assert
Assert.Equal("<link href=\"HtmlEncode[[/css/site.css?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk]]\"" +
" rel=\"stylesheet\" /><link href=\"HtmlEncode[[/base.css" +
"?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk]]\" rel=\"stylesheet\" />", output.Content.GetContent());
}
private static ViewContext MakeViewContext(string requestPathBase = null)
{
var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor());
if (requestPathBase != null)
{
actionContext.HttpContext.Request.PathBase = new Http.PathString(requestPathBase);
}
var metadataProvider = new EmptyModelMetadataProvider();
var viewData = new ViewDataDictionary(metadataProvider);
var viewContext = new ViewContext(
@ -411,15 +619,59 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
var emptyDirectoryContents = new Mock<IDirectoryContents>();
emptyDirectoryContents.Setup(dc => dc.GetEnumerator())
.Returns(Enumerable.Empty<IFileInfo>().GetEnumerator());
var mockFile = new Mock<IFileInfo>();
mockFile.SetupGet(f => f.Exists).Returns(true);
mockFile
.Setup(m => m.CreateReadStream())
.Returns(() => new MemoryStream(Encoding.UTF8.GetBytes("Hello World!")));
var mockFileProvider = new Mock<IFileProvider>();
mockFileProvider.Setup(fp => fp.GetDirectoryContents(It.IsAny<string>()))
.Returns(emptyDirectoryContents.Object);
mockFileProvider.Setup(fp => fp.GetFileInfo(It.IsAny<string>()))
.Returns(mockFile.Object);
var hostingEnvironment = new Mock<IHostingEnvironment>();
hostingEnvironment.Setup(h => h.WebRootFileProvider).Returns(mockFileProvider.Object);
return hostingEnvironment.Object;
}
private static IApplicationEnvironment MakeApplicationEnvironment(string applicationName = "testApplication")
{
var applicationEnvironment = new Mock<IApplicationEnvironment>();
applicationEnvironment.Setup(a => a.ApplicationName).Returns(applicationName);
return applicationEnvironment.Object;
}
private static IMemoryCache MakeCache(object result = null)
{
var cache = new Mock<IMemoryCache>();
cache.CallBase = true;
cache.Setup(c => c.TryGetValue(It.IsAny<string>(), It.IsAny<IEntryLink>(), out result))
.Returns(result != null);
var cacheSetContext = new Mock<ICacheSetContext>();
cacheSetContext.Setup(c => c.AddExpirationTrigger(It.IsAny<IExpirationTrigger>()));
cache
.Setup(
c => c.Set(
/*key*/ It.IsAny<string>(),
/*link*/ It.IsAny<IEntryLink>(),
/*state*/ It.IsAny<object>(),
/*create*/ It.IsAny<Func<ICacheSetContext, object>>()))
.Returns((
string input,
IEntryLink entryLink,
object state,
Func<ICacheSetContext, object> create) =>
{
{
cacheSetContext.Setup(c => c.State).Returns(state);
return create(cacheSetContext.Object);
}
});
return cache.Object;
}
private class TestHtmlEncoder : IHtmlEncoder
{
public string HtmlEncode(string value)

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNet.FileProviders;
using Microsoft.AspNet.Hosting;
@ -14,7 +15,10 @@ using Microsoft.AspNet.Mvc.Rendering;
using Microsoft.AspNet.Mvc.TagHelpers.Internal;
using Microsoft.AspNet.Razor.Runtime.TagHelpers;
using Microsoft.AspNet.Routing;
using Microsoft.Framework.Caching.Memory;
using Microsoft.Framework.Expiration.Interfaces;
using Microsoft.Framework.Logging;
using Microsoft.Framework.Runtime;
using Microsoft.Framework.WebEncoders;
using Moq;
using Xunit;
@ -102,6 +106,103 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
tagHelper.FallbackSrcExclude = "*.min.css";
tagHelper.FallbackTestExpression = "isavailable()";
}
},
// File Version
{
new Dictionary<string, object>
{
["asp-file-version"] = "true"
},
tagHelper =>
{
tagHelper.FileVersion = true;
}
},
{
new Dictionary<string, object>
{
["asp-src-include"] = "*.js",
["asp-file-version"] = "true"
},
tagHelper =>
{
tagHelper.SrcInclude = "*.js";
tagHelper.FileVersion = true;
}
},
{
new Dictionary<string, object>
{
["asp-src-include"] = "*.js",
["asp-src-exclude"] = "*.min.js",
["asp-file-version"] = "true"
},
tagHelper =>
{
tagHelper.SrcInclude = "*.js";
tagHelper.SrcExclude = "*.min.js";
tagHelper.FileVersion = true;
}
},
{
new Dictionary<string, object>
{
["asp-fallback-src"] = "test.js",
["asp-fallback-test"] = "isavailable()",
["asp-file-version"] = "true"
},
tagHelper =>
{
tagHelper.FallbackSrc = "test.js";
tagHelper.FallbackTestExpression = "isavailable()";
tagHelper.FileVersion = true;
}
},
{
new Dictionary<string, object>
{
["asp-fallback-src-include"] = "*.js",
["asp-fallback-test"] = "isavailable()",
["asp-file-version"] = "true"
},
tagHelper =>
{
tagHelper.FallbackSrcInclude = "*.css";
tagHelper.FallbackTestExpression = "isavailable()";
tagHelper.FileVersion = true;
}
},
{
new Dictionary<string, object>
{
["asp-fallback-src"] = "test.js",
["asp-fallback-src-include"] = "*.js",
["asp-fallback-test"] = "isavailable()",
["asp-file-version"] = "true"
},
tagHelper =>
{
tagHelper.FallbackSrc = "test.js";
tagHelper.FallbackSrcInclude = "*.css";
tagHelper.FallbackTestExpression = "isavailable()";
tagHelper.FileVersion = true;
}
},
{
new Dictionary<string, object>
{
["asp-fallback-src-include"] = "*.js",
["asp-fallback-src-exclude"] = "*.min.js",
["asp-fallback-test"] = "isavailable()",
["asp-file-version"] = "true"
},
tagHelper =>
{
tagHelper.FallbackSrcInclude = "*.css";
tagHelper.FallbackSrcExclude = "*.min.css";
tagHelper.FallbackTestExpression = "isavailable()";
tagHelper.FileVersion = true;
}
}
};
}
@ -125,6 +226,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
Logger = logger,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
Cache = MakeCache(),
};
setProperties(helper);
@ -213,7 +315,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
{
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext
ViewContext = viewContext,
Cache = MakeCache(),
};
setProperties(helper);
@ -241,7 +344,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
{
Logger = logger,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext
ViewContext = viewContext,
Cache = MakeCache(),
};
setProperties(helper);
@ -277,7 +381,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
var helper = new ScriptTagHelper
{
Logger = logger,
ViewContext = viewContext
ViewContext = viewContext,
Cache = MakeCache(),
};
// Act
@ -300,7 +405,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
var helper = new ScriptTagHelper
{
Logger = logger,
ViewContext = viewContext
ViewContext = viewContext,
Cache = MakeCache(),
};
// Act
@ -353,6 +459,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
HostingEnvironment = hostingEnvironment,
FallbackSrc = "~/blank.js",
FallbackTestExpression = "http://www.example.com/blank.js",
Cache = MakeCache(),
};
// Act
@ -391,7 +498,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
SrcInclude = "**/*.js",
HtmlEncoder = new HtmlEncoder()
HtmlEncoder = new HtmlEncoder(),
Cache = MakeCache(),
};
// Act
@ -428,7 +536,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
SrcInclude = "**/*.js",
HtmlEncoder = new TestHtmlEncoder()
HtmlEncoder = new TestHtmlEncoder(),
Cache = MakeCache(),
};
// Act
@ -439,6 +548,168 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
"<script src=\"HtmlEncode[[/common.js]]\"></script>", output.Content.GetContent());
}
[Fact]
public async Task RenderScriptTags_WithFileVersion()
{
// Arrange
var context = MakeTagHelperContext(
attributes: new Dictionary<string, object>
{
["src"] = "/js/site.js",
["asp-file-version"] = "true"
});
var output = MakeTagHelperOutput("script", attributes: new Dictionary<string, string>
{
["src"] = "/js/site.js"
});
var logger = new Mock<ILogger<ScriptTagHelper>>();
var hostingEnvironment = MakeHostingEnvironment();
var viewContext = MakeViewContext();
var helper = new ScriptTagHelper
{
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
FileVersion = true,
HtmlEncoder = new TestHtmlEncoder(),
Cache = MakeCache(),
};
// Act
await helper.ProcessAsync(context, output);
// Assert
Assert.Equal(
"<script src=\"HtmlEncode[[/js/site.js?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk]]\">" +
"</script>", output.Content.GetContent());
}
[Fact]
public async Task RenderScriptTags_WithFileVersion_AndRequestPathBase()
{
// Arrange
var context = MakeTagHelperContext(
attributes: new Dictionary<string, object>
{
["src"] = "/bar/js/site.js",
["asp-file-version"] = "true"
});
var output = MakeTagHelperOutput("script", attributes: new Dictionary<string, string>
{
["src"] = "/bar/js/site.js"
});
var logger = new Mock<ILogger<ScriptTagHelper>>();
var hostingEnvironment = MakeHostingEnvironment();
var viewContext = MakeViewContext("/bar");
var helper = new ScriptTagHelper
{
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
FileVersion = true,
HtmlEncoder = new TestHtmlEncoder(),
Cache = MakeCache(),
};
// Act
await helper.ProcessAsync(context, output);
// Assert
Assert.Equal(
"<script src=\"HtmlEncode[[/bar/js/site.js?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk]]\">" +
"</script>", output.Content.GetContent());
}
[Fact]
public async Task RenderScriptTags_FallbackSrc_WithFileVersion()
{
// Arrange
var context = MakeTagHelperContext(
attributes: new Dictionary<string, object>
{
["src"] = "/js/site.js",
["asp-fallback-src-include"] = "fallback.js",
["asp-fallback-test"] = "isavailable()",
["asp-file-version"] = "true"
});
var output = MakeTagHelperOutput("script", attributes: new Dictionary<string, string>
{
["src"] = "/js/site.js"
});
var logger = new Mock<ILogger<ScriptTagHelper>>();
var hostingEnvironment = MakeHostingEnvironment();
var viewContext = MakeViewContext();
var helper = new ScriptTagHelper
{
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
FallbackSrc = "fallback.js",
FallbackTestExpression = "isavailable()",
FileVersion = true,
HtmlEncoder = new TestHtmlEncoder(),
Cache = MakeCache(),
};
// Act
await helper.ProcessAsync(context, output);
// Assert
Assert.Equal(
"<script src=\"HtmlEncode[[/js/site.js?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk]]\">" +
"</script>\r\n<script>(isavailable()||document.write(\"<script src=\\\"HtmlEncode[[fallback.js" +
"?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk]]\\\"><\\/script>\"));</script>",
output.Content.GetContent());
}
[Fact]
public async Task RenderScriptTags_GlobbedSrc_WithFileVersion()
{
// Arrange
var context = MakeTagHelperContext(
attributes: new Dictionary<string, object>
{
["src"] = "/js/site.js",
["asp-src-include"] = "*.js",
["asp-file-version"] = "true"
});
var output = MakeTagHelperOutput("script", attributes: new Dictionary<string, string>
{
["src"] = "/js/site.js"
});
var logger = new Mock<ILogger<ScriptTagHelper>>();
var hostingEnvironment = MakeHostingEnvironment();
var viewContext = MakeViewContext();
var globbingUrlBuilder = new Mock<GlobbingUrlBuilder>();
globbingUrlBuilder.Setup(g => g.BuildUrlList("/js/site.js", "*.js", null))
.Returns(new[] { "/js/site.js", "/common.js" });
var helper = new ScriptTagHelper
{
GlobbingUrlBuilder = globbingUrlBuilder.Object,
Logger = logger.Object,
HostingEnvironment = hostingEnvironment,
ViewContext = viewContext,
SrcInclude = "*.js",
FileVersion = true,
HtmlEncoder = new TestHtmlEncoder(),
Cache = MakeCache(),
};
// Act
await helper.ProcessAsync(context, output);
// Assert
Assert.Equal("<script src=\"HtmlEncode[[/js/site.js?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk]]\">" +
"</script><script src=\"HtmlEncode[[/common.js?v=f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk]]\">" +
"</script>", output.Content.GetContent());
}
private TagHelperContext MakeTagHelperContext(
IDictionary<string, object> attributes = null,
string content = null)
@ -457,9 +728,14 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
});
}
private static ViewContext MakeViewContext()
private static ViewContext MakeViewContext(string requestPathBase = null)
{
var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor());
if (requestPathBase != null)
{
actionContext.HttpContext.Request.PathBase = new Http.PathString(requestPathBase);
}
var metadataProvider = new EmptyModelMetadataProvider();
var viewData = new ViewDataDictionary(metadataProvider);
var viewContext = new ViewContext(
@ -489,15 +765,59 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
var emptyDirectoryContents = new Mock<IDirectoryContents>();
emptyDirectoryContents.Setup(dc => dc.GetEnumerator())
.Returns(Enumerable.Empty<IFileInfo>().GetEnumerator());
var mockFile = new Mock<IFileInfo>();
mockFile.SetupGet(f => f.Exists).Returns(true);
mockFile
.Setup(m => m.CreateReadStream())
.Returns(() => new MemoryStream(Encoding.UTF8.GetBytes("Hello World!")));
var mockFileProvider = new Mock<IFileProvider>();
mockFileProvider.Setup(fp => fp.GetDirectoryContents(It.IsAny<string>()))
.Returns(emptyDirectoryContents.Object);
mockFileProvider.Setup(fp => fp.GetFileInfo(It.IsAny<string>()))
.Returns(mockFile.Object);
var hostingEnvironment = new Mock<IHostingEnvironment>();
hostingEnvironment.Setup(h => h.WebRootFileProvider).Returns(mockFileProvider.Object);
return hostingEnvironment.Object;
}
private static IApplicationEnvironment MakeApplicationEnvironment(string applicationName = "testApplication")
{
var applicationEnvironment = new Mock<IApplicationEnvironment>();
applicationEnvironment.Setup(a => a.ApplicationName).Returns(applicationName);
return applicationEnvironment.Object;
}
private static IMemoryCache MakeCache(object result = null)
{
var cache = new Mock<IMemoryCache>();
cache.CallBase = true;
cache.Setup(c => c.TryGetValue(It.IsAny<string>(), It.IsAny<IEntryLink>(), out result))
.Returns(result != null);
var cacheSetContext = new Mock<ICacheSetContext>();
cacheSetContext.Setup(c => c.AddExpirationTrigger(It.IsAny<IExpirationTrigger>()));
cache
.Setup(
c => c.Set(
/*key*/ It.IsAny<string>(),
/*link*/ It.IsAny<IEntryLink>(),
/*state*/ It.IsAny<object>(),
/*create*/ It.IsAny<Func<ICacheSetContext, object>>()))
.Returns((
string input,
IEntryLink entryLink,
object state,
Func<ICacheSetContext, object> create) =>
{
{
cacheSetContext.Setup(c => c.State).Returns(state);
return create(cacheSetContext.Object);
}
});
return cache.Object;
}
private class TestHtmlEncoder : IHtmlEncoder
{
public string HtmlEncode(string value)

View File

@ -181,6 +181,23 @@
asp-fallback-href="~/site.css"
asp-fallback-test-class="hidden"
asp-fallback-test-property="visibility" />
<!-- Plain link tag with file version -->
<link href="~/site.css" rel="stylesheet" asp-file-version="true" />
<!-- Globbed link tag with existing file and file version -->
<link asp-href-include="**/site.css" rel="stylesheet" asp-file-version="true" />
<!-- Fallback with file version -->
<link href="~/site.min.css" rel="stylesheet" data-extra="test"
asp-fallback-href="~/site.css"
asp-fallback-test-class="hidden"
asp-fallback-test-property="visibility"
asp-fallback-test-value="hidden"
asp-file-version="true" />
<!-- Globbed link tag with existing file, static href and file version -->
<link href="~/site.css" asp-href-include="**/*.css" rel="stylesheet" asp-file-version="true" />
</head>
<body>

View File

@ -105,5 +105,27 @@
<script src="~/site.js" asp-src-include="**/site.js">
// Globbed script tag with existing file and static src should dedupe
</script>
<script src="~/blank.js" asp-fallback-src="~/site.js" asp-fallback-test="false" asp-file-version="true">
// TagHelper script with comment in body, and file version.
</script>
<script src="~/blank.js" asp-fallback-src-include="**/site.js" asp-fallback-test="false" asp-file-version="true">
// Fallback to globbed src with file version.
</script>
<script src="~/site.js" asp-file-version="true">
// Regular script with comment in body, and file version.
</script>
<!-- Globbed script tag with existing files and version -->
<script asp-src-include="**/*.js" asp-file-version="true">
// This comment shouldn't be emitted
</script>
<!-- Globbed script tag with existing file, exclude and version -->
<script asp-src-include="**/*.js" asp-src-exclude="**/site3.js" asp-file-version="true">
// This comment shouldn't be emitted
</script>
</body>
</html>