From d58d0f917f617ef882caf2fbf28aeda1eada1c3d Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Sat, 6 Jan 2018 16:08:52 -0800 Subject: [PATCH] Add support for recompilation --- Mvc.sln | 45 ++ .../Compilation/CompiledViewDescriptor.cs | 57 ++ .../Compilation/ViewsFeatureProvider.cs | 111 ++-- .../Internal/ChecksumValidator.cs | 122 +++++ .../DefaultRazorPageFactoryProvider.cs | 10 +- .../Internal/RazorViewCompiler.cs | 241 ++++++--- .../Properties/Resources.Designer.cs | 14 + .../Resources.resx | 5 +- .../CompiledPageRouteModelProvider.cs | 100 ++-- ...soft.AspNetCore.Mvc.FunctionalTests.csproj | 1 + .../RazorBuildTest.cs | 68 +++ .../Compilation/ViewsFeatureProviderTest.cs | 138 ++++- .../Internal/ChecksumValidatorTest.cs | 199 +++++++ .../Internal/RazorViewCompilerTest.cs | 280 +++++++++- .../CompiledPageRouteModelProviderTest.cs | 498 ++++++++++++------ ...Microsoft.AspNetCore.Mvc.TestCommon.csproj | 1 + .../TestRazorCompiledItem.cs | 47 ++ .../Pages/Precompilation/Page.cs | 39 ++ .../Pages/Precompilation/Page_Model.cs | 9 + .../RazorBuildWebSite.PrecompiledViews.csproj | 12 + .../Views/Precompilation/View.cs | 37 ++ .../RazorBuildWebSite.Views/Pages/Rzc/Page.cs | 10 + .../Pages/Rzc/Page_Model.cs | 9 + .../RazorBuildWebSite.Views.csproj | 12 + .../RazorBuildWebSite.Views/Views/Rzc/View.cs | 10 + .../Controllers/PrecompilationController.cs | 13 + .../Controllers/RzcController.cs | 13 + .../Pages/Precompilation/Page.cshtml | 4 + .../Pages/Precompilation/Page.cshtml.cs | 9 + .../RazorBuildWebSite/Pages/Rzc/Page.cshtml | 4 + .../Pages/Rzc/Page.cshtml.cs | 9 + .../Pages/_ViewImports.cshtml | 1 + .../RazorBuildWebSite.csproj | 27 + test/WebSites/RazorBuildWebSite/Startup.cs | 35 ++ .../Views/Precompilation/View.cshtml | 1 + .../RazorBuildWebSite/Views/Rzc/View.cshtml | 1 + .../Views/_ViewImports.cshtml | 1 + test/WebSites/RazorBuildWebSite/readme.md | 4 + 38 files changed, 1870 insertions(+), 327 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.Razor/Internal/ChecksumValidator.cs create mode 100644 test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorBuildTest.cs create mode 100644 test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/ChecksumValidatorTest.cs create mode 100644 test/Microsoft.AspNetCore.Mvc.TestCommon/TestRazorCompiledItem.cs create mode 100644 test/WebSites/RazorBuildWebSite.PrecompiledViews/Pages/Precompilation/Page.cs create mode 100644 test/WebSites/RazorBuildWebSite.PrecompiledViews/Pages/Precompilation/Page_Model.cs create mode 100644 test/WebSites/RazorBuildWebSite.PrecompiledViews/RazorBuildWebSite.PrecompiledViews.csproj create mode 100644 test/WebSites/RazorBuildWebSite.PrecompiledViews/Views/Precompilation/View.cs create mode 100644 test/WebSites/RazorBuildWebSite.Views/Pages/Rzc/Page.cs create mode 100644 test/WebSites/RazorBuildWebSite.Views/Pages/Rzc/Page_Model.cs create mode 100644 test/WebSites/RazorBuildWebSite.Views/RazorBuildWebSite.Views.csproj create mode 100644 test/WebSites/RazorBuildWebSite.Views/Views/Rzc/View.cs create mode 100644 test/WebSites/RazorBuildWebSite/Controllers/PrecompilationController.cs create mode 100644 test/WebSites/RazorBuildWebSite/Controllers/RzcController.cs create mode 100644 test/WebSites/RazorBuildWebSite/Pages/Precompilation/Page.cshtml create mode 100644 test/WebSites/RazorBuildWebSite/Pages/Precompilation/Page.cshtml.cs create mode 100644 test/WebSites/RazorBuildWebSite/Pages/Rzc/Page.cshtml create mode 100644 test/WebSites/RazorBuildWebSite/Pages/Rzc/Page.cshtml.cs create mode 100644 test/WebSites/RazorBuildWebSite/Pages/_ViewImports.cshtml create mode 100644 test/WebSites/RazorBuildWebSite/RazorBuildWebSite.csproj create mode 100644 test/WebSites/RazorBuildWebSite/Startup.cs create mode 100644 test/WebSites/RazorBuildWebSite/Views/Precompilation/View.cshtml create mode 100644 test/WebSites/RazorBuildWebSite/Views/Rzc/View.cshtml create mode 100644 test/WebSites/RazorBuildWebSite/Views/_ViewImports.cshtml create mode 100644 test/WebSites/RazorBuildWebSite/readme.md diff --git a/Mvc.sln b/Mvc.sln index b2bf771f05..107d31ef52 100644 --- a/Mvc.sln +++ b/Mvc.sln @@ -155,6 +155,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{FDC66952-A3EA-4074-899E-C29816BF7C1F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorBuildWebSite", "test\WebSites\RazorBuildWebSite\RazorBuildWebSite.csproj", "{BF8A3392-C3D2-4813-855A-E906564600E1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorBuildWebSite.PrecompiledViews", "test\WebSites\RazorBuildWebSite.PrecompiledViews\RazorBuildWebSite.PrecompiledViews.csproj", "{856D7E25-E033-477D-9ABD-0B50CF428C80}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorBuildWebSite.Views", "test\WebSites\RazorBuildWebSite.Views\RazorBuildWebSite.Views.csproj", "{8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -791,6 +797,42 @@ Global {7500B228-1769-4CFB-A571-3DFAC6678A06}.Release|Mixed Platforms.Build.0 = Release|Any CPU {7500B228-1769-4CFB-A571-3DFAC6678A06}.Release|x86.ActiveCfg = Release|Any CPU {7500B228-1769-4CFB-A571-3DFAC6678A06}.Release|x86.Build.0 = Release|Any CPU + {BF8A3392-C3D2-4813-855A-E906564600E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF8A3392-C3D2-4813-855A-E906564600E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF8A3392-C3D2-4813-855A-E906564600E1}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {BF8A3392-C3D2-4813-855A-E906564600E1}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {BF8A3392-C3D2-4813-855A-E906564600E1}.Debug|x86.ActiveCfg = Debug|Any CPU + {BF8A3392-C3D2-4813-855A-E906564600E1}.Debug|x86.Build.0 = Debug|Any CPU + {BF8A3392-C3D2-4813-855A-E906564600E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF8A3392-C3D2-4813-855A-E906564600E1}.Release|Any CPU.Build.0 = Release|Any CPU + {BF8A3392-C3D2-4813-855A-E906564600E1}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {BF8A3392-C3D2-4813-855A-E906564600E1}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {BF8A3392-C3D2-4813-855A-E906564600E1}.Release|x86.ActiveCfg = Release|Any CPU + {BF8A3392-C3D2-4813-855A-E906564600E1}.Release|x86.Build.0 = Release|Any CPU + {856D7E25-E033-477D-9ABD-0B50CF428C80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {856D7E25-E033-477D-9ABD-0B50CF428C80}.Debug|Any CPU.Build.0 = Debug|Any CPU + {856D7E25-E033-477D-9ABD-0B50CF428C80}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {856D7E25-E033-477D-9ABD-0B50CF428C80}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {856D7E25-E033-477D-9ABD-0B50CF428C80}.Debug|x86.ActiveCfg = Debug|Any CPU + {856D7E25-E033-477D-9ABD-0B50CF428C80}.Debug|x86.Build.0 = Debug|Any CPU + {856D7E25-E033-477D-9ABD-0B50CF428C80}.Release|Any CPU.ActiveCfg = Release|Any CPU + {856D7E25-E033-477D-9ABD-0B50CF428C80}.Release|Any CPU.Build.0 = Release|Any CPU + {856D7E25-E033-477D-9ABD-0B50CF428C80}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {856D7E25-E033-477D-9ABD-0B50CF428C80}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {856D7E25-E033-477D-9ABD-0B50CF428C80}.Release|x86.ActiveCfg = Release|Any CPU + {856D7E25-E033-477D-9ABD-0B50CF428C80}.Release|x86.Build.0 = Release|Any CPU + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}.Debug|x86.ActiveCfg = Debug|Any CPU + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}.Debug|x86.Build.0 = Debug|Any CPU + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}.Release|Any CPU.Build.0 = Release|Any CPU + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}.Release|x86.ActiveCfg = Release|Any CPU + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -853,6 +895,9 @@ Global {4BA6EC9A-B6D9-41F2-BFDA-D82B22D80352} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {F16CEE0D-A28E-43BD-802F-99BAFE4BA7CE} = {FDC66952-A3EA-4074-899E-C29816BF7C1F} {7500B228-1769-4CFB-A571-3DFAC6678A06} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} + {BF8A3392-C3D2-4813-855A-E906564600E1} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} + {856D7E25-E033-477D-9ABD-0B50CF428C80} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} + {8916DDCA-EC2A-4193-B9F3-78CAA1A96D5A} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {63D344F6-F86D-40E6-85B9-0AABBE338C4A} diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/CompiledViewDescriptor.cs b/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/CompiledViewDescriptor.cs index 660f38f6de..e1ccf3c3b6 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/CompiledViewDescriptor.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/CompiledViewDescriptor.cs @@ -1,13 +1,60 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Razor.Internal; +using Microsoft.AspNetCore.Razor.Hosting; using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Mvc.Razor.Compilation { + /// + /// Represents a compiled Razor View or Page. + /// public class CompiledViewDescriptor { + /// + /// Creates a new . + /// + public CompiledViewDescriptor() + { + + } + + /// + /// Creates a new . At least one of or + /// must be non-null. + /// + /// The . + /// The . + public CompiledViewDescriptor(RazorCompiledItem item, RazorViewAttribute attribute) + { + if (item == null && attribute == null) + { + // We require at least one of these to be specified. + throw new ArgumentException(Resources.FormatCompiledViewDescriptor_NoData(nameof(item), nameof(attribute))); + } + + Item = item; + + // + // For now we expect that MVC views and pages will still have either: + // [RazorView(...)] or + // [RazorPage(...)]. + // + // In theory we could look at the 'Item.Kind' to determine what kind of thing we're dealing + // with, but for compat reasons we're basing it on ViewAttribute since that's what 2.0 had. + ViewAttribute = attribute; + + // We don't have access to the file provider here so we can't check if the files + // even exist or what their checksums are. For now leave this empty, it will be updated + // later. + ExpirationTokens = Array.Empty(); + RelativePath = ViewPath.NormalizePath(item?.Identifier ?? attribute.Path); + IsPrecompiled = true; + } + /// /// The normalized application relative path of the view. /// @@ -27,5 +74,15 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation /// Gets a value that determines if the view is precompiled. /// public bool IsPrecompiled { get; set; } + + /// + /// Gets the descriptor for this view. + /// + public RazorCompiledItem Item { get; set; } + + /// + /// Gets the type of the compiled item. + /// + public Type Type => Item?.Type ?? ViewAttribute?.ViewType; } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/ViewsFeatureProvider.cs b/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/ViewsFeatureProvider.cs index a9e3b15ed2..ade3da33e3 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/ViewsFeatureProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/Compilation/ViewsFeatureProvider.cs @@ -7,8 +7,7 @@ using System.IO; using System.Linq; using System.Reflection; using Microsoft.AspNetCore.Mvc.ApplicationParts; -using Microsoft.AspNetCore.Mvc.Razor.Internal; -using Microsoft.Extensions.Primitives; +using Microsoft.AspNetCore.Razor.Hosting; namespace Microsoft.AspNetCore.Mvc.Razor.Compilation { @@ -19,28 +18,81 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation { public static readonly string PrecompiledViewsAssemblySuffix = ".PrecompiledViews"; + public static readonly IReadOnlyList ViewAssemblySuffixes = new string[] + { + PrecompiledViewsAssemblySuffix, + ".Views", + }; + /// public void PopulateFeature(IEnumerable parts, ViewsFeature feature) { foreach (var assemblyPart in parts.OfType()) { - var viewAttributes = GetViewAttributes(assemblyPart); - foreach (var attribute in viewAttributes) - { - var relativePath = ViewPath.NormalizePath(attribute.Path); - var viewDescriptor = new CompiledViewDescriptor - { - ExpirationTokens = Array.Empty(), - RelativePath = relativePath, - ViewAttribute = attribute, - IsPrecompiled = true, - }; + var attributes = GetViewAttributes(assemblyPart); + var items = LoadItems(assemblyPart); - feature.ViewDescriptors.Add(viewDescriptor); + var merged = Merge(items, attributes); + foreach (var entry in merged) + { + feature.ViewDescriptors.Add(new CompiledViewDescriptor(entry.item, entry.attribute)); } } } + private ICollection<(RazorCompiledItem item, RazorViewAttribute attribute)> Merge( + IReadOnlyList items, + IEnumerable attributes) + { + // This code is a intentionally defensive. We assume that it's possible to have duplicates + // of attributes, and also items that have a single kind of metadata, but not the other. + var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + if (!dictionary.TryGetValue(item.Identifier, out var entry)) + { + dictionary.Add(item.Identifier, (item, null)); + + } + else if (entry.item == null) + { + dictionary[item.Identifier] = (item, entry.attribute); + } + } + + foreach (var attribute in attributes) + { + if (!dictionary.TryGetValue(attribute.Path, out var entry)) + { + dictionary.Add(attribute.Path, (null, attribute)); + } + else if (entry.attribute == null) + { + dictionary[attribute.Path] = (entry.item, attribute); + } + } + + return dictionary.Values; + } + + protected virtual IReadOnlyList LoadItems(AssemblyPart assemblyPart) + { + if (assemblyPart == null) + { + throw new ArgumentNullException(nameof(assemblyPart)); + } + + var viewAssembly = GetViewAssembly(assemblyPart); + if (viewAssembly != null) + { + var loader = new RazorCompiledItemLoader(); + return loader.LoadItems(viewAssembly); + } + + return Array.Empty(); + } + /// /// Gets the sequence of instances associated with the specified . /// @@ -53,7 +105,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation throw new ArgumentNullException(nameof(assemblyPart)); } - var featureAssembly = GetFeatureAssembly(assemblyPart); + var featureAssembly = GetViewAssembly(assemblyPart); if (featureAssembly != null) { return featureAssembly.GetCustomAttributes(); @@ -62,29 +114,28 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation return Enumerable.Empty(); } - private static Assembly GetFeatureAssembly(AssemblyPart assemblyPart) + private Assembly GetViewAssembly(AssemblyPart assemblyPart) { if (assemblyPart.Assembly.IsDynamic || string.IsNullOrEmpty(assemblyPart.Assembly.Location)) { return null; } - var precompiledAssemblyFileName = assemblyPart.Assembly.GetName().Name - + PrecompiledViewsAssemblySuffix - + ".dll"; - var precompiledAssemblyFilePath = Path.Combine( - Path.GetDirectoryName(assemblyPart.Assembly.Location), - precompiledAssemblyFileName); - - if (File.Exists(precompiledAssemblyFilePath)) + for (var i = 0; i < ViewAssemblySuffixes.Count; i++) { - try + var fileName = assemblyPart.Assembly.GetName().Name + ViewAssemblySuffixes[i] + ".dll"; + var filePath = Path.Combine(Path.GetDirectoryName(assemblyPart.Assembly.Location), fileName); + + if (File.Exists(filePath)) { - return Assembly.LoadFile(precompiledAssemblyFilePath); - } - catch (FileLoadException) - { - // Don't throw if assembly cannot be loaded. This can happen if the file is not a managed assembly. + try + { + return Assembly.LoadFile(filePath); + } + catch (FileLoadException) + { + // Don't throw if assembly cannot be loaded. This can happen if the file is not a managed assembly. + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/Internal/ChecksumValidator.cs b/src/Microsoft.AspNetCore.Mvc.Razor/Internal/ChecksumValidator.cs new file mode 100644 index 0000000000..704ae1151f --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Razor/Internal/ChecksumValidator.cs @@ -0,0 +1,122 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.AspNetCore.Razor.Hosting; +using Microsoft.AspNetCore.Razor.Language; + +namespace Microsoft.AspNetCore.Mvc.Razor.Internal +{ + public static class ChecksumValidator + { + public static bool IsRecompilationSupported(RazorCompiledItem item) + { + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + // A Razor item only supports recompilation if its primary source file has a checksum. + // + // Other files (view imports) may or may not have existed at the time of compilation, + // so we may not have checksums for them. + var checksums = item.GetChecksumMetadata(); + return checksums.Any(c => string.Equals(item.Identifier, c.Identifier, StringComparison.OrdinalIgnoreCase)); + } + + // Validates that we can use an existing precompiled view by comparing checksums with files on + // disk. + public static bool IsItemValid(RazorProject project, RazorCompiledItem item) + { + if (project == null) + { + throw new ArgumentNullException(nameof(project)); + } + + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + var checksums = item.GetChecksumMetadata(); + + // The checksum that matches 'Item.Identity' in this list is significant. That represents the main file. + // + // We don't really care about the validation unless the main file exists. This is because we expect + // most sites to have some _ViewImports in common location. That means that in the case you're + // using views from a 3rd party library, you'll always have **some** conflicts. + // + // The presence of the main file with the same content is a very strong signal that you're in a + // development scenario. + var primaryChecksum = checksums + .FirstOrDefault(c => string.Equals(item.Identifier, c.Identifier, StringComparison.OrdinalIgnoreCase)); + if (primaryChecksum == null) + { + // No primary checksum, assume valid. + return true; + } + + var projectItem = project.GetItem(primaryChecksum.Identifier); + if (!projectItem.Exists) + { + // Main file doesn't exist - assume valid. + return true; + } + + var sourceDocument = RazorSourceDocument.ReadFrom(projectItem); + if (!string.Equals(sourceDocument.GetChecksumAlgorithm(), primaryChecksum.ChecksumAlgorithm) || + !ChecksumsEqual(primaryChecksum.Checksum, sourceDocument.GetChecksum())) + { + // Main file exists, but checksums not equal. + return false; + } + + for (var i = 0; i < checksums.Count; i++) + { + var checksum = checksums[i]; + if (string.Equals(item.Identifier, checksum.Identifier, StringComparison.OrdinalIgnoreCase)) + { + // Ignore primary checksum on this pass. + continue; + } + + var importItem = project.GetItem(checksum.Identifier); + if (!importItem.Exists) + { + // Import file doesn't exist - assume invalid. + return false; + } + + sourceDocument = RazorSourceDocument.ReadFrom(importItem); + if (!string.Equals(sourceDocument.GetChecksumAlgorithm(), checksum.ChecksumAlgorithm) || + !ChecksumsEqual(checksum.Checksum, sourceDocument.GetChecksum())) + { + // Import file exists, but checksums not equal. + return false; + } + } + + return true; + } + + private static bool ChecksumsEqual(string checksum, byte[] bytes) + { + if (bytes.Length * 2 != checksum.Length) + { + return false; + } + + for (var i = 0; i < bytes.Length; i++) + { + var text = bytes[i].ToString("x2"); + if (checksum[i * 2] != text[0] || checksum[i * 2 + 1] != text[1]) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/Internal/DefaultRazorPageFactoryProvider.cs b/src/Microsoft.AspNetCore.Mvc.Razor/Internal/DefaultRazorPageFactoryProvider.cs index 0ffd390d52..b5c7341830 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/Internal/DefaultRazorPageFactoryProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/Internal/DefaultRazorPageFactoryProvider.cs @@ -43,12 +43,12 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var compileTask = Compiler.CompileAsync(relativePath); var viewDescriptor = compileTask.GetAwaiter().GetResult(); - if (viewDescriptor.ViewAttribute != null) - { - var compiledType = viewDescriptor.ViewAttribute.ViewType; - var newExpression = Expression.New(compiledType); - var pathProperty = compiledType.GetTypeInfo().GetProperty(nameof(IRazorPage.Path)); + var viewType = viewDescriptor.Type; + if (viewType != null) + { + var newExpression = Expression.New(viewType); + var pathProperty = viewType.GetTypeInfo().GetProperty(nameof(IRazorPage.Path)); // Generate: page.Path = relativePath; // Use the normalized path specified from the result. diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompiler.cs b/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompiler.cs index 0a031b8cba..a9119951ef 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompiler.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/Internal/RazorViewCompiler.cs @@ -6,16 +6,19 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Reflection; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Razor.Compilation; +using Microsoft.AspNetCore.Razor.Hosting; using Microsoft.AspNetCore.Razor.Language; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Text; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Mvc.Razor.Internal { @@ -25,8 +28,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal public class RazorViewCompiler : IViewCompiler { private readonly object _cacheLock = new object(); - private readonly Dictionary> _precompiledViewLookup; - private readonly ConcurrentDictionary _normalizedPathLookup; + private readonly Dictionary _precompiledViews; + private readonly ConcurrentDictionary _normalizedPathCache; private readonly IFileProvider _fileProvider; private readonly RazorTemplateEngine _templateEngine; private readonly Action _compilationCallback; @@ -78,26 +81,33 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal _compilationCallback = compilationCallback; _logger = logger; - _normalizedPathLookup = new ConcurrentDictionary(StringComparer.Ordinal); + _normalizedPathCache = new ConcurrentDictionary(StringComparer.Ordinal); + + // This is our L0 cache, and is a durable store. Views migrate into the cache as they are requested + // from either the set of known precompiled views, or by being compiled. _cache = new MemoryCache(new MemoryCacheOptions()); - _precompiledViewLookup = new Dictionary>( + // We need to validate that the all of the precompiled views are unique by path (case-insenstive). + // We do this because there's no good way to canonicalize paths on windows, and it will create + // problems when deploying to linux. Rather than deal with these issues, we just don't support + // views that differ only by case. + _precompiledViews = new Dictionary( precompiledViews.Count, StringComparer.OrdinalIgnoreCase); foreach (var precompiledView in precompiledViews) { - if (_precompiledViewLookup.TryGetValue(precompiledView.RelativePath, out var otherValue)) + if (_precompiledViews.TryGetValue(precompiledView.RelativePath, out var otherValue)) { var message = string.Join( Environment.NewLine, Resources.RazorViewCompiler_ViewPathsDifferOnlyInCase, - otherValue.Result.RelativePath, + otherValue.RelativePath, precompiledView.RelativePath); throw new InvalidOperationException(message); } - _precompiledViewLookup.Add(precompiledView.RelativePath, Task.FromResult(precompiledView)); + _precompiledViews.Add(precompiledView.RelativePath, precompiledView); } } @@ -109,103 +119,192 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal throw new ArgumentNullException(nameof(relativePath)); } - // Lookup precompiled views first. - // Attempt to lookup the cache entry using the passed in path. This will succeed if the path is already // normalized and a cache entry exists. - string normalizedPath = null; Task cachedResult; - - if (_precompiledViewLookup.Count > 0) - { - if (_precompiledViewLookup.TryGetValue(relativePath, out cachedResult)) - { - return cachedResult; - } - - normalizedPath = GetNormalizedPath(relativePath); - if (_precompiledViewLookup.TryGetValue(normalizedPath, out cachedResult)) - { - return cachedResult; - } - } - if (_cache.TryGetValue(relativePath, out cachedResult)) { return cachedResult; } - normalizedPath = normalizedPath ?? GetNormalizedPath(relativePath); + var normalizedPath = GetNormalizedPath(relativePath); if (_cache.TryGetValue(normalizedPath, out cachedResult)) { return cachedResult; } // Entry does not exist. Attempt to create one. - cachedResult = CreateCacheEntry(normalizedPath); + cachedResult = OnCacheMiss(normalizedPath); return cachedResult; } - private Task CreateCacheEntry(string normalizedPath) + private Task OnCacheMiss(string normalizedPath) { - TaskCompletionSource compilationTaskSource = null; + ViewCompilerWorkItem item; + TaskCompletionSource taskSource; MemoryCacheEntryOptions cacheEntryOptions; - Task cacheEntry; // Safe races cannot be allowed when compiling Razor pages. To ensure only one compilation request succeeds // per file, we'll lock the creation of a cache entry. Creating the cache entry should be very quick. The // actual work for compiling files happens outside the critical section. lock (_cacheLock) { - if (_cache.TryGetValue(normalizedPath, out cacheEntry)) + // Double-checked locking to handle a possible race. + if (_cache.TryGetValue(normalizedPath, out Task result)) { - return cacheEntry; + return result; } - cacheEntryOptions = new MemoryCacheEntryOptions(); - - cacheEntryOptions.ExpirationTokens.Add(_fileProvider.Watch(normalizedPath)); - var projectItem = _templateEngine.Project.GetItem(normalizedPath); - if (!projectItem.Exists) + if (_precompiledViews.TryGetValue(normalizedPath, out var precompiledView)) { - cacheEntry = Task.FromResult(new CompiledViewDescriptor - { - RelativePath = normalizedPath, - ExpirationTokens = cacheEntryOptions.ExpirationTokens, - }); + item = CreatePrecompiledWorkItem(normalizedPath, precompiledView); } else { - // A file exists and needs to be compiled. - compilationTaskSource = new TaskCompletionSource(); - foreach (var importItem in _templateEngine.GetImportItems(projectItem)) - { - cacheEntryOptions.ExpirationTokens.Add(_fileProvider.Watch(importItem.FilePath)); - } - cacheEntry = compilationTaskSource.Task; + item = CreateRuntimeCompilationWorkItem(normalizedPath); } - cacheEntry = _cache.Set(normalizedPath, cacheEntry, cacheEntryOptions); + // At this point, we've decided what to do - but we should create the cache entry and + // release the lock first. + cacheEntryOptions = new MemoryCacheEntryOptions(); + + Debug.Assert(item.ExpirationTokens != null); + for (var i = 0; i < item.ExpirationTokens.Count; i++) + { + cacheEntryOptions.ExpirationTokens.Add(item.ExpirationTokens[i]); + } + + taskSource = new TaskCompletionSource(); + if (item.SupportsCompilation) + { + // We'll compile in just a sec, be patient. + } + else + { + // If we can't compile, we should have already created the descriptor + Debug.Assert(item.Descriptor != null); + taskSource.SetResult(item.Descriptor); + } + + _cache.Set(normalizedPath, taskSource.Task, cacheEntryOptions); } - if (compilationTaskSource != null) + // Now the lock has been released so we can do more expensive processing. + if (item.SupportsCompilation) { - // Indicates that a file was found and needs to be compiled. - Debug.Assert(cacheEntryOptions != null); + Debug.Assert(taskSource != null); + + if (item.Descriptor?.Item != null && + ChecksumValidator.IsItemValid(_templateEngine.Project, item.Descriptor.Item)) + { + // If the item has checksums to validate, we should also have a precompiled view. + Debug.Assert(item.Descriptor != null); + + taskSource.SetResult(item.Descriptor); + return taskSource.Task; + } try { var descriptor = CompileAndEmit(normalizedPath); descriptor.ExpirationTokens = cacheEntryOptions.ExpirationTokens; - compilationTaskSource.SetResult(descriptor); + taskSource.SetResult(descriptor); } catch (Exception ex) { - compilationTaskSource.SetException(ex); + taskSource.SetException(ex); } } - return cacheEntry; + return taskSource.Task; + } + + private ViewCompilerWorkItem CreatePrecompiledWorkItem(string normalizedPath, CompiledViewDescriptor precompiledView) + { + // We have a precompiled view - but we're not sure that we can use it yet. + // + // We need to determine first if we have enough information to 'recompile' this view. If that's the case + // we'll create change tokens for all of the files. + // + // Then we'll attempt to validate if any of those files have different content than the original sources + // based on checksums. + if (precompiledView.Item == null || !ChecksumValidator.IsRecompilationSupported(precompiledView.Item)) + { + return new ViewCompilerWorkItem() + { + // If we don't have a checksum for the primary source file we can't recompile. + SupportsCompilation = false, + + ExpirationTokens = Array.Empty(), // Never expire because we can't recompile. + Descriptor = precompiledView, // This will be used as-is. + }; + } + + var item = new ViewCompilerWorkItem() + { + SupportsCompilation = true, + + Descriptor = precompiledView, // This might be used, if the checksums match. + + // Used to validate and recompile + NormalizedPath = normalizedPath, + ExpirationTokens = new List(), + }; + + var checksums = precompiledView.Item.GetChecksumMetadata(); + for (var i = 0; i < checksums.Count; i++) + { + // We rely on Razor to provide the right set of checksums. Trust the compiler, it has to do a good job, + // so it probably will. + item.ExpirationTokens.Add(_fileProvider.Watch(checksums[i].Identifier)); + } + + return item; + } + + private ViewCompilerWorkItem CreateRuntimeCompilationWorkItem(string normalizedPath) + { + var expirationTokens = new List() + { + _fileProvider.Watch(normalizedPath), + }; + + var projectItem = _templateEngine.Project.GetItem(normalizedPath); + if (!projectItem.Exists) + { + // If the file doesn't exist, we can't do compilation right now - we still want to cache + // the fact that we tried. This will allow us to retrigger compilation if the view file + // is added. + return new ViewCompilerWorkItem() + { + // We don't have enough information to compile + SupportsCompilation = false, + + Descriptor = new CompiledViewDescriptor() + { + RelativePath = normalizedPath, + ExpirationTokens = expirationTokens, + }, + + // We can try again if the file gets created. + ExpirationTokens = expirationTokens, + }; + } + + // OK this means we can do compilation. For now let's just identify the other files we need to watch + // so we can create the cache entry. Compilation will happen after we release the lock. + foreach (var importItem in _templateEngine.GetImportItems(projectItem)) + { + expirationTokens.Add(_fileProvider.Watch(importItem.FilePath)); + } + + return new ViewCompilerWorkItem() + { + SupportsCompilation = true, + + NormalizedPath = normalizedPath, + ExpirationTokens = expirationTokens, + }; } protected virtual CompiledViewDescriptor CompileAndEmit(string relativePath) @@ -220,13 +319,14 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal cSharpDocument.Diagnostics); } - var generatedAssembly = CompileAndEmit(codeDocument, cSharpDocument.GeneratedCode); - var viewAttribute = generatedAssembly.GetCustomAttribute(); - return new CompiledViewDescriptor - { - ViewAttribute = viewAttribute, - RelativePath = relativePath, - }; + var assembly = CompileAndEmit(codeDocument, cSharpDocument.GeneratedCode); + + // Anything we compile from source will use Razor 2.1 and so should have the new metadata. + var loader = new RazorCompiledItemLoader(); + var item = loader.LoadItems(assembly).SingleOrDefault(); + var attribute = assembly.GetCustomAttribute(); + + return new CompiledViewDescriptor(item, attribute); } internal Assembly CompileAndEmit(RazorCodeDocument codeDocument, string generatedCode) @@ -288,13 +388,24 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal return relativePath; } - if (!_normalizedPathLookup.TryGetValue(relativePath, out var normalizedPath)) + if (!_normalizedPathCache.TryGetValue(relativePath, out var normalizedPath)) { normalizedPath = ViewPath.NormalizePath(relativePath); - _normalizedPathLookup[relativePath] = normalizedPath; + _normalizedPathCache[relativePath] = normalizedPath; } return normalizedPath; } + + private class ViewCompilerWorkItem + { + public bool SupportsCompilation { get; set; } + + public string NormalizedPath { get; set; } + + public IList ExpirationTokens { get; set; } + + public CompiledViewDescriptor Descriptor { get; set; } + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.Razor/Properties/Resources.Designer.cs index f82255ab10..3b6a518dd3 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/Properties/Resources.Designer.cs @@ -378,6 +378,20 @@ namespace Microsoft.AspNetCore.Mvc.Razor internal static string FormatUnsupportedDebugInformationFormat(object p0) => string.Format(CultureInfo.CurrentCulture, GetString("UnsupportedDebugInformationFormat"), p0); + /// + /// At least one of the '{0}' or '{1}' values must be non-null. + /// + internal static string CompiledViewDescriptor_NoData + { + get => GetString("CompiledViewDescriptor_NoData"); + } + + /// + /// At least one of the '{0}' or '{1}' values must be non-null. + /// + internal static string FormatCompiledViewDescriptor_NoData(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("CompiledViewDescriptor_NoData"), p0, p1); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/Resources.resx b/src/Microsoft.AspNetCore.Mvc.Razor/Resources.resx index 63deeebd9b..19b0e8f053 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/Resources.resx +++ b/src/Microsoft.AspNetCore.Mvc.Razor/Resources.resx @@ -197,4 +197,7 @@ The debug type specified in the dependency context could be parsed. The debug type value '{0}' is not supported. - + + At least one of the '{0}' or '{1}' values must be non-null. + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/CompiledPageRouteModelProvider.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/CompiledPageRouteModelProvider.cs index 4a5f9aa72f..032f868abd 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/CompiledPageRouteModelProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/CompiledPageRouteModelProvider.cs @@ -8,7 +8,9 @@ using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.Razor.Compilation; +using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; +using Microsoft.AspNetCore.Razor.Language; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,19 +18,40 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { public class CompiledPageRouteModelProvider : IPageRouteModelProvider { - private readonly object _cacheLock = new object(); private readonly ApplicationPartManager _applicationManager; private readonly RazorPagesOptions _pagesOptions; + private readonly RazorTemplateEngine _templateEngine; private readonly ILogger _logger; - private List _cachedModels; public CompiledPageRouteModelProvider( ApplicationPartManager applicationManager, IOptions pagesOptionsAccessor, + RazorTemplateEngine templateEngine, ILoggerFactory loggerFactory) { + if (applicationManager == null) + { + throw new ArgumentNullException(nameof(applicationManager)); + } + + if (pagesOptionsAccessor == null) + { + throw new ArgumentNullException(nameof(pagesOptionsAccessor)); + } + + if (templateEngine == null) + { + throw new ArgumentNullException(nameof(templateEngine)); + } + + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + _applicationManager = applicationManager; _pagesOptions = pagesOptionsAccessor.Value; + _templateEngine = templateEngine; _logger = loggerFactory.CreateLogger(); } @@ -36,61 +59,60 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public void OnProvidersExecuting(PageRouteModelProviderContext context) { - EnsureCache(); - for (var i = 0; i < _cachedModels.Count; i++) + if (context == null) { - var pageModel = _cachedModels[i]; - context.RouteModels.Add(new PageRouteModel(pageModel)); + throw new ArgumentNullException(nameof(context)); } + + CreateModels(context.RouteModels); } public void OnProvidersExecuted(PageRouteModelProviderContext context) { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } } - private void EnsureCache() + private void CreateModels(IList results) { - lock (_cacheLock) + var rootDirectory = _pagesOptions.RootDirectory; + if (!rootDirectory.EndsWith("/", StringComparison.Ordinal)) { - if (_cachedModels != null) + rootDirectory = rootDirectory + "/"; + } + + var areaRootDirectory = _pagesOptions.AreaRootDirectory; + if (!areaRootDirectory.EndsWith("/", StringComparison.Ordinal)) + { + areaRootDirectory = areaRootDirectory + "/"; + } + + foreach (var viewDescriptor in GetViewDescriptors(_applicationManager)) + { + if (viewDescriptor.Item != null && !ChecksumValidator.IsItemValid(_templateEngine.Project, viewDescriptor.Item)) { - return; + // If we get here, this compiled Page has different local content, so ignore it. + continue; } - var rootDirectory = _pagesOptions.RootDirectory; - if (!rootDirectory.EndsWith("/", StringComparison.Ordinal)) + PageRouteModel model = null; + // When RootDirectory and AreaRootDirectory overlap (e.g. RootDirectory = '/', AreaRootDirectory = '/Areas'), we + // only want to allow a page to be associated with the area route. + if (_pagesOptions.AllowAreas && viewDescriptor.RelativePath.StartsWith(areaRootDirectory, StringComparison.OrdinalIgnoreCase)) { - rootDirectory = rootDirectory + "/"; + model = GetAreaPageRouteModel(areaRootDirectory, viewDescriptor); + } + else if (viewDescriptor.RelativePath.StartsWith(rootDirectory, StringComparison.OrdinalIgnoreCase)) + { + model = GetPageRouteModel(rootDirectory, viewDescriptor); } - var areaRootDirectory = _pagesOptions.AreaRootDirectory; - if (!areaRootDirectory.EndsWith("/", StringComparison.Ordinal)) + if (model != null) { - areaRootDirectory = areaRootDirectory + "/"; + results.Add(model); } - - var cachedApplicationModels = new List(); - foreach (var viewDescriptor in GetViewDescriptors(_applicationManager)) - { - PageRouteModel model = null; - // When RootDirectory and AreaRootDirectory overlap (e.g. RootDirectory = '/', AreaRootDirectory = '/Areas'), we - // only want to allow a page to be associated with the area route. - if (_pagesOptions.AllowAreas && viewDescriptor.RelativePath.StartsWith(areaRootDirectory, StringComparison.OrdinalIgnoreCase)) - { - model = GetAreaPageRouteModel(areaRootDirectory, viewDescriptor); - } - else if (viewDescriptor.RelativePath.StartsWith(rootDirectory, StringComparison.OrdinalIgnoreCase)) - { - model = GetPageRouteModel(rootDirectory, viewDescriptor); - } - - if (model != null) - { - cachedApplicationModels.Add(model); - } - } - - _cachedModels = cachedApplicationModels; } } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj index b360623583..e8db9d460a 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj @@ -30,6 +30,7 @@ + diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorBuildTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorBuildTest.cs new file mode 100644 index 0000000000..e9ecfacbdf --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorBuildTest.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class RazorBuildTest : IClassFixture> + { + public RazorBuildTest(MvcTestFixture fixture) + { + Client = fixture.Client; + } + + public HttpClient Client { get; } + + [Fact] + public async Task PrecompiledPage_LocalPageWithDifferentContent_NotUsed() + { + // Act + var response = await Client.GetAsync("http://localhost/Precompilation/Page"); + var responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Hello from buildtime-compiled precompilation page!", responseBody.Trim()); + } + + [Fact] + public async Task PrecompiledView_LocalViewWithDifferentContent_NotUsed() + { + // Act + var response = await Client.GetAsync("http://localhost/Precompilation/View"); + var responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Hello from buildtime-compiled precompilation view!", responseBody.Trim()); + } + + [Fact(Skip = "Not yet implemented")] + public async Task Rzc_LocalPageWithDifferentContent_IsUsed() + { + // Act + var response = await Client.GetAsync("http://localhost/Rzc/Page"); + var responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Hello from runtime-compiled rzc page!", responseBody.Trim()); + } + + [Fact(Skip = "Not yet implemented")] + public async Task Rzc_LocalViewWithDifferentContent_IsUsed() + { + // Act + var response = await Client.GetAsync("http://localhost/Rzc/View"); + var responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Hello from runtime-compiled rzc view!", responseBody.Trim()); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Razor.Test/Compilation/ViewsFeatureProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.Razor.Test/Compilation/ViewsFeatureProviderTest.cs index c61ac53b2b..7493f608d1 100644 --- a/test/Microsoft.AspNetCore.Mvc.Razor.Test/Compilation/ViewsFeatureProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Razor.Test/Compilation/ViewsFeatureProviderTest.cs @@ -6,7 +6,9 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Reflection.Emit; +using System.Text; using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Razor.Hosting; using Xunit; namespace Microsoft.AspNetCore.Mvc.Razor.Compilation @@ -17,14 +19,13 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation public void PopulateFeature_ReturnsEmptySequenceIfNoAssemblyPartHasViewAssembly() { // Arrange - var applicationPartManager = new ApplicationPartManager(); - applicationPartManager.ApplicationParts.Add( - new AssemblyPart(typeof(ViewsFeatureProviderTest).GetTypeInfo().Assembly)); - applicationPartManager.FeatureProviders.Add(new ViewsFeatureProvider()); + var partManager = new ApplicationPartManager(); + partManager.ApplicationParts.Add(new AssemblyPart(typeof(ViewsFeatureProviderTest).GetTypeInfo().Assembly)); + partManager.FeatureProviders.Add(new ViewsFeatureProvider()); var feature = new ViewsFeature(); // Act - applicationPartManager.PopulateFeature(feature); + partManager.PopulateFeature(feature); // Assert Assert.Empty(feature.ViewDescriptors); @@ -36,7 +37,29 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation // Arrange var part1 = new AssemblyPart(typeof(object).GetTypeInfo().Assembly); var part2 = new AssemblyPart(GetType().GetTypeInfo().Assembly); - var featureProvider = new TestableViewsFeatureProvider(new Dictionary> + + var items = new Dictionary> + { + { + part1, + new[] + { + new TestRazorCompiledItem(typeof(object), "mvc.1.0.view", "/Views/test/Index.cshtml", new object[]{ }), + + // This one doesn't have a RazorViewAttribute + new TestRazorCompiledItem(typeof(StringBuilder), "mvc.1.0.view", "/Views/test/About.cshtml", new object[]{ }), + } + }, + { + part2, + new[] + { + new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", "/Areas/Admin/Views/Index.cshtml", new object[]{ }), + } + }, + }; + + var attributes = new Dictionary> { { part1, @@ -50,35 +73,70 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation new[] { new RazorViewAttribute("/Areas/Admin/Views/Index.cshtml", typeof(string)), + + // This one doesn't have a RazorCompiledItem new RazorViewAttribute("/Areas/Admin/Views/About.cshtml", typeof(int)), } }, - }); + }; - var applicationPartManager = new ApplicationPartManager(); - applicationPartManager.ApplicationParts.Add(part1); - applicationPartManager.ApplicationParts.Add(part2); - applicationPartManager.FeatureProviders.Add(featureProvider); + var featureProvider = new TestableViewsFeatureProvider(items, attributes); + var partManager = new ApplicationPartManager(); + partManager.ApplicationParts.Add(part1); + partManager.ApplicationParts.Add(part2); + partManager.FeatureProviders.Add(featureProvider); var feature = new ViewsFeature(); // Act - applicationPartManager.PopulateFeature(feature); + partManager.PopulateFeature(feature); // Assert Assert.Collection(feature.ViewDescriptors.OrderBy(f => f.RelativePath, StringComparer.Ordinal), view => { + Assert.Empty(view.ExpirationTokens); + Assert.True(view.IsPrecompiled); + Assert.Null(view.Item); Assert.Equal("/Areas/Admin/Views/About.cshtml", view.RelativePath); + Assert.Equal(typeof(int), view.Type); + Assert.Equal("/Areas/Admin/Views/About.cshtml", view.ViewAttribute.Path); Assert.Equal(typeof(int), view.ViewAttribute.ViewType); }, view => { + // This one doesn't have a RazorCompiledItem + Assert.Empty(view.ExpirationTokens); + Assert.True(view.IsPrecompiled); + Assert.Equal("/Areas/Admin/Views/Index.cshtml", view.Item.Identifier); + Assert.Equal("mvc.1.0.view", view.Item.Kind); + Assert.Equal(typeof(string), view.Item.Type); Assert.Equal("/Areas/Admin/Views/Index.cshtml", view.RelativePath); + Assert.Equal(typeof(string), view.Type); + Assert.Equal("/Areas/Admin/Views/Index.cshtml", view.ViewAttribute.Path); Assert.Equal(typeof(string), view.ViewAttribute.ViewType); }, view => { + // This one doesn't have a RazorViewAttribute + Assert.Empty(view.ExpirationTokens); + Assert.True(view.IsPrecompiled); + Assert.Equal("/Views/test/About.cshtml", view.Item.Identifier); + Assert.Equal("mvc.1.0.view", view.Item.Kind); + Assert.Equal(typeof(StringBuilder), view.Item.Type); + Assert.Equal("/Views/test/About.cshtml", view.RelativePath); + Assert.Equal(typeof(StringBuilder), view.Type); + Assert.Null(view.ViewAttribute); + }, + view => + { + Assert.Empty(view.ExpirationTokens); + Assert.True(view.IsPrecompiled); + Assert.Equal("/Views/test/Index.cshtml", view.Item.Identifier); + Assert.Equal("mvc.1.0.view", view.Item.Kind); + Assert.Equal(typeof(object), view.Item.Type); Assert.Equal("/Views/test/Index.cshtml", view.RelativePath); + Assert.Equal(typeof(object), view.Type); + Assert.Equal("/Views/test/Index.cshtml", view.ViewAttribute.Path); Assert.Equal(typeof(object), view.ViewAttribute.ViewType); }); } @@ -88,50 +146,78 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Compilation { // Arrange var name = new AssemblyName($"DynamicAssembly-{Guid.NewGuid()}"); - var assembly = AssemblyBuilder.DefineDynamicAssembly(name, - AssemblyBuilderAccess.RunAndCollect); + var assembly = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.RunAndCollect); - var applicationPartManager = new ApplicationPartManager(); - applicationPartManager.ApplicationParts.Add(new AssemblyPart(assembly)); - applicationPartManager.FeatureProviders.Add(new ViewsFeatureProvider()); + var partManager = new ApplicationPartManager(); + partManager.ApplicationParts.Add(new AssemblyPart(assembly)); + partManager.FeatureProviders.Add(new ViewsFeatureProvider()); var feature = new ViewsFeature(); // Act - applicationPartManager.PopulateFeature(feature); + partManager.PopulateFeature(feature); // Assert Assert.Empty(feature.ViewDescriptors); } + [Fact] public void PopulateFeature_DoesNotFail_IfAssemblyHasEmptyLocation() { // Arrange var assembly = new AssemblyWithEmptyLocation(); - var applicationPartManager = new ApplicationPartManager(); - applicationPartManager.ApplicationParts.Add(new AssemblyPart(assembly)); - applicationPartManager.FeatureProviders.Add(new ViewsFeatureProvider()); + var partManager = new ApplicationPartManager(); + partManager.ApplicationParts.Add(new AssemblyPart(assembly)); + partManager.FeatureProviders.Add(new ViewsFeatureProvider()); var feature = new ViewsFeature(); // Act - applicationPartManager.PopulateFeature(feature); + partManager.PopulateFeature(feature); // Assert Assert.Empty(feature.ViewDescriptors); } + private class TestRazorCompiledItem : RazorCompiledItem + { + public TestRazorCompiledItem(Type type, string kind, string identifier, object[] metadata) + { + Type = type; + Kind = kind; + Identifier = identifier; + Metadata = metadata; + } + + public override string Identifier { get; } + + public override string Kind { get; } + + public override IReadOnlyList Metadata { get; } + + public override Type Type { get; } + } + private class TestableViewsFeatureProvider : ViewsFeatureProvider { - private readonly Dictionary> _attributeLookup; + private readonly Dictionary> _attributes; + private readonly Dictionary> _items; - public TestableViewsFeatureProvider(Dictionary> attributeLookup) + public TestableViewsFeatureProvider( + Dictionary> items, + Dictionary> attributes) { - _attributeLookup = attributeLookup; + _items = items; + _attributes = attributes; } protected override IEnumerable GetViewAttributes(AssemblyPart assemblyPart) { - return _attributeLookup[assemblyPart]; + return _attributes[assemblyPart]; + } + + protected override IReadOnlyList LoadItems(AssemblyPart assemblyPart) + { + return _items[assemblyPart]; } } diff --git a/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/ChecksumValidatorTest.cs b/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/ChecksumValidatorTest.cs new file mode 100644 index 0000000000..b95d2122e3 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/ChecksumValidatorTest.cs @@ -0,0 +1,199 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.Hosting; +using Microsoft.AspNetCore.Razor.Language; +using Moq; +using Xunit; +using static Microsoft.AspNetCore.Razor.Hosting.TestRazorCompiledItem; + +namespace Microsoft.AspNetCore.Mvc.Razor.Internal +{ + public class ChecksumValidatorTest + { + public ChecksumValidatorTest() + { + FileProvider = new TestFileProvider(); + Project = new FileProviderRazorProject(Mock.Of(a => a.FileProvider == FileProvider)); + } + + public RazorProject Project { get; } + + public TestFileProvider FileProvider { get; } + + [Fact] + public void IsRecompilationSupported_NoChecksums_ReturnsFalse() + { + // Arrange + var item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", "/Views/Home/Index.cstml", new object[] { }); + + // Act + var result = ChecksumValidator.IsRecompilationSupported(item); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsRecompilationSupported_NoPrimaryChecksum_ReturnsFalse() + { + // Arrange + var item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", "/Views/Home/Index.cstml", new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some import"), "/Views/Home/_ViewImports.cstml"), + }); + + // Act + var result = ChecksumValidator.IsRecompilationSupported(item); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsRecompilationSupported_HasPrimaryChecksum_ReturnsTrue() + { + // Arrange + var item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", "/Views/Home/Index.cstml", new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some import"), "/Views/Home/_ViewImports.cstml"), + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), "/Views/Home/Index.cstml"), + }); + + // Act + var result = ChecksumValidator.IsRecompilationSupported(item); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsItemValid_NoChecksums_ReturnsTrue() + { + // Arrange + var item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", "/Views/Home/Index.cstml", new object[] { }); + + // Act + var result = ChecksumValidator.IsItemValid(Project, item); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsItemValid_NoPrimaryChecksum_ReturnsTrue() + { + // Arrange + var item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", "/Views/Home/Index.cstml", new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some import"), "/Views/Home/_ViewImports.cstml"), + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), "/Views/Home/About.cstml"), + }); + + // Act + var result = ChecksumValidator.IsItemValid(Project, item); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsItemValid_PrimaryFileDoesNotExist_ReturnsTrue() + { + // Arrange + var item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", "/Views/Home/Index.cstml", new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some import"), "/Views/Home/_ViewImports.cstml"), + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), "/Views/Home/Index.cstml"), + }); + + FileProvider.AddFile("/Views/Home/_ViewImports.cstml", "dkdkfkdf"); // This will be ignored + + // Act + var result = ChecksumValidator.IsItemValid(Project, item); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsItemValid_PrimaryFileExistsButDoesNotMatch_ReturnsFalse() + { + // Arrange + var item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", "/Views/Home/Index.cstml", new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some import"), "/Views/Home/_ViewImports.cstml"), + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), "/Views/Home/Index.cstml"), + }); + + FileProvider.AddFile("/Views/Home/Index.cstml", "other content"); + + // Act + var result = ChecksumValidator.IsItemValid(Project, item); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsItemValid_ImportFileDoesNotExist_ReturnsFalse() + { + // Arrange + var item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", "/Views/Home/Index.cstml", new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some import"), "/Views/Home/_ViewImports.cstml"), + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), "/Views/Home/Index.cstml"), + }); + + FileProvider.AddFile("/Views/Home/Index.cstml", "some content"); + + // Act + var result = ChecksumValidator.IsItemValid(Project, item); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsItemValid_ImportFileExistsButDoesNotMatch_ReturnsFalse() + { + // Arrange + var item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", "/Views/Home/Index.cstml", new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some import"), "/Views/Home/_ViewImports.cstml"), + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), "/Views/Home/Index.cstml"), + }); + + FileProvider.AddFile("/Views/Home/Index.cstml", "some content"); + FileProvider.AddFile("/Views/Home/_ViewImports.cstml", "some other import"); + + // Act + var result = ChecksumValidator.IsItemValid(Project, item); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsItemValid_AllFilesMatch_ReturnsTrue() + { + // Arrange + var item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", "/Views/Home/Index.cstml", new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some other import"), "/Views/_ViewImports.cstml"), + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some import"), "/Views/Home/_ViewImports.cstml"), + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), "/Views/Home/Index.cstml"), + }); + + FileProvider.AddFile("/Views/Home/Index.cstml", "some content"); + FileProvider.AddFile("/Views/Home/_ViewImports.cstml", "some import"); + FileProvider.AddFile("/Views/_ViewImports.cstml", "some other import"); + + // Act + var result = ChecksumValidator.IsItemValid(Project, item); + + // Assert + Assert.True(result); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerTest.cs b/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerTest.cs index fcc38d701e..5111f83e89 100644 --- a/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Razor.Test/Internal/RazorViewCompilerTest.cs @@ -8,12 +8,14 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.Razor.Compilation; +using Microsoft.AspNetCore.Razor.Hosting; using Microsoft.AspNetCore.Razor.Language; using Microsoft.CodeAnalysis; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Xunit; +using static Microsoft.AspNetCore.Razor.Hosting.TestRazorCompiledItem; namespace Microsoft.AspNetCore.Mvc.Razor.Internal { @@ -55,7 +57,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal // Assert Assert.Same(result1, result2); Assert.Null(result1.ViewAttribute); - Assert.Collection(result1.ExpirationTokens, + Assert.Collection( + result1.ExpirationTokens, token => Assert.Equal(fileProvider.GetChangeToken(path), token)); } @@ -73,7 +76,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal // Assert Assert.NotNull(result.ViewAttribute); - Assert.Collection(result.ExpirationTokens, + Assert.Collection( + result.ExpirationTokens, token => Assert.Same(fileProvider.GetChangeToken(path), token), token => Assert.Same(fileProvider.GetChangeToken("/file/exists/_ViewImports.cshtml"), token), token => Assert.Same(fileProvider.GetChangeToken("/file/_ViewImports.cshtml"), token), @@ -246,27 +250,280 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal } [Fact] - public async Task CompileAsync_DoesNotRecompile_IfFileTriggerWasSetForPrecompiledView() + public async Task CompileAsync_PrecompiledViewWithoutChecksumForMainSource_DoesNotSupportRecompilation() { // Arrange var path = "/Views/Home/Index.cshtml"; + var fileProvider = new TestFileProvider(); var fileInfo = fileProvider.AddFile(path, "some content"); + var precompiledView = new CompiledViewDescriptor { RelativePath = path, + Item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", path, new object[] + { + new RazorSourceChecksumAttribute("sha1", GetChecksum("some content"), "/Views/Some-Other-View"), + }), }; + + var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + + // Act - 1 + var result = await viewCompiler.CompileAsync(path); + + // Assert - 1 + Assert.Same(precompiledView, result); + + // Act - 2 + fileProvider.Watch(path); + fileProvider.GetChangeToken(path).HasChanged = true; + result = await viewCompiler.CompileAsync(path); + + // Assert - 2 + Assert.Same(precompiledView, result); + } + + [Fact] + public async Task CompileAsync_PrecompiledViewWithoutAnyChecksum_DoesNotSupportRecompilation() + { + // Arrange + var path = "/Views/Home/Index.cshtml"; + + var fileProvider = new TestFileProvider(); + var fileInfo = fileProvider.AddFile(path, "some content"); + + var precompiledView = new CompiledViewDescriptor + { + RelativePath = path, + Item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", path, new object[] { }), + }; + + var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + + // Act - 1 + var result = await viewCompiler.CompileAsync(path); + + // Assert - 1 + Assert.Same(precompiledView, result); + + // Act - 2 + fileProvider.Watch(path); + fileProvider.GetChangeToken(path).HasChanged = true; + result = await viewCompiler.CompileAsync(path); + + // Assert - 2 + Assert.Same(precompiledView, result); + } + + [Fact] + public async Task CompileAsync_PrecompiledViewWithChecksum_UsesPrecompiledViewWhenChecksumIsMatch() + { + // Arrange + var path = "/Views/Home/Index.cshtml"; + + var fileProvider = new TestFileProvider(); + var fileInfo = fileProvider.AddFile(path, "some content"); + + var precompiledView = new CompiledViewDescriptor + { + RelativePath = path, + Item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", path, new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), path), + }), + }; + var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); // Act - fileProvider.Watch(path); - fileProvider.GetChangeToken(path).HasChanged = true; var result = await viewCompiler.CompileAsync(path); // Assert Assert.Same(precompiledView, result); } + [Fact] + public async Task CompileAsync_PrecompiledViewWithChecksum_CanRejectWhenChecksumFails() + { + // Arrange + var path = "/Views/Home/Index.cshtml"; + + var fileProvider = new TestFileProvider(); + var fileInfo = fileProvider.AddFile(path, "some content"); + + var expected = new CompiledViewDescriptor(); + + var precompiledView = new CompiledViewDescriptor + { + RelativePath = path, + Item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", path, new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some other content"), path), + }), + }; + + var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + viewCompiler.Compile = _ => expected; + + // Act + var result = await viewCompiler.CompileAsync(path); + + // Assert + Assert.Same(expected, result); + } + + [Fact] + public async Task CompileAsync_PrecompiledViewWithChecksum_CanRecompile() + { + // Arrange + var path = "/Views/Home/Index.cshtml"; + + var fileProvider = new TestFileProvider(); + var fileInfo = fileProvider.AddFile(path, "some content"); + + var expected2 = new CompiledViewDescriptor(); + + var precompiledView = new CompiledViewDescriptor + { + RelativePath = path, + Item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", path, new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), path), + }), + }; + + var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + + // Act - 1 + var result = await viewCompiler.CompileAsync(path); + + // Assert - 1 + Assert.Same(precompiledView, result); + + // Act - 2 + fileInfo.Content = "some other content"; + fileProvider.GetChangeToken(path).HasChanged = true; + viewCompiler.Compile = _ => expected2; + result = await viewCompiler.CompileAsync(path); + + // Assert - 2 + Assert.Same(expected2, result); + } + + [Fact] + public async Task CompileAsync_PrecompiledViewWithChecksum_DoesNotRecompiledWithoutContentChange() + { + // Arrange + var path = "/Views/Home/Index.cshtml"; + + var fileProvider = new TestFileProvider(); + var fileInfo = fileProvider.AddFile(path, "some content"); + + var precompiledView = new CompiledViewDescriptor + { + RelativePath = path, + Item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", path, new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), path), + }), + }; + + var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + + // Act - 1 + var result = await viewCompiler.CompileAsync(path); + + // Assert - 1 + Assert.Same(precompiledView, result); + + // Act - 2 + fileProvider.GetChangeToken(path).HasChanged = true; + result = await viewCompiler.CompileAsync(path); + + // Assert - 2 + Assert.Same(precompiledView, result); + } + + [Fact] + public async Task CompileAsync_PrecompiledViewWithChecksum_CanReusePrecompiledViewIfContentChangesToMatch() + { + // Arrange + var path = "/Views/Home/Index.cshtml"; + + var fileProvider = new TestFileProvider(); + var fileInfo = fileProvider.AddFile(path, "some other content"); + + var expected1 = new CompiledViewDescriptor(); + + var precompiledView = new CompiledViewDescriptor + { + RelativePath = path, + Item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", path, new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), path), + }), + }; + + var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + viewCompiler.Compile = _ => expected1; + + // Act - 1 + var result = await viewCompiler.CompileAsync(path); + + // Assert - 1 + Assert.Same(expected1, result); + + // Act - 2 + fileInfo.Content = "some content"; + fileProvider.GetChangeToken(path).HasChanged = true; + result = await viewCompiler.CompileAsync(path); + + // Assert - 2 + Assert.Same(precompiledView, result); + } + + [Fact] + public async Task CompileAsync_PrecompiledViewWithChecksum_CanRecompileWhenViewImportChanges() + { + // Arrange + var path = "/Views/Home/Index.cshtml"; + var importPath = "/Views/_ViewImports.cshtml"; + + var fileProvider = new TestFileProvider(); + var fileInfo = fileProvider.AddFile(path, "some content"); + var importFileInfo = fileProvider.AddFile(importPath, "some import"); + + var expected2 = new CompiledViewDescriptor(); + + var precompiledView = new CompiledViewDescriptor + { + RelativePath = path, + Item = new TestRazorCompiledItem(typeof(string), "mvc.1.0.view", path, new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), path), + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some import"), importPath), + }), + }; + + var viewCompiler = GetViewCompiler(fileProvider, precompiledViews: new[] { precompiledView }); + + // Act - 1 + var result = await viewCompiler.CompileAsync(path); + + // Assert - 1 + Assert.Same(precompiledView, result); + + // Act - 2 + importFileInfo.Content = "some import changed"; + fileProvider.GetChangeToken(importPath).HasChanged = true; + viewCompiler.Compile = _ => expected2; + result = await viewCompiler.CompileAsync(path); + + // Assert - 2 + Assert.Same(expected2, result); + } + [Fact] public async Task GetOrAdd_AllowsConcurrentCompilationOfMultipleRazorPages() { @@ -274,18 +531,23 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal var path1 = "/Views/Home/Index.cshtml"; var path2 = "/Views/Home/About.cshtml"; var waitDuration = TimeSpan.FromSeconds(20); + var fileProvider = new TestFileProvider(); fileProvider.AddFile(path1, "Index content"); fileProvider.AddFile(path2, "About content"); + var resetEvent1 = new AutoResetEvent(initialState: false); var resetEvent2 = new ManualResetEvent(initialState: false); - var cache = GetViewCompiler(fileProvider); + var compilingOne = false; var compilingTwo = false; + var result1 = new CompiledViewDescriptor(); var result2 = new CompiledViewDescriptor(); - cache.Compile = path => + var compiler = GetViewCompiler(fileProvider); + + compiler.Compile = path => { if (path == path1) { @@ -325,8 +587,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Internal }; // Act - var task1 = Task.Run(() => cache.CompileAsync(path1)); - var task2 = Task.Run(() => cache.CompileAsync(path2)); + var task1 = Task.Run(() => compiler.CompileAsync(path1)); + var task2 = Task.Run(() => compiler.CompileAsync(path2)); // Event 1 resetEvent1.Set(); diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageRouteModelProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageRouteModelProviderTest.cs index ca8aca079e..888b699202 100644 --- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageRouteModelProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageRouteModelProviderTest.cs @@ -6,102 +6,261 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.Razor.Compilation; +using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; +using Microsoft.AspNetCore.Razor.Hosting; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Moq; using Xunit; +using static Microsoft.AspNetCore.Razor.Hosting.TestRazorCompiledItem; namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { public class CompiledPageRouteModelProviderTest { + public CompiledPageRouteModelProviderTest() + { + FileProvider = new TestFileProvider(); + Project = new FileProviderRazorProject(Mock.Of(a => a.FileProvider == FileProvider)); + TemplateEngine = new RazorTemplateEngine(RazorEngine.Create(), Project); + + PagesOptions = new RazorPagesOptions(); + Provider = new TestCompiledPageRouteModelProvider(new ApplicationPartManager(), Options.Create(PagesOptions), TemplateEngine, NullLoggerFactory.Instance); + } + + public TestFileProvider FileProvider { get; } + + public RazorProject Project { get; } + + public RazorTemplateEngine TemplateEngine { get; } + + public RazorPagesOptions PagesOptions { get; } + + public TestCompiledPageRouteModelProvider Provider { get; } + [Fact] public void OnProvidersExecuting_AddsModelsForCompiledViews() { // Arrange - var descriptors = new[] + Provider.Descriptors.AddRange(new[] { - GetDescriptor("/Pages/About.cshtml"), - GetDescriptor("/Pages/Home.cshtml", "some-prefix"), - }; - var provider = new TestCompiledPageRouteModelProvider(descriptors, new RazorPagesOptions()); + CreateVersion_2_0_Descriptor("/Pages/About.cshtml"), + CreateVersion_2_0_Descriptor("/Pages/Home.cshtml", "some-prefix"), + }); + var context = new PageRouteModelProviderContext(); // Act - provider.OnProvidersExecuting(context); + Provider.OnProvidersExecuting(context); // Assert - Assert.Collection(context.RouteModels, + Assert.Collection( + context.RouteModels, result => { Assert.Equal("/Pages/About.cshtml", result.RelativePath); Assert.Equal("/About", result.ViewEnginePath); - Assert.Collection(result.Selectors, + Assert.Collection( + result.Selectors, selector => Assert.Equal("About", selector.AttributeRouteModel.Template)); - Assert.Collection(result.RouteValues.OrderBy(k => k.Key), - kvp => - { - Assert.Equal("page", kvp.Key); - Assert.Equal("/About", kvp.Value); - }); + Assert.Collection( + result.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/About", kvp.Value); + }); }, result => { Assert.Equal("/Pages/Home.cshtml", result.RelativePath); Assert.Equal("/Home", result.ViewEnginePath); - Assert.Collection(result.Selectors, + Assert.Collection( + result.Selectors, selector => Assert.Equal("Home/some-prefix", selector.AttributeRouteModel.Template)); - Assert.Collection(result.RouteValues.OrderBy(k => k.Key), - kvp => - { - Assert.Equal("page", kvp.Key); - Assert.Equal("/Home", kvp.Value); - }); + Assert.Collection( + result.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/Home", kvp.Value); + }); }); } + [Fact] // 2.1 adds some additional metadata to the view descriptors. We want to make sure both versions work. + public void OnProvidersExecuting_AddsModelsForCompiledViews_Version_2_1() + { + // Arrange + Provider.Descriptors.AddRange(new[] + { + CreateVersion_2_1_Descriptor("/Pages/About.cshtml"), + CreateVersion_2_1_Descriptor("/Pages/Home.cshtml", "some-prefix"), + }); + + var context = new PageRouteModelProviderContext(); + + // Act + Provider.OnProvidersExecuting(context); + + // Assert + Assert.Collection( + context.RouteModels, + result => + { + Assert.Equal("/Pages/About.cshtml", result.RelativePath); + Assert.Equal("/About", result.ViewEnginePath); + Assert.Collection( + result.Selectors, + selector => Assert.Equal("About", selector.AttributeRouteModel.Template)); + Assert.Collection( + result.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/About", kvp.Value); + }); + }, + result => + { + Assert.Equal("/Pages/Home.cshtml", result.RelativePath); + Assert.Equal("/Home", result.ViewEnginePath); + Assert.Collection( + result.Selectors, + selector => Assert.Equal("Home/some-prefix", selector.AttributeRouteModel.Template)); + Assert.Collection( + result.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/Home", kvp.Value); + }); + }); + } + + [Fact] + public void OnProvidersExecuting_ValidatesChecksum_RejectsPageWhenContentDoesntMatch() + { + // Arrange + Provider.Descriptors.AddRange(new[] + { + CreateVersion_2_1_Descriptor("/Pages/About.cshtml", metadata: new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), "/Pages/About.cshtml"), + }), + }); + + FileProvider.AddFile("/Pages/About.cshtml", "some other content"); + + var context = new PageRouteModelProviderContext(); + + // Act + Provider.OnProvidersExecuting(context); + + // Assert + Assert.Empty(context.RouteModels); + } + + [Fact] + public void OnProvidersExecuting_ValidatesChecksum_AcceptsPageWhenContentMatches() + { + // Arrange + Provider.Descriptors.AddRange(new[] + { + CreateVersion_2_1_Descriptor("/Pages/About.cshtml", metadata: new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), "/Pages/About.cshtml"), + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some import"), "/Pages/_ViewImports.cshtml"), + }), + }); + + FileProvider.AddFile("/Pages/About.cshtml", "some content"); + FileProvider.AddFile("/Pages/_ViewImports.cshtml", "some import"); + + var context = new PageRouteModelProviderContext(); + + // Act + Provider.OnProvidersExecuting(context); + + // Assert + Assert.Collection( + context.RouteModels, + result => Assert.Equal("/Pages/About.cshtml", result.RelativePath)); + } + + [Fact] + public void OnProvidersExecuting_ValidatesChecksum_SkipsValidationWhenMainSourceMissing() + { + // Arrange + Provider.Descriptors.AddRange(new[] + { + CreateVersion_2_1_Descriptor("/Pages/About.cshtml", metadata: new object[] + { + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some content"), "/Pages/About.cshtml"), + new RazorSourceChecksumAttribute("SHA1", GetChecksum("some import"), "/Pages/_ViewImports.cshtml"), + }), + }); + + FileProvider.AddFile("/Pages/_ViewImports.cshtml", "some other import"); + + var context = new PageRouteModelProviderContext(); + + // Act + Provider.OnProvidersExecuting(context); + + // Assert + Assert.Collection( + context.RouteModels, + result => Assert.Equal("/Pages/About.cshtml", result.RelativePath)); + } + [Fact] public void OnProvidersExecuting_AddsModelsForCompiledAreaPages() { // Arrange - var descriptors = new[] + Provider.Descriptors.AddRange(new[] { - GetDescriptor("/Features/Products/Files/About.cshtml"), - GetDescriptor("/Features/Products/Files/Manage/Index.cshtml"), - GetDescriptor("/Features/Products/Files/Manage/Edit.cshtml", "{id}"), - }; - var options = new RazorPagesOptions - { - AllowAreas = true, - AreaRootDirectory = "/Features", - RootDirectory = "/Files", - }; - var provider = new TestCompiledPageRouteModelProvider(descriptors, options); + CreateVersion_2_0_Descriptor("/Features/Products/Files/About.cshtml"), + CreateVersion_2_0_Descriptor("/Features/Products/Files/Manage/Index.cshtml"), + CreateVersion_2_0_Descriptor("/Features/Products/Files/Manage/Edit.cshtml", "{id}"), + }); + + PagesOptions.AllowAreas = true; + PagesOptions.AreaRootDirectory = "/Features"; + PagesOptions.RootDirectory = "/Files"; + var context = new PageRouteModelProviderContext(); // Act - provider.OnProvidersExecuting(context); + Provider.OnProvidersExecuting(context); // Assert - Assert.Collection(context.RouteModels, + Assert.Collection( + context.RouteModels, result => { Assert.Equal("/Features/Products/Files/About.cshtml", result.RelativePath); Assert.Equal("/About", result.ViewEnginePath); - Assert.Collection(result.Selectors, + Assert.Collection( + result.Selectors, selector => Assert.Equal("Products/About", selector.AttributeRouteModel.Template)); - Assert.Collection(result.RouteValues.OrderBy(k => k.Key), - kvp => - { - Assert.Equal("area", kvp.Key); - Assert.Equal("Products", kvp.Value); - }, - kvp => - { - Assert.Equal("page", kvp.Key); - Assert.Equal("/About", kvp.Value); - }); + Assert.Collection( + result.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.Equal("Products", kvp.Value); + }, + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/About", kvp.Value); + }); }, result => { @@ -110,35 +269,38 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal Assert.Collection(result.Selectors, selector => Assert.Equal("Products/Manage/Index", selector.AttributeRouteModel.Template), selector => Assert.Equal("Products/Manage", selector.AttributeRouteModel.Template)); - Assert.Collection(result.RouteValues.OrderBy(k => k.Key), - kvp => - { - Assert.Equal("area", kvp.Key); - Assert.Equal("Products", kvp.Value); - }, - kvp => - { - Assert.Equal("page", kvp.Key); - Assert.Equal("/Manage/Index", kvp.Value); - }); + Assert.Collection( + result.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.Equal("Products", kvp.Value); + }, + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/Manage/Index", kvp.Value); + }); }, result => { Assert.Equal("/Features/Products/Files/Manage/Edit.cshtml", result.RelativePath); Assert.Equal("/Manage/Edit", result.ViewEnginePath); - Assert.Collection(result.Selectors, + Assert.Collection( + result.Selectors, selector => Assert.Equal("Products/Manage/Edit/{id}", selector.AttributeRouteModel.Template)); - Assert.Collection(result.RouteValues.OrderBy(k => k.Key), - kvp => - { - Assert.Equal("area", kvp.Key); - Assert.Equal("Products", kvp.Value); - }, - kvp => - { - Assert.Equal("page", kvp.Key); - Assert.Equal("/Manage/Edit", kvp.Value); - }); + Assert.Collection( + result.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.Equal("Products", kvp.Value); + }, + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/Manage/Edit", kvp.Value); + }); }); } @@ -146,32 +308,36 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public void OnProvidersExecuting_DoesNotAddsModelsForAreaPages_IfFeatureIsDisabled() { // Arrange - var descriptors = new[] + Provider.Descriptors.AddRange(new[] { - GetDescriptor("/Pages/About.cshtml"), - GetDescriptor("/Areas/Accounts/Pages/Home.cshtml"), - }; - var options = new RazorPagesOptions { AllowAreas = false }; - var provider = new TestCompiledPageRouteModelProvider(descriptors, options); + CreateVersion_2_0_Descriptor("/Pages/About.cshtml"), + CreateVersion_2_0_Descriptor("/Areas/Accounts/Pages/Home.cshtml"), + }); + + PagesOptions.AllowAreas = false; + var context = new PageRouteModelProviderContext(); // Act - provider.OnProvidersExecuting(context); + Provider.OnProvidersExecuting(context); // Assert - Assert.Collection(context.RouteModels, + Assert.Collection( + context.RouteModels, result => { Assert.Equal("/Pages/About.cshtml", result.RelativePath); Assert.Equal("/About", result.ViewEnginePath); - Assert.Collection(result.Selectors, + Assert.Collection( + result.Selectors, selector => Assert.Equal("About", selector.AttributeRouteModel.Template)); - Assert.Collection(result.RouteValues.OrderBy(k => k.Key), - kvp => - { - Assert.Equal("page", kvp.Key); - Assert.Equal("/About", kvp.Value); - }); + Assert.Collection( + result.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/About", kvp.Value); + }); }); } @@ -179,56 +345,59 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public void OnProvidersExecuting_DoesNotAddAreaAndNonAreaRoutesForAPage() { // Arrange - var descriptors = new[] + Provider.Descriptors.AddRange(new[] { - GetDescriptor("/Areas/Accounts/Manage/Home.cshtml"), - GetDescriptor("/Areas/About.cshtml"), - GetDescriptor("/Contact.cshtml"), - }; - var options = new RazorPagesOptions - { - AllowAreas = true, - AreaRootDirectory = "/Areas", - RootDirectory = "/", - }; - var provider = new TestCompiledPageRouteModelProvider(descriptors, options); + CreateVersion_2_0_Descriptor("/Areas/Accounts/Manage/Home.cshtml"), + CreateVersion_2_0_Descriptor("/Areas/About.cshtml"), + CreateVersion_2_0_Descriptor("/Contact.cshtml"), + }); + + PagesOptions.AllowAreas = true; + PagesOptions.AreaRootDirectory = "/Areas"; + PagesOptions.RootDirectory = "/"; + var context = new PageRouteModelProviderContext(); // Act - provider.OnProvidersExecuting(context); + Provider.OnProvidersExecuting(context); // Assert - Assert.Collection(context.RouteModels, + Assert.Collection( + context.RouteModels, result => { Assert.Equal("/Areas/Accounts/Manage/Home.cshtml", result.RelativePath); Assert.Equal("/Manage/Home", result.ViewEnginePath); - Assert.Collection(result.Selectors, + Assert.Collection( + result.Selectors, selector => Assert.Equal("Accounts/Manage/Home", selector.AttributeRouteModel.Template)); - Assert.Collection(result.RouteValues.OrderBy(k => k.Key), - kvp => - { - Assert.Equal("area", kvp.Key); - Assert.Equal("Accounts", kvp.Value); - }, - kvp => - { - Assert.Equal("page", kvp.Key); - Assert.Equal("/Manage/Home", kvp.Value); - }); + Assert.Collection( + result.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.Equal("Accounts", kvp.Value); + }, + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/Manage/Home", kvp.Value); + }); }, result => { Assert.Equal("/Contact.cshtml", result.RelativePath); Assert.Equal("/Contact", result.ViewEnginePath); - Assert.Collection(result.Selectors, + Assert.Collection( + result.Selectors, selector => Assert.Equal("Contact", selector.AttributeRouteModel.Template)); - Assert.Collection(result.RouteValues.OrderBy(k => k.Key), - kvp => - { - Assert.Equal("page", kvp.Key); - Assert.Equal("/Contact", kvp.Value); - }); + Assert.Collection( + result.RouteValues.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/Contact", kvp.Value); + }); }); } @@ -236,24 +405,28 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public void OnProvidersExecuting_AddsMultipleSelectorsForIndexPage_WithIndexAtRoot() { // Arrange - var descriptors = new[] + Provider.Descriptors.AddRange(new[] { - GetDescriptor("/Pages/Index.cshtml"), - GetDescriptor("/Pages/Admin/Index.cshtml", "some-template"), - }; - var provider = new TestCompiledPageRouteModelProvider(descriptors, new RazorPagesOptions { RootDirectory = "/" }); + CreateVersion_2_0_Descriptor("/Pages/Index.cshtml"), + CreateVersion_2_0_Descriptor("/Pages/Admin/Index.cshtml", "some-template"), + }); + + PagesOptions.RootDirectory = "/"; + var context = new PageRouteModelProviderContext(); // Act - provider.OnProvidersExecuting(context); + Provider.OnProvidersExecuting(context); // Assert - Assert.Collection(context.RouteModels, + Assert.Collection( + context.RouteModels, result => { Assert.Equal("/Pages/Index.cshtml", result.RelativePath); Assert.Equal("/Pages/Index", result.ViewEnginePath); - Assert.Collection(result.Selectors, + Assert.Collection( + result.Selectors, selector => Assert.Equal("Pages/Index", selector.AttributeRouteModel.Template), selector => Assert.Equal("Pages", selector.AttributeRouteModel.Template)); }, @@ -261,7 +434,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { Assert.Equal("/Pages/Admin/Index.cshtml", result.RelativePath); Assert.Equal("/Pages/Admin/Index", result.ViewEnginePath); - Assert.Collection(result.Selectors, + Assert.Collection( + result.Selectors, selector => Assert.Equal("Pages/Admin/Index/some-template", selector.AttributeRouteModel.Template), selector => Assert.Equal("Pages/Admin/some-template", selector.AttributeRouteModel.Template)); }); @@ -271,24 +445,26 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public void OnProvidersExecuting_AddsMultipleSelectorsForIndexPage() { // Arrange - var descriptors = new[] + Provider.Descriptors.AddRange(new[] { - GetDescriptor("/Pages/Index.cshtml"), - GetDescriptor("/Pages/Admin/Index.cshtml", "some-template"), - }; - var provider = new TestCompiledPageRouteModelProvider(descriptors, new RazorPagesOptions()); + CreateVersion_2_0_Descriptor("/Pages/Index.cshtml"), + CreateVersion_2_0_Descriptor("/Pages/Admin/Index.cshtml", "some-template"), + }); + var context = new PageRouteModelProviderContext(); // Act - provider.OnProvidersExecuting(context); + Provider.OnProvidersExecuting(context); // Assert - Assert.Collection(context.RouteModels, + Assert.Collection( + context.RouteModels, result => { Assert.Equal("/Pages/Index.cshtml", result.RelativePath); Assert.Equal("/Index", result.ViewEnginePath); - Assert.Collection(result.Selectors, + Assert.Collection( + result.Selectors, selector => Assert.Equal("Index", selector.AttributeRouteModel.Template), selector => Assert.Equal("", selector.AttributeRouteModel.Template)); }, @@ -296,7 +472,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal { Assert.Equal("/Pages/Admin/Index.cshtml", result.RelativePath); Assert.Equal("/Admin/Index", result.ViewEnginePath); - Assert.Collection(result.Selectors, + Assert.Collection( + result.Selectors, selector => Assert.Equal("Admin/Index/some-template", selector.AttributeRouteModel.Template), selector => Assert.Equal("Admin/some-template", selector.AttributeRouteModel.Template)); }); @@ -306,40 +483,57 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal public void OnProvidersExecuting_ThrowsIfRouteTemplateHasOverridePattern() { // Arrange - var descriptors = new[] + Provider.Descriptors.AddRange(new[] { - GetDescriptor("/Pages/Index.cshtml"), - GetDescriptor("/Pages/Home.cshtml", "/some-prefix"), - }; - var provider = new TestCompiledPageRouteModelProvider(descriptors, new RazorPagesOptions()); + CreateVersion_2_0_Descriptor("/Pages/Index.cshtml"), + CreateVersion_2_0_Descriptor("/Pages/Home.cshtml", "/some-prefix"), + }); + var context = new PageRouteModelProviderContext(); // Act & Assert - var ex = Assert.Throws(() => provider.OnProvidersExecuting(context)); - Assert.Equal("The route for the page at '/Pages/Home.cshtml' cannot start with / or ~/. Pages do not support overriding the file path of the page.", - ex.Message); + var exception = Assert.Throws(() => Provider.OnProvidersExecuting(context)); + Assert.Equal( + "The route for the page at '/Pages/Home.cshtml' cannot start with / or ~/. Pages do not support overriding the file path of the page.", + exception.Message); } - private static CompiledViewDescriptor GetDescriptor(string path, string prefix = "") + private static CompiledViewDescriptor CreateVersion_2_0_Descriptor(string path, string routeTemplate = "") { return new CompiledViewDescriptor { RelativePath = path, - ViewAttribute = new RazorPageAttribute(path, typeof(object), prefix), + ViewAttribute = new RazorPageAttribute(path, typeof(object), routeTemplate), + }; + } + + private static CompiledViewDescriptor CreateVersion_2_1_Descriptor( + string path, + string routeTemplate = "", + object[] metadata = null) + { + return new CompiledViewDescriptor + { + RelativePath = path, + ViewAttribute = new RazorPageAttribute(path, typeof(object), routeTemplate), + Item = new TestRazorCompiledItem(typeof(object), "mvc.1.0.razor-page", path, metadata ?? Array.Empty()), }; } public class TestCompiledPageRouteModelProvider : CompiledPageRouteModelProvider { - private readonly IEnumerable _descriptors; - - public TestCompiledPageRouteModelProvider(IEnumerable descriptors, RazorPagesOptions options) - : base(new ApplicationPartManager(), Options.Create(options), NullLoggerFactory.Instance) + public TestCompiledPageRouteModelProvider( + ApplicationPartManager partManager, + IOptions options, + RazorTemplateEngine templateEngine, + ILoggerFactory loggerFactory) + : base(partManager, options, templateEngine, loggerFactory) { - _descriptors = descriptors; } - protected override IEnumerable GetViewDescriptors(ApplicationPartManager applicationManager) => _descriptors; + public List Descriptors { get; } = new List(); + + protected override IEnumerable GetViewDescriptors(ApplicationPartManager applicationManager) => Descriptors; } } } diff --git a/test/Microsoft.AspNetCore.Mvc.TestCommon/Microsoft.AspNetCore.Mvc.TestCommon.csproj b/test/Microsoft.AspNetCore.Mvc.TestCommon/Microsoft.AspNetCore.Mvc.TestCommon.csproj index 3198cc2aa0..72f9e5bb96 100644 --- a/test/Microsoft.AspNetCore.Mvc.TestCommon/Microsoft.AspNetCore.Mvc.TestCommon.csproj +++ b/test/Microsoft.AspNetCore.Mvc.TestCommon/Microsoft.AspNetCore.Mvc.TestCommon.csproj @@ -11,6 +11,7 @@ + diff --git a/test/Microsoft.AspNetCore.Mvc.TestCommon/TestRazorCompiledItem.cs b/test/Microsoft.AspNetCore.Mvc.TestCommon/TestRazorCompiledItem.cs new file mode 100644 index 0000000000..6bd6b246e9 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.TestCommon/TestRazorCompiledItem.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; + +namespace Microsoft.AspNetCore.Razor.Hosting +{ + public class TestRazorCompiledItem : RazorCompiledItem + { + public TestRazorCompiledItem(Type type, string kind, string identifier, object[] metadata) + { + Type = type; + Kind = kind; + Identifier = identifier; + Metadata = metadata; + } + + public override string Identifier { get; } + + public override string Kind { get; } + + public override IReadOnlyList Metadata { get; } + + public override Type Type { get; } + + public static string GetChecksum(string content) + { + byte[] bytes; + using (var sha = SHA1.Create()) + { + bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(content)); + } + + var result = new StringBuilder(bytes.Length); + for (var i = 0; i < bytes.Length; i++) + { + // The x2 format means lowercase hex, where each byte is a 2-character string. + result.Append(bytes[i].ToString("x2")); + } + + return result.ToString(); + } + } +} \ No newline at end of file diff --git a/test/WebSites/RazorBuildWebSite.PrecompiledViews/Pages/Precompilation/Page.cs b/test/WebSites/RazorBuildWebSite.PrecompiledViews/Pages/Precompilation/Page.cs new file mode 100644 index 0000000000..989480bc6d --- /dev/null +++ b/test/WebSites/RazorBuildWebSite.PrecompiledViews/Pages/Precompilation/Page.cs @@ -0,0 +1,39 @@ +#pragma checksum "D:\k\Mvc\test\WebSites\RazorBuildWebSite\Pages\Precompilation\Page.cshtml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "f3a9fa49018f90b3470f6c0e4f475d1d8c9cd456" +// +#pragma warning disable 1591 +[assembly: global::Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.RazorPageAttribute(@"/Pages/Precompilation/Page.cshtml", typeof(RazorBuildWebSite.Pages.Precompilation._Pages_Precompilation_Page), null)] +namespace RazorBuildWebSite.Pages.Precompilation +{ +#line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Mvc.Rendering; + using Microsoft.AspNetCore.Mvc.ViewFeatures; + public class _Pages_Precompilation_Page : global::Microsoft.AspNetCore.Mvc.RazorPages.Page + { +#pragma warning disable 1998 + public async override global::System.Threading.Tasks.Task ExecuteAsync() + { + BeginContext(26, 52, true); + WriteLiteral("\r\nHello from buildtime-compiled precompilation page!\r\n"); + EndContext(); + } +#pragma warning restore 1998 + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.ViewFeatures.IModelExpressionProvider ModelExpressionProvider { get; private set; } + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.IUrlHelper Url { get; private set; } + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.IViewComponentHelper Component { get; private set; } + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.Rendering.IJsonHelper Json { get; private set; } + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper Html { get; private set; } + public global::Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary ViewData => (global::Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary)PageContext?.ViewData; + public Page_Model Model => ViewData.Model; + } +} +#pragma warning restore 1591 diff --git a/test/WebSites/RazorBuildWebSite.PrecompiledViews/Pages/Precompilation/Page_Model.cs b/test/WebSites/RazorBuildWebSite.PrecompiledViews/Pages/Precompilation/Page_Model.cs new file mode 100644 index 0000000000..bc6db7744e --- /dev/null +++ b/test/WebSites/RazorBuildWebSite.PrecompiledViews/Pages/Precompilation/Page_Model.cs @@ -0,0 +1,9 @@ + +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace RazorBuildWebSite.Pages.Precompilation +{ + public class Page_Model : PageModel + { + } +} diff --git a/test/WebSites/RazorBuildWebSite.PrecompiledViews/RazorBuildWebSite.PrecompiledViews.csproj b/test/WebSites/RazorBuildWebSite.PrecompiledViews/RazorBuildWebSite.PrecompiledViews.csproj new file mode 100644 index 0000000000..73549c2142 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite.PrecompiledViews/RazorBuildWebSite.PrecompiledViews.csproj @@ -0,0 +1,12 @@ + + + + $(StandardTestWebsiteTfms) + $(DefineConstants) + + + + + + + diff --git a/test/WebSites/RazorBuildWebSite.PrecompiledViews/Views/Precompilation/View.cs b/test/WebSites/RazorBuildWebSite.PrecompiledViews/Views/Precompilation/View.cs new file mode 100644 index 0000000000..704f7fbeee --- /dev/null +++ b/test/WebSites/RazorBuildWebSite.PrecompiledViews/Views/Precompilation/View.cs @@ -0,0 +1,37 @@ +#pragma checksum "D:\k\Mvc\test\WebSites\RazorBuildWebSite\Views\Precompilation\View.cshtml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "a09a0106df2e63aecf6fc6ddf30df39b489d9783" +// +#pragma warning disable 1591 +[assembly: global::Microsoft.AspNetCore.Mvc.Razor.Compilation.RazorViewAttribute(@"/Views/Precompilation/View.cshtml", typeof(RazorBuildWebSite.Views.Precompilation._Views_Precompilation_View))] +namespace RazorBuildWebSite.Views.Precompilation +{ +#line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Mvc.Rendering; + using Microsoft.AspNetCore.Mvc.ViewFeatures; + public class _Views_Precompilation_View : global::Microsoft.AspNetCore.Mvc.Razor.RazorPage + { +#pragma warning disable 1998 + public async override global::System.Threading.Tasks.Task ExecuteAsync() + { + BeginContext(0, 48, true); + WriteLiteral("Hello from buildtime-compiled precompilation view!"); + EndContext(); + } +#pragma warning restore 1998 + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.ViewFeatures.IModelExpressionProvider ModelExpressionProvider { get; private set; } + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.IUrlHelper Url { get; private set; } + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.IViewComponentHelper Component { get; private set; } + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.Rendering.IJsonHelper Json { get; private set; } + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper Html { get; private set; } + } +} +#pragma warning restore 1591 diff --git a/test/WebSites/RazorBuildWebSite.Views/Pages/Rzc/Page.cs b/test/WebSites/RazorBuildWebSite.Views/Pages/Rzc/Page.cs new file mode 100644 index 0000000000..76d83023c6 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite.Views/Pages/Rzc/Page.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace RazorBuildWebSite.Views.Pages.Rzc +{ + class Page + { + } +} diff --git a/test/WebSites/RazorBuildWebSite.Views/Pages/Rzc/Page_Model.cs b/test/WebSites/RazorBuildWebSite.Views/Pages/Rzc/Page_Model.cs new file mode 100644 index 0000000000..bccaaf5373 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite.Views/Pages/Rzc/Page_Model.cs @@ -0,0 +1,9 @@ + +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace RazorBuildWebSite.Views.Pages.Rzc +{ + public class Page_Model : PageModel + { + } +} diff --git a/test/WebSites/RazorBuildWebSite.Views/RazorBuildWebSite.Views.csproj b/test/WebSites/RazorBuildWebSite.Views/RazorBuildWebSite.Views.csproj new file mode 100644 index 0000000000..73549c2142 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite.Views/RazorBuildWebSite.Views.csproj @@ -0,0 +1,12 @@ + + + + $(StandardTestWebsiteTfms) + $(DefineConstants) + + + + + + + diff --git a/test/WebSites/RazorBuildWebSite.Views/Views/Rzc/View.cs b/test/WebSites/RazorBuildWebSite.Views/Views/Rzc/View.cs new file mode 100644 index 0000000000..eae030ba4e --- /dev/null +++ b/test/WebSites/RazorBuildWebSite.Views/Views/Rzc/View.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace RazorBuildWebSite.Views.Views.Rzc +{ + class Index + { + } +} diff --git a/test/WebSites/RazorBuildWebSite/Controllers/PrecompilationController.cs b/test/WebSites/RazorBuildWebSite/Controllers/PrecompilationController.cs new file mode 100644 index 0000000000..a14f94a13f --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/Controllers/PrecompilationController.cs @@ -0,0 +1,13 @@ + +using Microsoft.AspNetCore.Mvc; + +namespace RazorBuildWebSite.Controllers +{ + public class PrecompilationController : Controller + { + public new ActionResult View() + { + return base.View(); + } + } +} diff --git a/test/WebSites/RazorBuildWebSite/Controllers/RzcController.cs b/test/WebSites/RazorBuildWebSite/Controllers/RzcController.cs new file mode 100644 index 0000000000..cd032017a2 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/Controllers/RzcController.cs @@ -0,0 +1,13 @@ + +using Microsoft.AspNetCore.Mvc; + +namespace RazorBuildWebSite.Controllers +{ + public class RzcController : Controller + { + public new ActionResult View() + { + return base.View(); + } + } +} diff --git a/test/WebSites/RazorBuildWebSite/Pages/Precompilation/Page.cshtml b/test/WebSites/RazorBuildWebSite/Pages/Precompilation/Page.cshtml new file mode 100644 index 0000000000..7a399ab8a0 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/Pages/Precompilation/Page.cshtml @@ -0,0 +1,4 @@ +@page +@model Page_Model + +Hello from runtime-compiled precompilation page! diff --git a/test/WebSites/RazorBuildWebSite/Pages/Precompilation/Page.cshtml.cs b/test/WebSites/RazorBuildWebSite/Pages/Precompilation/Page.cshtml.cs new file mode 100644 index 0000000000..8e7b83ecae --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/Pages/Precompilation/Page.cshtml.cs @@ -0,0 +1,9 @@ + +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace RazorBuildWebSite.Pages.Precompilation +{ + public class Page_Model : PageModel + { + } +} \ No newline at end of file diff --git a/test/WebSites/RazorBuildWebSite/Pages/Rzc/Page.cshtml b/test/WebSites/RazorBuildWebSite/Pages/Rzc/Page.cshtml new file mode 100644 index 0000000000..44dab72c6b --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/Pages/Rzc/Page.cshtml @@ -0,0 +1,4 @@ +@page +@model Page_Model + +Hello from runtime-compiled rzc page! diff --git a/test/WebSites/RazorBuildWebSite/Pages/Rzc/Page.cshtml.cs b/test/WebSites/RazorBuildWebSite/Pages/Rzc/Page.cshtml.cs new file mode 100644 index 0000000000..9e665e54a4 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/Pages/Rzc/Page.cshtml.cs @@ -0,0 +1,9 @@ + +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace RazorBuildWebSite.Pages.Rzc +{ + public class Page_Model : PageModel + { + } +} \ No newline at end of file diff --git a/test/WebSites/RazorBuildWebSite/Pages/_ViewImports.cshtml b/test/WebSites/RazorBuildWebSite/Pages/_ViewImports.cshtml new file mode 100644 index 0000000000..7080884287 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/Pages/_ViewImports.cshtml @@ -0,0 +1 @@ +@namespace RazorBuildWebSite.Pages \ No newline at end of file diff --git a/test/WebSites/RazorBuildWebSite/RazorBuildWebSite.csproj b/test/WebSites/RazorBuildWebSite/RazorBuildWebSite.csproj new file mode 100644 index 0000000000..c9173c2836 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/RazorBuildWebSite.csproj @@ -0,0 +1,27 @@ + + + + $(StandardTestWebsiteTfms) + $(DefineConstants) + + + + + + + + + + + + + + + + + + + diff --git a/test/WebSites/RazorBuildWebSite/Startup.cs b/test/WebSites/RazorBuildWebSite/Startup.cs new file mode 100644 index 0000000000..4d17168cae --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/Startup.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace RazorBuildWebSite +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseMvcWithDefaultRoute(); + } + + public static void Main(string[] args) + { + var host = new WebHostBuilder() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseStartup() + .UseKestrel() + .UseIISIntegration() + .Build(); + + host.Run(); + } + } +} diff --git a/test/WebSites/RazorBuildWebSite/Views/Precompilation/View.cshtml b/test/WebSites/RazorBuildWebSite/Views/Precompilation/View.cshtml new file mode 100644 index 0000000000..92afcc7180 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/Views/Precompilation/View.cshtml @@ -0,0 +1 @@ +Hello from runtime-compiled precompilation view! \ No newline at end of file diff --git a/test/WebSites/RazorBuildWebSite/Views/Rzc/View.cshtml b/test/WebSites/RazorBuildWebSite/Views/Rzc/View.cshtml new file mode 100644 index 0000000000..9b313496e3 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/Views/Rzc/View.cshtml @@ -0,0 +1 @@ +Hello from runtime-compiled rzc view! \ No newline at end of file diff --git a/test/WebSites/RazorBuildWebSite/Views/_ViewImports.cshtml b/test/WebSites/RazorBuildWebSite/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..9f1dc56476 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/Views/_ViewImports.cshtml @@ -0,0 +1 @@ +@namespace RazorBuildWebSite.Views \ No newline at end of file diff --git a/test/WebSites/RazorBuildWebSite/readme.md b/test/WebSites/RazorBuildWebSite/readme.md new file mode 100644 index 0000000000..292a669614 --- /dev/null +++ b/test/WebSites/RazorBuildWebSite/readme.md @@ -0,0 +1,4 @@ +RazorBuildWebSite +=== + +This web site tests how the Razor view engine interacts with pre-built Razor assemblies. \ No newline at end of file