diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCache.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCache.cs index 1445ae10b5..95d10908f5 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCache.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCache.cs @@ -6,6 +6,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; +using Microsoft.AspNet.FileSystems; namespace Microsoft.AspNet.Mvc.Razor { @@ -13,22 +14,30 @@ namespace Microsoft.AspNet.Mvc.Razor public class CompilerCache : ICompilerCache { private readonly ConcurrentDictionary _cache; - private static readonly Type[] EmptyType = new Type[0]; + private readonly IFileSystem _fileSystem; /// - /// Sets up the runtime compilation cache. + /// Initializes a new instance of populated with precompiled views + /// discovered using . /// /// /// An representing the assemblies /// used to search for pre-compiled views. /// - public CompilerCache([NotNull] IAssemblyProvider provider) - : this(GetFileInfos(provider.CandidateAssemblies)) + /// An instance that represents the application's + /// file system. + /// + public CompilerCache(IAssemblyProvider provider, IRazorFileSystemCache fileSystem) + : this (GetFileInfos(provider.CandidateAssemblies), fileSystem) { } - internal CompilerCache(IEnumerable viewCollections) : this() + // Internal for unit testing + internal CompilerCache(IEnumerable viewCollections, IFileSystem fileSystem) { + _fileSystem = fileSystem; + _cache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + foreach (var viewCollection in viewCollections) { foreach (var fileInfo in viewCollection.FileInfos) @@ -42,11 +51,22 @@ namespace Microsoft.AspNet.Mvc.Razor _cache.TryAdd(NormalizePath(fileInfo.RelativePath), cacheEntry); } } - } - internal CompilerCache() - { - _cache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + // Set up ViewStarts + foreach (var entry in _cache) + { + var viewStartLocations = ViewStartUtility.GetViewStartLocations(entry.Key); + foreach (var location in viewStartLocations) + { + CompilerCacheEntry viewStartEntry; + if (_cache.TryGetValue(location, out viewStartEntry)) + { + // Add the the composite _ViewStart entry as a dependency. + entry.Value.AssociatedViewStartEntry = viewStartEntry; + break; + } + } + } } internal static IEnumerable @@ -62,7 +82,7 @@ namespace Microsoft.AspNet.Mvc.Razor var inAssemblyType = typeof(RazorFileInfoCollection); if (inAssemblyType.IsAssignableFrom(t)) { - var hasParameterlessConstructor = t.GetConstructor(EmptyType) != null; + var hasParameterlessConstructor = t.GetConstructor(Type.EmptyTypes) != null; return hasParameterlessConstructor && !t.GetTypeInfo().IsAbstract @@ -74,56 +94,114 @@ namespace Microsoft.AspNet.Mvc.Razor /// public CompilationResult GetOrAdd([NotNull] RelativeFileInfo fileInfo, - [NotNull] Func compile) + [NotNull] Func compile) { - CompilerCacheEntry cacheEntry; - if (!_cache.TryGetValue(NormalizePath(fileInfo.RelativePath), out cacheEntry)) - { - return OnCacheMiss(fileInfo, compile); - } - else - { - if (cacheEntry.Length != fileInfo.FileInfo.Length) - { - // Recompile if the file lengths differ - return OnCacheMiss(fileInfo, compile); - } - - if (cacheEntry.LastModified == fileInfo.FileInfo.LastModified) - { - // Match, not update needed - return CompilationResult.Successful(cacheEntry.CompiledType); - } - - var hash = RazorFileHash.GetHash(fileInfo.FileInfo); - - // Timestamp doesn't match but it might be because of deployment, compare the hash. - if (cacheEntry.IsPreCompiled && - string.Equals(cacheEntry.Hash, hash, StringComparison.Ordinal)) - { - // Cache hit, but we need to update the entry - return OnCacheMiss(fileInfo, - () => CompilationResult.Successful(cacheEntry.CompiledType)); - } - - // it's not a match, recompile - return OnCacheMiss(fileInfo, compile); - } - } - - private CompilationResult OnCacheMiss(RelativeFileInfo file, - Func compile) - { - var result = compile(); - - var cacheEntry = new CompilerCacheEntry(file, result.CompiledType); - _cache[NormalizePath(file.RelativePath)] = cacheEntry; - + CompilationResult result; + var entry = GetOrAdd(fileInfo, compile, out result); return result; } - private string NormalizePath(string path) + private CompilerCacheEntry GetOrAdd(RelativeFileInfo relativeFileInfo, + Func compile, + out CompilationResult result) { + CompilerCacheEntry cacheEntry; + var normalizedPath = NormalizePath(relativeFileInfo.RelativePath); + if (!_cache.TryGetValue(normalizedPath, out cacheEntry)) + { + return OnCacheMiss(relativeFileInfo, normalizedPath, compile, out result); + } + else + { + var fileInfo = relativeFileInfo.FileInfo; + if (cacheEntry.Length != fileInfo.Length) + { + // Recompile if the file lengths differ + return OnCacheMiss(relativeFileInfo, normalizedPath, compile, out result); + } + + if (AssociatedViewStartsChanged(cacheEntry, compile)) + { + // Recompile if the view starts have changed since the entry was created. + return OnCacheMiss(relativeFileInfo, normalizedPath, compile, out result); + } + + if (cacheEntry.LastModified == fileInfo.LastModified) + { + result = CompilationResult.Successful(cacheEntry.CompiledType); + return cacheEntry; + } + + // Timestamp doesn't match but it might be because of deployment, compare the hash. + if (cacheEntry.IsPreCompiled && + string.Equals(cacheEntry.Hash, RazorFileHash.GetHash(fileInfo), StringComparison.Ordinal)) + { + // Cache hit, but we need to update the entry. + // Assigning to LastModified is an atomic operation and will result in a safe race if it is + // being concurrently read and written or updated concurrently. + cacheEntry.LastModified = fileInfo.LastModified; + result = CompilationResult.Successful(cacheEntry.CompiledType); + + return cacheEntry; + } + + // it's not a match, recompile + return OnCacheMiss(relativeFileInfo, normalizedPath, compile, out result); + } + } + + private CompilerCacheEntry OnCacheMiss(RelativeFileInfo file, + string normalizedPath, + Func compile, + out CompilationResult result) + { + result = compile(file); + + var cacheEntry = new CompilerCacheEntry(file, result.CompiledType) + { + AssociatedViewStartEntry = GetCompositeViewStartEntry(normalizedPath, compile) + }; + + // The cache is a concurrent dictionary, so concurrent addition to it with the same key would result in a + // safe race. + _cache[normalizedPath] = cacheEntry; + return cacheEntry; + } + + private bool AssociatedViewStartsChanged(CompilerCacheEntry entry, + Func compile) + { + var viewStartEntry = GetCompositeViewStartEntry(entry.RelativePath, compile); + return entry.AssociatedViewStartEntry != viewStartEntry; + } + + // Returns the entry for the nearest _ViewStart that the file inherits directives from. Since _ViewStart + // entries are affected by other _ViewStart entries that are in the path hierarchy, the returned value + // represents the composite result of performing a cache check on individual _ViewStart entries. + private CompilerCacheEntry GetCompositeViewStartEntry(string relativePath, + Func compile) + { + var viewStartLocations = ViewStartUtility.GetViewStartLocations(relativePath); + foreach (var viewStartLocation in viewStartLocations) + { + var viewStartFileInfo = _fileSystem.GetFileInfo(viewStartLocation); + if (viewStartFileInfo.Exists) + { + var relativeFileInfo = new RelativeFileInfo(viewStartFileInfo, viewStartLocation); + CompilationResult result; + return GetOrAdd(relativeFileInfo, compile, out result); + } + } + + // No _ViewStarts discovered. + return null; + } + + private static string NormalizePath(string path) + { + // We need to allow for scenarios where the application was precompiled on a machine with forward slashes + // but is being run in one with backslashes (or vice versa). To this effect, we'll normalize paths to + // use backslashes for lookups and storage in the dictionary. path = path.Replace('/', '\\'); path = path.TrimStart('\\'); diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCacheEntry.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCacheEntry.cs index 4972a1f3fd..bf41278741 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCacheEntry.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCacheEntry.cs @@ -53,9 +53,9 @@ namespace Microsoft.AspNet.Mvc.Razor public long Length { get; private set; } /// - /// Gets the last modified for the file that was compiled at the time of compilation. + /// Gets or sets the last modified for the file at the time of compilation. /// - public DateTime LastModified { get; private set; } + public DateTime LastModified { get; set; } /// /// Gets the file hash, should only be available for pre compiled files. @@ -66,5 +66,11 @@ namespace Microsoft.AspNet.Mvc.Razor /// Gets a flag that indicates if the file is precompiled. /// public bool IsPreCompiled { get { return Hash != null; } } + + /// + /// Gets or sets the for the nearest ViewStart that the compiled type + /// depends on. + /// + public CompilerCacheEntry AssociatedViewStartEntry { get; set; } } } diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/ExpiringFileInfoCache.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/DefaultRazorFileSystemCache.cs similarity index 58% rename from src/Microsoft.AspNet.Mvc.Razor/Compilation/ExpiringFileInfoCache.cs rename to src/Microsoft.AspNet.Mvc.Razor/Compilation/DefaultRazorFileSystemCache.cs index 1fe390c474..82dd51d9fd 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Compilation/ExpiringFileInfoCache.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/DefaultRazorFileSystemCache.cs @@ -9,9 +9,10 @@ using Microsoft.Framework.OptionsModel; namespace Microsoft.AspNet.Mvc.Razor { /// - /// A default implementation for the interface. + /// Default implementation for the interface that caches + /// the results of . /// - public class ExpiringFileInfoCache : IFileInfoCache + public class DefaultRazorFileSystemCache : IRazorFileSystemCache { private readonly ConcurrentDictionary _fileInfoCache = new ConcurrentDictionary(StringComparer.Ordinal); @@ -19,7 +20,11 @@ namespace Microsoft.AspNet.Mvc.Razor private readonly IFileSystem _fileSystem; private readonly TimeSpan _offset; - public ExpiringFileInfoCache(IOptions optionsAccessor) + /// + /// Initializes a new instance of . + /// + /// Accessor to . + public DefaultRazorFileSystemCache(IOptions optionsAccessor) { _fileSystem = optionsAccessor.Options.FileSystem; _offset = optionsAccessor.Options.ExpirationBeforeCheckingFilesOnDisk; @@ -34,21 +39,25 @@ namespace Microsoft.AspNet.Mvc.Razor } /// - public IFileInfo GetFileInfo([NotNull] string virtualPath) + public IDirectoryContents GetDirectoryContents(string subpath) { - IFileInfo fileInfo; - ExpiringFileInfo expiringFileInfo; + return _fileSystem.GetDirectoryContents(subpath); + } + /// + public IFileInfo GetFileInfo(string subpath) + { + ExpiringFileInfo expiringFileInfo; var utcNow = UtcNow; - if (_fileInfoCache.TryGetValue(virtualPath, out expiringFileInfo) - && expiringFileInfo.ValidUntil > utcNow) + if (_fileInfoCache.TryGetValue(subpath, out expiringFileInfo) && + expiringFileInfo.ValidUntil > utcNow) { - fileInfo = expiringFileInfo.FileInfo; + return expiringFileInfo.FileInfo; } else { - fileInfo = _fileSystem.GetFileInfo(virtualPath); + var fileInfo = _fileSystem.GetFileInfo(subpath); expiringFileInfo = new ExpiringFileInfo() { @@ -56,10 +65,10 @@ namespace Microsoft.AspNet.Mvc.Razor ValidUntil = _offset == TimeSpan.MaxValue ? DateTime.MaxValue : utcNow.Add(_offset), }; - _fileInfoCache.AddOrUpdate(virtualPath, expiringFileInfo, (a, b) => expiringFileInfo); - } + _fileInfoCache[subpath] = expiringFileInfo; - return fileInfo; + return fileInfo; + } } private class ExpiringFileInfo diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/ICompilerCache.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/ICompilerCache.cs index 474dddb4fd..e7a08a82ec 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Compilation/ICompilerCache.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/ICompilerCache.cs @@ -6,18 +6,18 @@ using System; namespace Microsoft.AspNet.Mvc.Razor { /// - /// Caches the result of runtime compilation for the duration of the app lifetime. + /// Caches the result of runtime compilation of Razor files for the duration of the app lifetime. /// public interface ICompilerCache { /// /// Get an existing compilation result, or create and add a new one if it is - /// not available in the cache. + /// not available in the cache or is expired. /// /// A representing the file. /// An delegate that will generate a compilation result. /// A cached . CompilationResult GetOrAdd([NotNull] RelativeFileInfo fileInfo, - [NotNull] Func compile); + [NotNull] Func compile); } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/IFileInfoCache.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/IFileInfoCache.cs deleted file mode 100644 index a6408a06b9..0000000000 --- a/src/Microsoft.AspNet.Mvc.Razor/Compilation/IFileInfoCache.cs +++ /dev/null @@ -1,20 +0,0 @@ -// 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 Microsoft.AspNet.FileSystems; - -namespace Microsoft.AspNet.Mvc.Razor -{ - /// - /// Provides cached access to file infos. - /// - public interface IFileInfoCache - { - /// - /// Returns a cached for a given path. - /// - /// The virtual path. - /// The cached . - IFileInfo GetFileInfo(string virtualPath); - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/IRazorFileSystemCache.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/IRazorFileSystemCache.cs new file mode 100644 index 0000000000..333dfe668c --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/IRazorFileSystemCache.cs @@ -0,0 +1,15 @@ +// 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 Microsoft.AspNet.FileSystems; + +namespace Microsoft.AspNet.Mvc.Razor +{ + /// + /// An that caches the results of for a + /// duration specified by . + /// + public interface IRazorFileSystemCache : IFileSystem + { + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/RazorViewEngineOptions.cs b/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/RazorViewEngineOptions.cs index 3585041721..828390e275 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/RazorViewEngineOptions.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/RazorViewEngineOptions.cs @@ -17,7 +17,9 @@ namespace Microsoft.AspNet.Mvc.Razor private IFileSystem _fileSystem; /// - /// Controls the caching behavior. + /// Gets or sets the that specifies the duration for which results of + /// are cached by . + /// is used to query for file changes during Razor compilation. /// /// /// of or less, means no caching. diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollectionGenerator.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollectionGenerator.cs index 3ff31eca2c..7d3b9e5dad 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollectionGenerator.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollectionGenerator.cs @@ -99,7 +99,7 @@ namespace __ASP_ASSEMBLY " info = new " + nameof(RazorFileInfo) + @" {{ - " + nameof(RazorFileInfo.LastModified) + @" = DateTime.FromFileTimeUtc({0:D}), + " + nameof(RazorFileInfo.LastModified) + @" = DateTime.FromFileTimeUtc({0:D}).ToLocalTime(), " + nameof(RazorFileInfo.Length) + @" = {1:D}, " + nameof(RazorFileInfo.RelativePath) + @" = @""{2}"", " + nameof(RazorFileInfo.FullTypeName) + @" = @""{3}"", diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorPreCompiler.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorPreCompiler.cs index 9ca28a53c2..2d69ef1ff6 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorPreCompiler.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorPreCompiler.cs @@ -99,12 +99,8 @@ namespace Microsoft.AspNet.Mvc.Razor else if (Path.GetExtension(fileInfo.Name) .Equals(FileExtension, StringComparison.OrdinalIgnoreCase)) { - var info = new RelativeFileInfo() - { - FileInfo = fileInfo, - RelativePath = Path.Combine(currentPath, fileInfo.Name), - }; - + var relativePath = Path.Combine(currentPath, fileInfo.Name); + var info = new RelativeFileInfo(fileInfo, relativePath); yield return info; } } diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RelativeFileInfo.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RelativeFileInfo.cs index c561fdcba9..d0100620da 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RelativeFileInfo.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RelativeFileInfo.cs @@ -1,13 +1,41 @@ // 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 Microsoft.AspNet.FileSystems; namespace Microsoft.AspNet.Mvc.Razor { + /// + /// A container type that represents along with the application base relative path + /// for a file in the file system. + /// public class RelativeFileInfo { - public IFileInfo FileInfo { get; set; } - public string RelativePath { get; set; } + /// + /// Initializes a new instance of . + /// + /// for the file. + /// Path of the file relative to the application base. + public RelativeFileInfo([NotNull] IFileInfo fileInfo, string relativePath) + { + if (string.IsNullOrEmpty(relativePath)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(relativePath)); + } + + FileInfo = fileInfo; + RelativePath = relativePath; + } + + /// + /// Gets the associated with this instance of . + /// + public IFileInfo FileInfo { get; } + + /// + /// Gets the path of the file relative to the application base. + /// + public string RelativePath { get; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/VirtualPathRazorPageFactory.cs b/src/Microsoft.AspNet.Mvc.Razor/VirtualPathRazorPageFactory.cs index b9cbbc8319..befa9540ed 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/VirtualPathRazorPageFactory.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/VirtualPathRazorPageFactory.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNet.FileSystems; using Microsoft.AspNet.Http; using Microsoft.AspNet.PageExecutionInstrumentation; using Microsoft.Framework.DependencyInjection; @@ -16,19 +17,19 @@ namespace Microsoft.AspNet.Mvc.Razor { private readonly ITypeActivator _activator; private readonly IServiceProvider _serviceProvider; - private readonly IFileInfoCache _fileInfoCache; + private readonly IRazorFileSystemCache _fileSystemCache; private readonly ICompilerCache _compilerCache; private IRazorCompilationService _razorcompilationService; public VirtualPathRazorPageFactory(ITypeActivator typeActivator, IServiceProvider serviceProvider, ICompilerCache compilerCache, - IFileInfoCache fileInfoCache) + IRazorFileSystemCache fileSystemCache) { _activator = typeActivator; _serviceProvider = serviceProvider; _compilerCache = compilerCache; - _fileInfoCache = fileInfoCache; + _fileSystemCache = fileSystemCache; } private IRazorCompilationService RazorCompilationService @@ -56,19 +57,15 @@ namespace Microsoft.AspNet.Mvc.Razor relativePath = relativePath.Substring(1); } - var fileInfo = _fileInfoCache.GetFileInfo(relativePath); + var fileInfo = _fileSystemCache.GetFileInfo(relativePath); if (fileInfo.Exists) { - var relativeFileInfo = new RelativeFileInfo() - { - FileInfo = fileInfo, - RelativePath = relativePath, - }; + var relativeFileInfo = new RelativeFileInfo(fileInfo, relativePath); var result = _compilerCache.GetOrAdd( relativeFileInfo, - () => RazorCompilationService.Compile(relativeFileInfo)); + RazorCompilationService.Compile); var page = (IRazorPage)_activator.CreateInstance(_serviceProvider, result.CompiledType); page.Path = relativePath; @@ -78,10 +75,5 @@ namespace Microsoft.AspNet.Mvc.Razor return null; } - - private static bool IsInstrumentationEnabled(HttpContext context) - { - return context.GetFeature() != null; - } } } diff --git a/src/Microsoft.AspNet.Mvc/MvcServices.cs b/src/Microsoft.AspNet.Mvc/MvcServices.cs index a53a0aa5d8..d02390f793 100644 --- a/src/Microsoft.AspNet.Mvc/MvcServices.cs +++ b/src/Microsoft.AspNet.Mvc/MvcServices.cs @@ -108,16 +108,20 @@ namespace Microsoft.AspNet.Mvc yield return describe.Transient(); // Caches view locations that are valid for the lifetime of the application. yield return describe.Singleton(); - yield return describe.Singleton(); + yield return describe.Singleton(); // The host is designed to be discarded after consumption and is very inexpensive to initialize. yield return describe.Transient(serviceProvider => { - var optionsAccessor = serviceProvider.GetRequiredService>(); - return new MvcRazorHost(optionsAccessor.Options.FileSystem); + var cachedFileSystem = serviceProvider.GetRequiredService(); + return new MvcRazorHost(cachedFileSystem); }); + // Caches compilation artifacts across the lifetime of the application. yield return describe.Singleton(); + + // This caches compilation related details that are valid across the lifetime of the application + // and is required to be a singleton. yield return describe.Singleton(); // Both the compiler cache and roslyn compilation service hold on the compilation related diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/PrecompilationTest.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/PrecompilationTest.cs index 985905a773..9734386b05 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/PrecompilationTest.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/PrecompilationTest.cs @@ -2,17 +2,22 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.IO; +using System.Linq; using System.Net; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNet.Builder; using Microsoft.AspNet.TestHost; +using Microsoft.Framework.DependencyInjection; +using Microsoft.Framework.Runtime; using Xunit; namespace Microsoft.AspNet.Mvc.FunctionalTests { public class PrecompilationTest { + private static readonly TimeSpan _cacheDelayInterval = TimeSpan.FromSeconds(2); private readonly IServiceProvider _services = TestHelper.CreateServices("PrecompilationWebSite"); private readonly Action _app = new PrecompilationWebSite.Startup().Configure; @@ -20,20 +25,155 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests public async Task PrecompiledView_RendersCorrectly() { // Arrange + var applicationEnvironment = _services.GetRequiredService(); + + var viewsDirectory = Path.Combine(applicationEnvironment.ApplicationBasePath, "Views", "Home"); + var layoutContent = File.ReadAllText(Path.Combine(viewsDirectory, "Layout.cshtml")); + var indexContent = File.ReadAllText(Path.Combine(viewsDirectory, "Index.cshtml")); + var viewstartContent = File.ReadAllText(Path.Combine(viewsDirectory, "_viewstart.cshtml")); + var server = TestServer.Create(_services, _app); var client = server.CreateClient(); // We will render a view that writes the fully qualified name of the Assembly containing the type of // the view. If the view is precompiled, this assembly will be PrecompilationWebsite. - var expectedContent = typeof(PrecompilationWebSite.Startup).GetTypeInfo().Assembly.GetName().ToString(); + var assemblyName = typeof(PrecompilationWebSite.Startup).GetTypeInfo().Assembly.GetName().ToString(); - // Act - var response = await client.GetAsync("http://localhost/Home/Index"); - var responseContent = await response.Content.ReadAsStringAsync(); + try + { + // Act - 1 + var response = await client.GetAsync("http://localhost/Home/Index"); + var responseContent = await response.Content.ReadAsStringAsync(); - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedContent, responseContent); + // Assert - 1 + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var parsedResponse1 = new ParsedResponse(responseContent); + Assert.Equal(assemblyName, parsedResponse1.ViewStart); + Assert.Equal(assemblyName, parsedResponse1.Layout); + Assert.Equal(assemblyName, parsedResponse1.Index); + + // Act - 2 + // Touch the Layout file and verify it is now dynamically compiled. + await TouchFile(viewsDirectory, "Layout.cshtml"); + responseContent = await client.GetStringAsync("http://localhost/Home/Index"); + + // Assert - 2 + var response2 = new ParsedResponse(responseContent); + Assert.NotEqual(assemblyName, response2.Layout); + Assert.Equal(assemblyName, response2.ViewStart); + Assert.Equal(assemblyName, response2.Index); + + // Act - 3 + // Touch the _ViewStart file and verify it causes all files to recompile. + await TouchFile(viewsDirectory, "_viewstart.cshtml"); + responseContent = await client.GetStringAsync("http://localhost/Home/Index"); + + // Assert - 3 + var response3 = new ParsedResponse(responseContent); + Assert.NotEqual(assemblyName, response3.ViewStart); + Assert.NotEqual(assemblyName, response3.Index); + Assert.NotEqual(response2.Layout, response3.Layout); + + // Act - 4 + // Touch Index file and verify it is the only page that recompiles. + await TouchFile(viewsDirectory, "Index.cshtml"); + responseContent = await client.GetStringAsync("http://localhost/Home/Index"); + + // Assert - 4 + var response4 = new ParsedResponse(responseContent); + // Layout and _ViewStart should not have changed. + Assert.Equal(response3.Layout, response4.Layout); + Assert.Equal(response3.ViewStart, response4.ViewStart); + Assert.NotEqual(response3.Index, response4.Index); + + // Act - 5 + // Touch the _ViewStart file. This time, we'll verify the Non-precompiled -> Non-precompiled workflow. + await TouchFile(viewsDirectory, "_viewstart.cshtml"); + responseContent = await client.GetStringAsync("http://localhost/Home/Index"); + + // Assert - 5 + var response5 = new ParsedResponse(responseContent); + // Everything should've recompiled. + Assert.NotEqual(response4.ViewStart, response5.ViewStart); + Assert.NotEqual(response4.Index, response5.Index); + Assert.NotEqual(response4.Layout, response5.Layout); + + // Act - 6 + // Add a new _ViewStart file + File.WriteAllText(Path.Combine(viewsDirectory, "..", "_viewstart.cshtml"), string.Empty); + await Task.Delay(_cacheDelayInterval); + responseContent = await client.GetStringAsync("http://localhost/Home/Index"); + + // Assert - 6 + // Everything should've recompiled. + var response6 = new ParsedResponse(responseContent); + Assert.NotEqual(response5.ViewStart, response6.ViewStart); + Assert.NotEqual(response5.Index, response6.Index); + Assert.NotEqual(response5.Layout, response6.Layout); + + // Act - 7 + // Remove new _ViewStart file + File.Delete(Path.Combine(viewsDirectory, "..", "_viewstart.cshtml")); + await Task.Delay(_cacheDelayInterval); + responseContent = await client.GetStringAsync("http://localhost/Home/Index"); + + // Assert - 7 + // Everything should've recompiled. + var response7 = new ParsedResponse(responseContent); + Assert.NotEqual(response6.ViewStart, response7.ViewStart); + Assert.NotEqual(response6.Index, response7.Index); + Assert.NotEqual(response6.Layout, response7.Layout); + + // Act - 8 + // Refetch and verify we get cached types + responseContent = await client.GetStringAsync("http://localhost/Home/Index"); + + // Assert - 7 + var response8 = new ParsedResponse(responseContent); + Assert.Equal(response7.ViewStart, response8.ViewStart); + Assert.Equal(response7.Index, response8.Index); + Assert.Equal(response7.Layout, response8.Layout); + } + finally + { + File.WriteAllText(Path.Combine(viewsDirectory, "Layout.cshtml"), layoutContent); + File.WriteAllText(Path.Combine(viewsDirectory, "Index.cshtml"), indexContent); + File.WriteAllText(Path.Combine(viewsDirectory, "_viewstart.cshtml"), viewstartContent); + } } + + private static Task TouchFile(string viewsDir, string file) + { + File.AppendAllText(Path.Combine(viewsDir, file), " "); + // Delay to ensure we don't hit the cached file system. + return Task.Delay(_cacheDelayInterval); + } + + private sealed class ParsedResponse + { + public ParsedResponse(string responseContent) + { + var results = responseContent.Split(new[] { Environment.NewLine }, + StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .ToArray(); + + Assert.True(results[0].StartsWith("Layout:")); + Layout = results[0].Substring("Layout:".Length); + + Assert.True(results[1].StartsWith("_viewstart:")); + ViewStart = results[1].Substring("_viewstart:".Length); + + Assert.True(results[2].StartsWith("index:")); + Index = results[2].Substring("index:".Length); + } + + public string Layout { get; } + + public string ViewStart { get; } + + public string Index { get; } + } + } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Razor.Host.Test/TestFileInfo.cs b/test/Microsoft.AspNet.Mvc.Razor.Host.Test/TestFileInfo.cs new file mode 100644 index 0000000000..6941503f81 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Razor.Host.Test/TestFileInfo.cs @@ -0,0 +1,70 @@ +// 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.FileSystems; +using Microsoft.Framework.Expiration.Interfaces; + +namespace Microsoft.AspNet.Mvc.Razor +{ + public class TestFileInfo : IFileInfo + { + private string _content; + + public bool IsDirectory { get; } = false; + + public DateTime LastModified { get; set; } + + public long Length { get; set; } + + public string Name { get; set; } + + public string PhysicalPath { get; set; } + + public string Content + { + get { return _content; } + set + { + _content = value; + Length = Encoding.UTF8.GetByteCount(Content); + } + } + + public bool Exists + { + get { return true; } + } + + public bool IsReadOnly + { + get + { + throw new NotSupportedException(); + } + } + + public Stream CreateReadStream() + { + var bytes = Encoding.UTF8.GetBytes(Content); + return new MemoryStream(bytes); + } + + public void WriteContent(byte[] content) + { + throw new NotSupportedException(); + } + + public void Delete() + { + throw new NotSupportedException(); + } + + public IExpirationTrigger CreateFileChangeTrigger() + { + throw new NotSupportedException(); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Razor.Host.Test/TestFileSystem.cs b/test/Microsoft.AspNet.Mvc.Razor.Host.Test/TestFileSystem.cs index b3d5ab8ca0..3b7f2d1c7c 100644 --- a/test/Microsoft.AspNet.Mvc.Razor.Host.Test/TestFileSystem.cs +++ b/test/Microsoft.AspNet.Mvc.Razor.Host.Test/TestFileSystem.cs @@ -4,9 +4,7 @@ using System; using System.Collections.Generic; using System.IO; -using System.Text; using Microsoft.AspNet.FileSystems; -using Moq; namespace Microsoft.AspNet.Mvc.Razor { @@ -17,26 +15,30 @@ namespace Microsoft.AspNet.Mvc.Razor public IDirectoryContents GetDirectoryContents(string subpath) { - throw new NotImplementedException(); + throw new NotSupportedException(); } public void AddFile(string path, string contents) { - var fileInfo = new Mock(); - fileInfo.Setup(f => f.CreateReadStream()) - .Returns(() => new MemoryStream(Encoding.UTF8.GetBytes(contents))); - fileInfo.SetupGet(f => f.PhysicalPath) - .Returns(path); - fileInfo.SetupGet(f => f.Name) - .Returns(Path.GetFileName(path)); - fileInfo.SetupGet(f => f.Exists) - .Returns(true); - AddFile(path, fileInfo.Object); + var fileInfo = new TestFileInfo + { + Content = contents, + PhysicalPath = path, + Name = Path.GetFileName(path), + LastModified = DateTime.UtcNow, + }; + + AddFile(path, fileInfo); } - public void AddFile(string path, IFileInfo contents) + public void AddFile(string path, TestFileInfo contents) { - _lookup.Add(path, contents); + _lookup[path] = contents; + } + + public void DeleteFile(string path) + { + _lookup.Remove(path); } public IFileInfo GetFileInfo(string subpath) @@ -50,5 +52,6 @@ namespace Microsoft.AspNet.Mvc.Razor return new NotFoundFileInfo(subpath); } } + } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/CompilerCacheTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/CompilerCacheTest.cs index 9dad480b21..6c270dd229 100644 --- a/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/CompilerCacheTest.cs +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/CompilerCacheTest.cs @@ -4,12 +4,11 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; -using Microsoft.AspNet.FileSystems; -using Moq; using Xunit; -namespace Microsoft.AspNet.Mvc.Razor.Test +namespace Microsoft.AspNet.Mvc.Razor { public class CompilerCacheTest { @@ -17,24 +16,20 @@ namespace Microsoft.AspNet.Mvc.Razor.Test public void GetOrAdd_ReturnsCompilationResultFromFactory() { // Arrange - var cache = new CompilerCache(); - var fileInfo = new Mock(); - - fileInfo - .SetupGet(i => i.LastModified) - .Returns(DateTime.FromFileTimeUtc(10000)); + var fileSystem = new TestFileSystem(); + var cache = new CompilerCache(Enumerable.Empty(), fileSystem); + var fileInfo = new TestFileInfo + { + LastModified = DateTime.FromFileTimeUtc(10000) + }; var type = GetType(); var expected = UncachedCompilationResult.Successful(type, "hello world"); - var runtimeFileInfo = new RelativeFileInfo() - { - FileInfo = fileInfo.Object, - RelativePath = "ab", - }; + var runtimeFileInfo = new RelativeFileInfo(fileInfo, "ab"); // Act - var actual = cache.GetOrAdd(runtimeFileInfo, () => expected); + var actual = cache.GetOrAdd(runtimeFileInfo, _ => expected); // Assert Assert.Same(expected, actual); @@ -75,15 +70,16 @@ namespace Microsoft.AspNet.Mvc.Razor.Test private class ViewCollection : RazorFileInfoCollection { + private readonly List _fileInfos = new List(); + public ViewCollection() { - var fileInfos = new List(); - FileInfos = fileInfos; + FileInfos = _fileInfos; var content = new PreCompile().Content; var length = Encoding.UTF8.GetByteCount(content); - fileInfos.Add(new RazorFileInfo() + Add(new RazorFileInfo() { FullTypeName = typeof(PreCompile).FullName, Hash = RazorFileHash.GetHash(GetMemoryStream(content)), @@ -92,6 +88,11 @@ namespace Microsoft.AspNet.Mvc.Razor.Test RelativePath = "ab", }); } + + public void Add(RazorFileInfo fileInfo) + { + _fileInfos.Add(fileInfo); + } } private static Stream GetMemoryStream(string content) @@ -102,81 +103,324 @@ namespace Microsoft.AspNet.Mvc.Razor.Test } [Theory] - [InlineData(typeof(RuntimeCompileIdentical), 10000, false)] - [InlineData(typeof(RuntimeCompileIdentical), 11000, false)] - [InlineData(typeof(RuntimeCompileDifferent), 10000, false)] // expected failure: same time and length - [InlineData(typeof(RuntimeCompileDifferent), 11000, true)] - [InlineData(typeof(RuntimeCompileDifferentLength), 10000, true)] - [InlineData(typeof(RuntimeCompileDifferentLength), 10000, true)] - public void FileWithTheSameLengthAndDifferentTime_DoesNot_OverridesPrecompilation( - Type resultViewType, - long fileTimeUTC, - bool swapsPreCompile) + [InlineData(10000)] + [InlineData(11000)] + [InlineData(10000)] // expected failure: same time and length + public void GetOrAdd_UsesFilesFromCache_IfTimestampDiffers_ButContentAndLengthAreTheSame(long fileTimeUTC) { // Arrange - var instance = (View)Activator.CreateInstance(resultViewType); + var instance = new RuntimeCompileIdentical(); var length = Encoding.UTF8.GetByteCount(instance.Content); - var collection = new ViewCollection(); - var cache = new CompilerCache(new[] { new ViewCollection() }); + var fileSystem = new TestFileSystem(); + var cache = new CompilerCache(new[] { new ViewCollection() }, fileSystem); - var fileInfo = new Mock(); - fileInfo - .SetupGet(i => i.Length) - .Returns(length); - fileInfo - .SetupGet(i => i.LastModified) - .Returns(DateTime.FromFileTimeUtc(fileTimeUTC)); - fileInfo.Setup(i => i.CreateReadStream()) - .Returns(GetMemoryStream(instance.Content)); - - var preCompileType = typeof(PreCompile); - - var runtimeFileInfo = new RelativeFileInfo() + var fileInfo = new TestFileInfo { - FileInfo = fileInfo.Object, + Length = length, + LastModified = DateTime.FromFileTimeUtc(fileTimeUTC), + Content = instance.Content + }; + + var runtimeFileInfo = new RelativeFileInfo(fileInfo, "ab"); + + var precompiledContent = new PreCompile().Content; + var razorFileInfo = new RazorFileInfo + { + FullTypeName = typeof(PreCompile).FullName, + Hash = RazorFileHash.GetHash(GetMemoryStream(precompiledContent)), + LastModified = DateTime.FromFileTimeUtc(10000), + Length = Encoding.UTF8.GetByteCount(precompiledContent), RelativePath = "ab", }; // Act var actual = cache.GetOrAdd(runtimeFileInfo, - compile: () => CompilationResult.Successful(resultViewType)); + compile: _ => { throw new Exception("Shouldn't be called."); }); // Assert - if (swapsPreCompile) + Assert.Equal(typeof(PreCompile), actual.CompiledType); + } + + [Theory] + [InlineData(typeof(RuntimeCompileDifferent), 11000)] + [InlineData(typeof(RuntimeCompileDifferentLength), 10000)] + [InlineData(typeof(RuntimeCompileDifferentLength), 11000)] + public void GetOrAdd_RecompilesFile_IfContentAndLengthAreChanged( + Type resultViewType, + long fileTimeUTC) + { + // Arrange + var instance = (View)Activator.CreateInstance(resultViewType); + var length = Encoding.UTF8.GetByteCount(instance.Content); + var collection = new ViewCollection(); + var fileSystem = new TestFileSystem(); + var cache = new CompilerCache(new[] { new ViewCollection() }, fileSystem); + + var fileInfo = new TestFileInfo { - Assert.Equal(actual.CompiledType, resultViewType); - } - else + Length = length, + LastModified = DateTime.FromFileTimeUtc(fileTimeUTC), + Content = instance.Content + }; + + var runtimeFileInfo = new RelativeFileInfo(fileInfo, "ab"); + + var precompiledContent = new PreCompile().Content; + var razorFileInfo = new RazorFileInfo { - Assert.Equal(actual.CompiledType, typeof(PreCompile)); + FullTypeName = typeof(PreCompile).FullName, + Hash = RazorFileHash.GetHash(GetMemoryStream(precompiledContent)), + LastModified = DateTime.FromFileTimeUtc(10000), + Length = Encoding.UTF8.GetByteCount(precompiledContent), + RelativePath = "ab", + }; + + // Act + var actual = cache.GetOrAdd(runtimeFileInfo, + compile: _ => CompilationResult.Successful(resultViewType)); + + // Assert + Assert.Equal(resultViewType, actual.CompiledType); + } + + [Fact] + public void GetOrAdd_UsesValueFromCache_IfViewStartHasNotChanged() + { + // Arrange + var instance = (View)Activator.CreateInstance(typeof(PreCompile)); + var length = Encoding.UTF8.GetByteCount(instance.Content); + var fileSystem = new TestFileSystem(); + + var lastModified = DateTime.UtcNow; + + var fileInfo = new TestFileInfo + { + Length = length, + LastModified = lastModified, + Content = instance.Content + }; + var runtimeFileInfo = new RelativeFileInfo(fileInfo, "ab"); + + var viewStartContent = "viewstart-content"; + var viewStartFileInfo = new TestFileInfo + { + Content = viewStartContent, + LastModified = DateTime.UtcNow + }; + fileSystem.AddFile("_ViewStart.cshtml", viewStartFileInfo); + var viewStartRazorFileInfo = new RazorFileInfo + { + Hash = RazorFileHash.GetHash(GetMemoryStream(viewStartContent)), + LastModified = viewStartFileInfo.LastModified, + Length = viewStartFileInfo.Length, + RelativePath = "_ViewStart.cshtml", + FullTypeName = typeof(RuntimeCompileIdentical).FullName + }; + + var precompiledViews = new ViewCollection(); + precompiledViews.Add(viewStartRazorFileInfo); + var cache = new CompilerCache(new[] { precompiledViews }, fileSystem); + + // Act + var actual = cache.GetOrAdd(runtimeFileInfo, + compile: _ => { throw new Exception("shouldn't be invoked"); }); + + // Assert + Assert.Equal(typeof(PreCompile), actual.CompiledType); + } + + [Fact] + public void GetOrAdd_IgnoresCachedValueIfFileIsIdentical_ButViewStartWasAdedSinceTheCacheWasCreated() + { + // Arrange + var expectedType = typeof(RuntimeCompileDifferent); + var lastModified = DateTime.UtcNow; + var fileSystem = new TestFileSystem(); + var collection = new ViewCollection(); + var precompiledFile = collection.FileInfos[0]; + precompiledFile.RelativePath = "Views\\home\\index.cshtml"; + var cache = new CompilerCache(new[] { collection }, fileSystem); + var testFile = new TestFileInfo + { + Content = new PreCompile().Content, + LastModified = precompiledFile.LastModified, + PhysicalPath = precompiledFile.RelativePath + }; + fileSystem.AddFile(precompiledFile.RelativePath, testFile); + var relativeFile = new RelativeFileInfo(testFile, testFile.PhysicalPath); + + // Act 1 + var actual1 = cache.GetOrAdd(relativeFile, + compile: _ => { throw new Exception("should not be called"); }); + + // Assert 1 + Assert.Equal(typeof(PreCompile), actual1.CompiledType); + + // Act 2 + fileSystem.AddFile("Views\\_ViewStart.cshtml", ""); + var actual2 = cache.GetOrAdd(relativeFile, + compile: _ => CompilationResult.Successful(expectedType)); + + // Assert 2 + Assert.Equal(expectedType, actual2.CompiledType); + } + + [Fact] + public void GetOrAdd_IgnoresCachedValueIfFileIsIdentical_ButViewStartWasDeletedSinceCacheWasCreated() + { + // Arrange + var expectedType = typeof(RuntimeCompileDifferent); + var lastModified = DateTime.UtcNow; + var fileSystem = new TestFileSystem(); + + var viewCollection = new ViewCollection(); + var precompiledView = viewCollection.FileInfos[0]; + precompiledView.RelativePath = "Views\\Index.cshtml"; + var viewFileInfo = new TestFileInfo + { + Content = new PreCompile().Content, + LastModified = precompiledView.LastModified, + PhysicalPath = precompiledView.RelativePath + }; + fileSystem.AddFile(viewFileInfo.PhysicalPath, viewFileInfo); + + var viewStartFileInfo = new TestFileInfo + { + PhysicalPath = "Views\\_ViewStart.cshtml", + Content = "viewstart-content", + LastModified = lastModified + }; + var viewStart = new RazorFileInfo + { + FullTypeName = typeof(RuntimeCompileIdentical).FullName, + RelativePath = viewStartFileInfo.PhysicalPath, + LastModified = viewStartFileInfo.LastModified, + Hash = RazorFileHash.GetHash(viewStartFileInfo), + Length = viewStartFileInfo.Length + }; + fileSystem.AddFile(viewStartFileInfo.PhysicalPath, viewStartFileInfo); + + viewCollection.Add(viewStart); + var cache = new CompilerCache(new[] { viewCollection }, fileSystem); + var fileInfo = new RelativeFileInfo(viewFileInfo, viewFileInfo.PhysicalPath); + + // Act 1 + var actual1 = cache.GetOrAdd(fileInfo, + compile: _ => { throw new Exception("should not be called"); }); + + // Assert 1 + Assert.Equal(typeof(PreCompile), actual1.CompiledType); + + // Act 2 + fileSystem.DeleteFile(viewStartFileInfo.PhysicalPath); + var actual2 = cache.GetOrAdd(fileInfo, + compile: _ => CompilationResult.Successful(expectedType)); + + // Assert 2 + Assert.Equal(expectedType, actual2.CompiledType); + } + + public static IEnumerable GetOrAdd_IgnoresCachedValue_IfViewStartWasChangedSinceCacheWasCreatedData + { + get + { + var viewStartContent = "viewstart-content"; + var contentStream = GetMemoryStream(viewStartContent); + var lastModified = DateTime.UtcNow; + int length = Encoding.UTF8.GetByteCount(viewStartContent); + var path = "Views\\_ViewStart.cshtml"; + + var razorFileInfo = new RazorFileInfo + { + Hash = RazorFileHash.GetHash(contentStream), + LastModified = lastModified, + Length = length, + RelativePath = path + }; + + // Length does not match + var testFileInfo1 = new TestFileInfo + { + Length = 7732 + }; + + yield return new object[] { razorFileInfo, testFileInfo1 }; + + // Content and last modified do not match + var testFileInfo2 = new TestFileInfo + { + Length = length, + Content = "viewstart-modified", + LastModified = lastModified.AddSeconds(100), + }; + + yield return new object[] { razorFileInfo, testFileInfo2 }; } } + [Theory] + [MemberData(nameof(GetOrAdd_IgnoresCachedValue_IfViewStartWasChangedSinceCacheWasCreatedData))] + public void GetOrAdd_IgnoresCachedValue_IfViewStartWasChangedSinceCacheWasCreated( + RazorFileInfo viewStartRazorFileInfo, TestFileInfo viewStartFileInfo) + { + // Arrange + var expectedType = typeof(RuntimeCompileDifferent); + var lastModified = DateTime.UtcNow; + var viewStartLastModified = DateTime.UtcNow; + var content = "some content"; + var fileInfo = new TestFileInfo + { + Length = 1020, + Content = content, + LastModified = lastModified, + PhysicalPath = "Views\\home\\index.cshtml" + }; + + var runtimeFileInfo = new RelativeFileInfo(fileInfo, fileInfo.PhysicalPath); + + var razorFileInfo = new RazorFileInfo + { + FullTypeName = typeof(PreCompile).FullName, + Hash = RazorFileHash.GetHash(fileInfo), + LastModified = lastModified, + Length = Encoding.UTF8.GetByteCount(content), + RelativePath = fileInfo.PhysicalPath, + }; + + var fileSystem = new TestFileSystem(); + fileSystem.AddFile(viewStartRazorFileInfo.RelativePath, viewStartFileInfo); + var viewCollection = new ViewCollection(); + var cache = new CompilerCache(new[] { viewCollection }, fileSystem); + + // Act + var actual = cache.GetOrAdd(runtimeFileInfo, + compile: _ => CompilationResult.Successful(expectedType)); + + // Assert + Assert.Equal(expectedType, actual.CompiledType); + } + [Fact] public void GetOrAdd_DoesNotCacheCompiledContent_OnCallsAfterInitial() { // Arrange var lastModified = DateTime.UtcNow; - var cache = new CompilerCache(); - var fileInfo = new Mock(); - fileInfo.SetupGet(f => f.PhysicalPath) - .Returns("test"); - fileInfo.SetupGet(f => f.LastModified) - .Returns(lastModified); + var cache = new CompilerCache(Enumerable.Empty(), new TestFileSystem()); + var fileInfo = new TestFileInfo + { + PhysicalPath = "test", + LastModified = lastModified + }; var type = GetType(); var uncachedResult = UncachedCompilationResult.Successful(type, "hello world"); - var runtimeFileInfo = new RelativeFileInfo() - { - FileInfo = fileInfo.Object, - RelativePath = "test", - }; + var runtimeFileInfo = new RelativeFileInfo(fileInfo, "test"); // Act - cache.GetOrAdd(runtimeFileInfo, () => uncachedResult); - var actual1 = cache.GetOrAdd(runtimeFileInfo, () => uncachedResult); - var actual2 = cache.GetOrAdd(runtimeFileInfo, () => uncachedResult); + cache.GetOrAdd(runtimeFileInfo, _ => uncachedResult); + var actual1 = cache.GetOrAdd(runtimeFileInfo, _ => uncachedResult); + var actual2 = cache.GetOrAdd(runtimeFileInfo, _ => uncachedResult); // Assert Assert.NotSame(uncachedResult, actual1); diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/ExpiringFileInfoCacheTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/DefaultRazorFileSystemCacheTest.cs similarity index 88% rename from test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/ExpiringFileInfoCacheTest.cs rename to test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/DefaultRazorFileSystemCacheTest.cs index 19a1460fb1..f10ec52df3 100644 --- a/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/ExpiringFileInfoCacheTest.cs +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/DefaultRazorFileSystemCacheTest.cs @@ -3,16 +3,14 @@ using System; using System.Collections.Generic; -using System.IO; using Microsoft.AspNet.FileSystems; -using Microsoft.Framework.Expiration.Interfaces; using Microsoft.Framework.OptionsModel; using Moq; using Xunit; namespace Microsoft.AspNet.Mvc.Razor { - public class ExpiringFileInfoCacheTest + public class DefaultRazorFileSystemCacheTest { private const string FileName = "myView.cshtml"; @@ -41,7 +39,7 @@ namespace Microsoft.AspNet.Mvc.Razor public void CreateFile(string fileName) { - var fileInfo = new DummyFileInfo() + var fileInfo = new TestFileInfo() { Name = fileName, LastModified = DateTime.Now, @@ -89,6 +87,9 @@ namespace Microsoft.AspNet.Mvc.Razor var fileInfo2 = cache.GetFileInfo(FileName); // Assert + Assert.True(fileInfo1.Exists); + Assert.True(fileInfo1.Exists); + Assert.Same(fileInfo1, fileInfo2); Assert.Equal(FileName, fileInfo1.Name); @@ -306,7 +307,34 @@ namespace Microsoft.AspNet.Mvc.Razor Assert.Equal(FileName, fileInfo1.Name); } - public class ControllableExpiringFileInfoCache : ExpiringFileInfoCache + [Fact] + public void GetDirectoryInfo_PassesThroughToUnderlyingFileSystem() + { + // Arrange + var fileSystem = new Mock(); + var expected = Mock.Of(); + fileSystem.Setup(f => f.GetDirectoryContents("/test-path")) + .Returns(expected) + .Verifiable(); + var options = new RazorViewEngineOptions + { + FileSystem = fileSystem.Object + }; + var accessor = new Mock>(); + accessor.SetupGet(a => a.Options) + .Returns(options); + + var cachedFileSystem = new DefaultRazorFileSystemCache(accessor.Object); + + // Act + var result = cachedFileSystem.GetDirectoryContents("/test-path"); + + // Assert + Assert.Same(expected, result); + fileSystem.Verify(); + } + + public class ControllableExpiringFileInfoCache : DefaultRazorFileSystemCache { public ControllableExpiringFileInfoCache(IOptions optionsAccessor) : base(optionsAccessor) @@ -338,7 +366,6 @@ namespace Microsoft.AspNet.Mvc.Razor _internalUtcNow = UtcNow.AddMilliseconds(milliSeconds); } } - public class DummyFileSystem : IFileSystem { private Dictionary _fileInfos = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -365,7 +392,7 @@ namespace Microsoft.AspNet.Mvc.Razor IFileInfo knownInfo; if (_fileInfos.TryGetValue(subpath, out knownInfo)) { - return new DummyFileInfo() + return new TestFileInfo { Name = knownInfo.Name, LastModified = knownInfo.LastModified, @@ -382,48 +409,5 @@ namespace Microsoft.AspNet.Mvc.Razor throw new NotImplementedException(); } } - - public class DummyFileInfo : IFileInfo - { - public DateTime LastModified { get; set; } - public string Name { get; set; } - - public long Length { get { throw new NotImplementedException(); } } - public bool IsDirectory { get { throw new NotImplementedException(); } } - public string PhysicalPath { get { throw new NotImplementedException(); } } - - public bool Exists - { - get - { - throw new NotImplementedException(); - } - } - - public bool IsReadOnly - { - get - { - throw new NotImplementedException(); - } - } - - public Stream CreateReadStream() { throw new NotImplementedException(); } - - public void WriteContent(byte[] content) - { - throw new NotImplementedException(); - } - - public void Delete() - { - throw new NotImplementedException(); - } - - public IExpirationTrigger CreateFileChangeTrigger() - { - throw new NotImplementedException(); - } - } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorCompilationServiceTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorCompilationServiceTest.cs index 4ed83270c0..c5a9b37767 100644 --- a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorCompilationServiceTest.cs +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorCompilationServiceTest.cs @@ -34,11 +34,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test var razorService = new RazorCompilationService(compiler.Object, host.Object); - var relativeFileInfo = new RelativeFileInfo() - { - FileInfo = fileInfo.Object, - RelativePath = @"Views\index\home.cshtml", - }; + var relativeFileInfo = new RelativeFileInfo(fileInfo.Object, @"Views\index\home.cshtml"); // Act razorService.Compile(relativeFileInfo); @@ -47,6 +43,73 @@ namespace Microsoft.AspNet.Mvc.Razor.Test host.Verify(); } + [Fact] + public void Compile_ReturnsFailedResultIfParseFails() + { + // Arrange + var generatorResult = new GeneratorResults( + new Block( + new BlockBuilder { Type = BlockType.Comment }), + new RazorError[] { new RazorError("some message", 1, 1, 1, 1) }, + new CodeBuilderResult("", new LineMapping[0]), + new CodeTree()); + var host = new Mock(); + host.Setup(h => h.GenerateCode(It.IsAny(), It.IsAny())) + .Returns(generatorResult) + .Verifiable(); + + var fileInfo = new Mock(); + fileInfo.Setup(f => f.CreateReadStream()) + .Returns(Stream.Null); + + var compiler = new Mock(MockBehavior.Strict); + var relativeFileInfo = new RelativeFileInfo(fileInfo.Object, @"Views\index\home.cshtml"); + var razorService = new RazorCompilationService(compiler.Object, host.Object); + + // Act + var result = razorService.Compile(relativeFileInfo); + + // Assert + var ex = Assert.Throws(() => result.CompiledType); + Assert.Equal("some message", Assert.Single(ex.Messages).Message); + host.Verify(); + } + + [Fact] + public void Compile_ReturnsResultFromCompilationServiceIfParseSucceeds() + { + // Arrange + var code = "compiled-content"; + var generatorResult = new GeneratorResults( + new Block( + new BlockBuilder { Type = BlockType.Comment }), + new RazorError[0], + new CodeBuilderResult(code, new LineMapping[0]), + new CodeTree()); + var host = new Mock(); + host.Setup(h => h.GenerateCode(It.IsAny(), It.IsAny())) + .Returns(generatorResult); + + var fileInfo = new Mock(); + fileInfo.Setup(f => f.CreateReadStream()) + .Returns(Stream.Null); + + var compilationResult = CompilationResult.Successful(typeof(object)); + var compiler = new Mock(); + compiler.Setup(c => c.Compile(fileInfo.Object, code)) + .Returns(compilationResult) + .Verifiable(); + var relativeFileInfo = new RelativeFileInfo(fileInfo.Object, @"Views\index\home.cshtml"); + var razorService = new RazorCompilationService(compiler.Object, host.Object); + + // Act + var result = razorService.Compile(relativeFileInfo); + + // Assert + Assert.Same(compilationResult, result); + compiler.Verify(); + } + private static GeneratorResults GetGeneratorResult() { return new GeneratorResults( diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/project.json b/test/Microsoft.AspNet.Mvc.Razor.Test/project.json index c4d0250b15..9db98807c4 100644 --- a/test/Microsoft.AspNet.Mvc.Razor.Test/project.json +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/project.json @@ -1,4 +1,9 @@ { + "code": [ + "**/*.cs", + "../Microsoft.AspNet.Mvc.Razor.Host.Test/TestFileSystem.cs", + "../Microsoft.AspNet.Mvc.Razor.Host.Test/TestFileInfo.cs" + ], "dependencies": { "Microsoft.AspNet.Mvc.Razor": "6.0.0-*", "Microsoft.AspNet.Testing": "1.0.0-*", diff --git a/test/WebSites/PrecompilationWebSite/.gitignore b/test/WebSites/PrecompilationWebSite/.gitignore new file mode 100644 index 0000000000..dce51a14ca --- /dev/null +++ b/test/WebSites/PrecompilationWebSite/.gitignore @@ -0,0 +1 @@ +*.cshtml \ No newline at end of file diff --git a/test/WebSites/PrecompilationWebSite/Views/Home/Index.cshtml b/test/WebSites/PrecompilationWebSite/Views/Home/Index.cshtml index 97580a0513..77a9e96d2e 100644 --- a/test/WebSites/PrecompilationWebSite/Views/Home/Index.cshtml +++ b/test/WebSites/PrecompilationWebSite/Views/Home/Index.cshtml @@ -1,2 +1 @@ -@using System.Reflection -@(GetType().GetTypeInfo().Assembly.GetName()) \ No newline at end of file +index:@GetType().GetTypeInfo().Assembly.GetName() \ No newline at end of file diff --git a/test/WebSites/PrecompilationWebSite/Views/Home/Layout.cshtml b/test/WebSites/PrecompilationWebSite/Views/Home/Layout.cshtml new file mode 100644 index 0000000000..75fe916035 --- /dev/null +++ b/test/WebSites/PrecompilationWebSite/Views/Home/Layout.cshtml @@ -0,0 +1,2 @@ +Layout:@GetType().GetTypeInfo().Assembly.FullName +@RenderBody() \ No newline at end of file diff --git a/test/WebSites/PrecompilationWebSite/Views/Home/_ViewStart.cshtml b/test/WebSites/PrecompilationWebSite/Views/Home/_ViewStart.cshtml new file mode 100644 index 0000000000..70e372c10e --- /dev/null +++ b/test/WebSites/PrecompilationWebSite/Views/Home/_ViewStart.cshtml @@ -0,0 +1,4 @@ +@using System.Reflection +@{ Layout = "/views/Home/Layout.cshtml";} +_viewstart:@GetType().GetTypeInfo().Assembly.FullName + \ No newline at end of file