diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/src/GenerateStaticWebAssetsManifest.cs b/src/Razor/Microsoft.NET.Sdk.Razor/src/GenerateStaticWebAssetsManifest.cs new file mode 100644 index 0000000000..7c6205d327 --- /dev/null +++ b/src/Razor/Microsoft.NET.Sdk.Razor/src/GenerateStaticWebAssetsManifest.cs @@ -0,0 +1,184 @@ +// 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.IO; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.AspNetCore.Razor.Tasks +{ + public class GenerateStaticWebAssetsManifest : Task + { + private const string ContentRoot = "ContentRoot"; + private const string BasePath = "BasePath"; + + [Required] + public string TargetManifestPath { get; set; } + + [Required] + public ITaskItem[] ContentRootDefinitions { get; set; } + + public override bool Execute() + { + if (!ValidateArguments()) + { + return false; + } + + return ExecuteCore(); + } + + private bool ExecuteCore() + { + var document = new XDocument(new XDeclaration("1.0", "utf-8", "yes")); + var root = new XElement( + "StaticWebAssets", + new XAttribute("Version", "1.0"), + CreateNodes()); + + document.Add(root); + + var settings = new XmlWriterSettings + { + Encoding = Encoding.UTF8, + CloseOutput = true, + OmitXmlDeclaration = true, + Indent = true, + NewLineOnAttributes = false, + Async = true + }; + + using (var xmlWriter = GetXmlWriter(settings)) + { + document.WriteTo(xmlWriter); + } + + return !Log.HasLoggedErrors; + } + + private IEnumerable CreateNodes() + { + var nodes = new List(); + for (var i = 0; i < ContentRootDefinitions.Length; i++) + { + var contentRootDefinition = ContentRootDefinitions[i]; + var basePath = contentRootDefinition.GetMetadata(BasePath); + var contentRoot = contentRootDefinition.GetMetadata(ContentRoot); + + // basePath is meant to be a prefix for the files under contentRoot. MSbuild + // normalizes '\' according to the OS, but this is going to be part of the url + // so it needs to always be '/'. + var normalizedBasePath = basePath.Replace("\\", "/"); + + // At this point we already know that there are no elements with different base paths and same content roots + // or viceversa. Here we simply skip additional items that have the same base path and same content root. + if (!nodes.Exists(e => e.Attribute(BasePath).Value.Equals(normalizedBasePath, StringComparison.OrdinalIgnoreCase))) + { + nodes.Add(new XElement("ContentRoot", + new XAttribute("BasePath", normalizedBasePath), + new XAttribute("Path", contentRoot))); + } + } + + return nodes; + } + + private XmlWriter GetXmlWriter(XmlWriterSettings settings) + { + var fileStream = new FileStream(TargetManifestPath, FileMode.Create); + return XmlWriter.Create(fileStream, settings); + } + + private bool ValidateArguments() + { + for (var i = 0; i < ContentRootDefinitions.Length; i++) + { + var contentRootDefinition = ContentRootDefinitions[i]; + if (!EnsureRequiredMetadata(contentRootDefinition, BasePath) || + !EnsureRequiredMetadata(contentRootDefinition, ContentRoot)) + { + return false; + } + } + + // We want to validate that there are no different item groups that share either the same base path + // but different content roots or that share the same content root but different base paths. + // We pass in all the static web assets that we discovered to this task without making any distinction for + // duplicates, so here we skip elements for which we are already tracking an element with the same + // content root path and same base path. + + // Case-sensitivity depends on the underlying OS so we are not going to do anything to enforce it here. + // Any two items that match base path and content root in a case-insensitive way won't produce an error. + // Any other two items will produce an error even if there is only a casing difference between either the + // base paths or the content roots. + var basePaths = new Dictionary(StringComparer.OrdinalIgnoreCase); + var contentRootPaths = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < ContentRootDefinitions.Length; i++) + { + var contentRootDefinition = ContentRootDefinitions[i]; + var basePath = contentRootDefinition.GetMetadata(BasePath); + var contentRoot = contentRootDefinition.GetMetadata(ContentRoot); + + if (basePaths.TryGetValue(basePath, out var existingBasePath)) + { + var existingBasePathContentRoot = existingBasePath.GetMetadata(ContentRoot); + if (!string.Equals(contentRoot, existingBasePathContentRoot, StringComparison.OrdinalIgnoreCase)) + { + // Case: + // Item1: /_content/Library, /package/aspnetContent1 + // Item2: /_content/Library, /package/aspnetContent2 + Log.LogError($"Duplicate base paths '{basePath}' for content root paths '{contentRoot}' and '{existingBasePathContentRoot}'. " + + $"('{contentRootDefinition.ItemSpec}', '{existingBasePath.ItemSpec}')"); + return false; + } + // It was a duplicate, so we skip it. + // Case: + // Item1: /_content/Library, /package/aspnetContent + // Item2: /_content/Library, /package/aspnetContent + } + else + { + if (contentRootPaths.TryGetValue(contentRoot, out var existingContentRoot)) + { + // Case: + // Item1: /_content/Library1, /package/aspnetContent + // Item2: /_content/Library2, /package/aspnetContent + Log.LogError($"Duplicate content root paths '{contentRoot}' for base paths '{basePath}' and '{existingContentRoot.GetMetadata(BasePath)}' " + + $"('{contentRootDefinition.ItemSpec}', '{existingContentRoot.ItemSpec}')"); + return false; + } + } + + if (!basePaths.ContainsKey(basePath)) + { + basePaths.Add(basePath, contentRootDefinition); + } + + if (!contentRootPaths.ContainsKey(contentRoot)) + { + contentRootPaths.Add(contentRoot, contentRootDefinition); + } + } + + return true; + } + + private bool EnsureRequiredMetadata(ITaskItem item, string metadataName) + { + var value = item.GetMetadata(metadataName); + if (string.IsNullOrEmpty(value)) + { + Log.LogError($"Missing required metadata '{metadataName}' for '{item.ItemSpec}'."); + return false; + } + + return true; + } + } +} \ No newline at end of file diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Microsoft.NET.Sdk.Razor.StaticWebAssets.targets b/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Microsoft.NET.Sdk.Razor.StaticWebAssets.targets new file mode 100644 index 0000000000..0c273df77b --- /dev/null +++ b/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Microsoft.NET.Sdk.Razor.StaticWebAssets.targets @@ -0,0 +1,172 @@ + + + + + + + + + + + ResolveStaticWebAssetsInputs; + _CreateStaticWebAssetsInputsCacheFile + + + + GenerateStaticWebAssetsManifest; + $(AssignTargetPathsDependsOn) + + + + + + <_GeneratedStaticWebAssetsInputsCacheFile>$(IntermediateOutputPath)$(TargetName).StaticWebAssets.cache + <_GeneratedStaticWebAssetsDevelopmentManifest>$(IntermediateOutputPath)$(TargetName).StaticWebAssets.xml + + + + + + + <_ExternalStaticWebAsset + Include="%(StaticWebAsset.Identity)" + Condition="'%(SourceType)' != ''"> + %(StaticWebAsset.BasePath) + %(StaticWebAsset.ContentRoot) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_SafeBasePath>$(PackageId.Replace('.','')) + + + + + + + + + + + + + $(MSBuildProjectDirectory)\wwwroot\ + + _content\$(_SafeBasePath)\ + + %(RecursiveDir)%(FileName)%(Extension) + + + + + + + + + + + \ No newline at end of file diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.props b/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.props index 5125968f83..c1a728ae5b 100644 --- a/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.props +++ b/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.props @@ -74,6 +74,10 @@ Copyright (c) .NET Foundation. All rights reserved. + + true + + diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.targets b/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.targets index f0982a8737..98920017b4 100644 --- a/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.targets +++ b/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.targets @@ -338,6 +338,8 @@ Copyright (c) .NET Foundation. All rights reserved. + + @@ -480,6 +482,7 @@ Copyright (c) .NET Foundation. All rights reserved. '$(EnableDefaultRazorGenerateItems)'=='true'"> + diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/test/GenerateAspNetCoreStaticAssetsManifestTest.cs b/src/Razor/Microsoft.NET.Sdk.Razor/test/GenerateAspNetCoreStaticAssetsManifestTest.cs new file mode 100644 index 0000000000..8bb80e5c2c --- /dev/null +++ b/src/Razor/Microsoft.NET.Sdk.Razor/test/GenerateAspNetCoreStaticAssetsManifestTest.cs @@ -0,0 +1,296 @@ +// 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.Collections.Generic; +using System.IO; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Tasks +{ + public class GenerateStaticWebAssetsManifestTest + { + [Fact] + public void ReturnsError_WhenBasePathIsMissing() + { + // Arrange + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var task = new GenerateStaticWebAssetsManifest + { + BuildEngine = buildEngine.Object, + ContentRootDefinitions = new TaskItem[] + { + CreateItem(Path.Combine("wwwroot", "sample.js"), new Dictionary + { + ["ContentRoot"] = "/" + }) + } + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.False(result); + var message = Assert.Single(errorMessages); + Assert.Equal($"Missing required metadata 'BasePath' for '{Path.Combine("wwwroot", "sample.js")}'.", message); + } + + [Fact] + public void ReturnsError_WhenContentRootIsMissing() + { + // Arrange + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var task = new GenerateStaticWebAssetsManifest + { + BuildEngine = buildEngine.Object, + ContentRootDefinitions = new TaskItem[] + { + CreateItem(Path.Combine("wwwroot","sample.js"), new Dictionary + { + ["BasePath"] = "MyLibrary" + }) + } + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.False(result); + var message = Assert.Single(errorMessages); + Assert.Equal($"Missing required metadata 'ContentRoot' for '{Path.Combine("wwwroot", "sample.js")}'.", message); + } + + [Fact] + public void ReturnsError_ForDuplicateBasePaths() + { + // Arrange + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var task = new GenerateStaticWebAssetsManifest + { + BuildEngine = buildEngine.Object, + ContentRootDefinitions = new TaskItem[] + { + CreateItem(Path.Combine("wwwroot","sample.js"), new Dictionary + { + ["BasePath"] = "MyLibrary", + ["ContentRoot"] = Path.Combine("nuget","MyLibrary") + }), + CreateItem(Path.Combine("wwwroot", "otherLib.js"), new Dictionary + { + ["BasePath"] = "MyLibrary", + ["ContentRoot"] = Path.Combine("nuget", "MyOtherLibrary") + }) + } + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.False(result); + var message = Assert.Single(errorMessages); + Assert.Equal( + $"Duplicate base paths 'MyLibrary' for content root paths '{Path.Combine("nuget", "MyOtherLibrary")}' and '{Path.Combine("nuget", "MyLibrary")}'. " + + $"('{Path.Combine("wwwroot", "otherLib.js")}', '{Path.Combine("wwwroot", "sample.js")}')", + message); + } + + [Fact] + public void ReturnsError_ForDuplicateContentRoots() + { + // Arrange + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var task = new GenerateStaticWebAssetsManifest + { + BuildEngine = buildEngine.Object, + ContentRootDefinitions = new TaskItem[] + { + CreateItem(Path.Combine("wwwroot","sample.js"), new Dictionary + { + ["BasePath"] = "MyLibrary", + ["ContentRoot"] = Path.Combine(".", "MyLibrary") + }), + CreateItem(Path.Combine("wwwroot", "otherLib.js"), new Dictionary + { + ["BasePath"] = "MyOtherLibrary", + ["ContentRoot"] = Path.Combine(".", "MyLibrary") + }) + } + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.False(result); + var message = Assert.Single(errorMessages); + Assert.Equal( + $"Duplicate content root paths '{Path.Combine(".", "MyLibrary")}' for base paths 'MyOtherLibrary' and 'MyLibrary' " + + $"('{Path.Combine("wwwroot", "otherLib.js")}', '{Path.Combine("wwwroot", "sample.js")}')", + message); + } + + [Fact] + public void Generates_EmptyManifest_WhenNoItems_Passed() + { + // Arrange + var file = Path.GetTempFileName(); + var expectedDocument = @""; + + try + { + var buildEngine = new Mock(); + + var task = new GenerateStaticWebAssetsManifest + { + BuildEngine = buildEngine.Object, + ContentRootDefinitions = new TaskItem[] { }, + TargetManifestPath = file + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.True(result); + var document = File.ReadAllText(file); + Assert.Equal(expectedDocument, document); + } + finally + { + if (File.Exists(file)) + { + File.Delete(file); + } + } + } + + [Fact] + public void Generates_Manifest_WhenContentRootsAvailable() + { + // Arrange + var file = Path.GetTempFileName(); + var expectedDocument = $@" + +"; + + try + { + var buildEngine = new Mock(); + + var task = new GenerateStaticWebAssetsManifest + { + BuildEngine = buildEngine.Object, + ContentRootDefinitions = new TaskItem[] + { + CreateItem(Path.Combine("wwwroot","sample.js"), new Dictionary + { + ["BasePath"] = "MyLibrary", + ["ContentRoot"] = Path.Combine(".", "nuget", "MyLibrary", "razorContent") + }), + }, + TargetManifestPath = file + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.True(result); + var document = File.ReadAllText(file); + Assert.Equal(expectedDocument, document); + } + finally + { + if (File.Exists(file)) + { + File.Delete(file); + } + } + } + + [Fact] + public void SkipsAdditionalElements_WithSameBasePathAndSameContentRoot() + { + // Arrange + var file = Path.GetTempFileName(); + var expectedDocument = $@" + +"; + + try + { + var buildEngine = new Mock(); + + var task = new GenerateStaticWebAssetsManifest + { + BuildEngine = buildEngine.Object, + ContentRootDefinitions = new TaskItem[] + { + CreateItem(Path.Combine("wwwroot","sample.js"), new Dictionary + { + // Base path needs to be normalized to '/' as it goes in the url + ["BasePath"] = "Base\\MyLibrary", + ["ContentRoot"] = Path.Combine(".", "nuget", "MyLibrary", "razorContent") + }), + // Comparisons are case insensitive + CreateItem(Path.Combine("wwwroot, site.css"), new Dictionary + { + ["BasePath"] = "Base\\MyLIBRARY", + ["ContentRoot"] = Path.Combine(".", "nuget", "MyLIBRARY", "razorContent") + }), + }, + TargetManifestPath = file + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.True(result); + var document = File.ReadAllText(file); + Assert.Equal(expectedDocument, document); + } + finally + { + if (File.Exists(file)) + { + File.Delete(file); + } + } + } + + private static TaskItem CreateItem( + string spec, + IDictionary metadata) + { + var result = new TaskItem(spec); + foreach (var (key, value) in metadata) + { + result.SetMetadata(key, value); + } + + return result; + } + } +} diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/Assert.cs b/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/Assert.cs index d43d0e6d2c..bdbfa9c25d 100644 --- a/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/Assert.cs +++ b/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/Assert.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; @@ -344,6 +345,58 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests } } + public static Stream ContainsEmbeddedResource(string assemblyPath, string resourceName) + { + var stream = ExtractEmbeddedResource(assemblyPath, resourceName); + Assert.NotNull(stream); + + return stream; + } + + public static void DoesNotContainEmbeddedResource(string assemblyPath, string resourceName) + { + var stream = ExtractEmbeddedResource(assemblyPath, resourceName); + Assert.Null(stream); + } + + private static Stream ExtractEmbeddedResource(string path, string expectedResourceName) + { + using (var peStream = File.OpenRead(path)) + { + using (var peReader = new PEReader(peStream)) + { + var mdReader = peReader.GetMetadataReader(); + + foreach (var resourceHandle in mdReader.ManifestResources) + { + var resource = mdReader.GetManifestResource(resourceHandle); + + if (!resource.Implementation.IsNil) + { + continue; // resource is not embedded. + } + + var resourceName = mdReader.GetString(resource.Name); + if (!string.Equals(expectedResourceName, resourceName)) + { + continue; + } + + // We are not taking resource.Offset into account here. + // We currently only have the casuistic that we are embedding a single resource. + // If that changes we'll have to change this test code, but as its hard we won't do it for now. + var resourcesSection = peReader.GetSectionData(peReader.PEHeaders.CorHeader.ResourcesDirectory.RelativeVirtualAddress); + var resourcesReader = resourcesSection.GetReader(); + var resourceSizeInBytes = resourcesReader.ReadInt32(); + var resourceBytes = resourcesReader.ReadBytes(resourceSizeInBytes); + return new MemoryStream(resourceBytes, writable: false); + } + } + } + + return null; + } + public static void NuspecContains(MSBuildResult result, string nuspecPath, string expected) { if (result == null) diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/MSBuildIntegrationTestBase.cs b/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/MSBuildIntegrationTestBase.cs index dd6c6e71b2..b17592bb80 100644 --- a/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/MSBuildIntegrationTestBase.cs +++ b/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/MSBuildIntegrationTestBase.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -13,6 +15,9 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests { public abstract class MSBuildIntegrationTestBase { + internal static readonly string LocalNugetPackagesCacheTempPath = + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()) + Path.DirectorySeparatorChar; + private static readonly AsyncLocal _project = new AsyncLocal(); private static readonly AsyncLocal _projectTfm = new AsyncLocal(); @@ -42,6 +47,10 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests set { _project.Value = value; } } + // Whether to use a local cache or not to prevent polluting the global cache + // with test packages. + public bool UseLocalPackageCache { get; set; } + protected string RazorIntermediateOutputPath => Path.Combine(IntermediateOutputPath, "Razor"); protected string RazorComponentIntermediateOutputPath => Path.Combine(IntermediateOutputPath, "RazorDeclaration"); @@ -64,18 +73,42 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests bool runRestoreBeforeBuildOrPublish = true) { var timeout = suppressTimeout ? (TimeSpan?)Timeout.InfiniteTimeSpan : null; + + // Additional restore sources for packages used in testing + var additionalRestoreSources = string.Join( + ',', + typeof(PackageTestProjectsFixture).Assembly.GetCustomAttributes() + .Where(a => a.Key == "Testing.AdditionalRestoreSources") + .Select(a => a.Value) + .ToArray()); + var buildArgumentList = new List { // Disable node-reuse. We don't want msbuild processes to stick around // once the test is completed. "/nr:false", + // Always generate a bin log for debugging purposes + "/bl", + // Let the test app know it is running as part of a test. "/p:RunningAsTest=true", $"/p:MicrosoftNETCoreApp30PackageVersion={BuildVariables.MicrosoftNETCoreApp30PackageVersion}", + + // Additional restore sources for projects that require built packages + $"/p:RuntimeAdditionalRestoreSources={additionalRestoreSources}", }; + if (UseLocalPackageCache) + { + if (!Directory.Exists(LocalNugetPackagesCacheTempPath)) + { + // The local cache folder needs to exist so that nuget + Directory.CreateDirectory(LocalNugetPackagesCacheTempPath); + } + } + if (!suppressBuildServer) { buildArgumentList.Add($@"/p:_RazorBuildServerPipeName=""{buildServerPipeName ?? BuildServer.PipeName}"""); @@ -110,7 +143,8 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests Project, buildArguments, timeout, - msBuildProcessKind); + msBuildProcessKind, + UseLocalPackageCache ? LocalNugetPackagesCacheTempPath : null); } internal void AddProjectFileContent(string content) diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/MSBuildProcessManager.cs b/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/MSBuildProcessManager.cs index 0d598ce741..de81c1f049 100644 --- a/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/MSBuildProcessManager.cs +++ b/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/MSBuildProcessManager.cs @@ -3,6 +3,9 @@ using System; using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -16,7 +19,8 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests ProjectDirectory project, string arguments, TimeSpan? timeout = null, - MSBuildProcessKind msBuildProcessKind = MSBuildProcessKind.Dotnet) + MSBuildProcessKind msBuildProcessKind = MSBuildProcessKind.Dotnet, + string localPackageCache = null) { var processStartInfo = new ProcessStartInfo() { @@ -26,6 +30,11 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests RedirectStandardOutput = true, }; + if (localPackageCache != null) + { + processStartInfo.Environment.Add("NUGET_PACKAGES", localPackageCache); + } + if (msBuildProcessKind == MSBuildProcessKind.Desktop) { if (string.IsNullOrEmpty(BuildVariables.MSBuildPath)) diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/StaticWebAssetsIntegrationTest.cs b/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/StaticWebAssetsIntegrationTest.cs new file mode 100644 index 0000000000..bb0d250f97 --- /dev/null +++ b/src/Razor/Microsoft.NET.Sdk.Razor/test/IntegrationTests/StaticWebAssetsIntegrationTest.cs @@ -0,0 +1,261 @@ +// 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.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.CommandLineUtils; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests +{ + public class StaticWebAssetsIntegrationTest : MSBuildIntegrationTestBase, IClassFixture, IClassFixture, IAsyncLifetime + { + public StaticWebAssetsIntegrationTest( + BuildServerTestFixture buildServer, + PackageTestProjectsFixture packageTestProjects, + ITestOutputHelper output) + : base(buildServer) + { + UseLocalPackageCache = true; + PackageTestProjects = packageTestProjects; + Output = output; + } + + public PackageTestProjectsFixture PackageTestProjects { get; private set; } + + public ITestOutputHelper Output { get; private set; } + + [Fact] + [InitializeTestProject("AppWithPackageAndP2PReference")] + public async Task Build_GeneratesStaticWebAssetsManifest_Success_CreatesManifest() + { + var result = await DotnetMSBuild("Build", "/restore"); + + var expectedManifest = GetExpectedManifest(); + + Assert.BuildPassed(result); + + // GenerateStaticWebAssetsManifest should generate the manifest and the cache. + Assert.FileExists(result, IntermediateOutputPath, "AppWithPackageAndP2PReference.StaticWebAssets.xml"); + Assert.FileExists(result, IntermediateOutputPath, "AppWithPackageAndP2PReference.StaticWebAssets.cache"); + + var path = Assert.FileExists(result, OutputPath, "AppWithPackageAndP2PReference.dll"); + var assembly = Assert.ContainsEmbeddedResource(path, "Microsoft.AspNetCore.StaticWebAssets.xml"); + using (var reader = new StreamReader(assembly)) + { + var data = await reader.ReadToEndAsync(); + Assert.Equal(expectedManifest, data); + } + } + + [Fact] + [InitializeTestProject("SimpleMvc")] + public async Task Build_DoesNotEmbedManifestWhen_NoStaticResourcesAvailable() + { + var result = await DotnetMSBuild("Build", "/restore"); + + Assert.BuildPassed(result); + + // GenerateStaticWebAssetsManifest should generate the manifest and the cache. + Assert.FileExists(result, IntermediateOutputPath, "SimpleMvc.StaticWebAssets.xml"); + Assert.FileExists(result, IntermediateOutputPath, "SimpleMvc.StaticWebAssets.cache"); + + var path = Assert.FileExists(result, OutputPath, "SimpleMvc.dll"); + Assert.DoesNotContainEmbeddedResource(path, "SimpleMvc.StaticWebAssets.xml"); + } + + [Fact] + [InitializeTestProject("AppWithPackageAndP2PReference")] + public async Task Clean_Success_RemovesManifestAndCache() + { + var result = await DotnetMSBuild("Build", "/restore"); + + Assert.BuildPassed(result); + + // GenerateStaticWebAssetsManifest should generate the manifest and the cache. + Assert.FileExists(result, IntermediateOutputPath, "AppWithPackageAndP2PReference.StaticWebAssets.xml"); + Assert.FileExists(result, IntermediateOutputPath, "AppWithPackageAndP2PReference.StaticWebAssets.cache"); + + var cleanResult = await DotnetMSBuild("Clean"); + + Assert.BuildPassed(cleanResult); + + // Clean should delete the manifest and the cache. + Assert.FileDoesNotExist(result, IntermediateOutputPath, "AppWithPackageAndP2PReference.StaticWebAssets.cache"); + Assert.FileDoesNotExist(result, IntermediateOutputPath, "AppWithPackageAndP2PReference.StaticWebAssets.xml"); + } + + [Fact] + [InitializeTestProject("AppWithPackageAndP2PReference")] + public async Task Rebuild_Success_RecreatesManifestAndCache() + { + // Arrange + var result = await DotnetMSBuild("Build", "/restore"); + + var expectedManifest = GetExpectedManifest(); + + Assert.BuildPassed(result); + + // GenerateStaticWebAssetsManifest should generate the manifest and the cache. + Assert.FileExists(result, IntermediateOutputPath, "AppWithPackageAndP2PReference.StaticWebAssets.xml"); + Assert.FileExists(result, IntermediateOutputPath, "AppWithPackageAndP2PReference.StaticWebAssets.cache"); + + var directoryPath = Path.Combine(result.Project.DirectoryPath, IntermediateOutputPath); + var thumbPrints = new Dictionary(); + var thumbPrintFiles = new[] + { + Path.Combine(directoryPath, "AppWithPackageAndP2PReference.StaticWebAssets.xml"), + Path.Combine(directoryPath, "AppWithPackageAndP2PReference.StaticWebAssets.cache"), + }; + + foreach (var file in thumbPrintFiles) + { + var thumbprint = GetThumbPrint(file); + thumbPrints[file] = thumbprint; + } + + // Act + var rebuild = await DotnetMSBuild("Rebuild"); + + // Assert + Assert.BuildPassed(rebuild); + + foreach (var file in thumbPrintFiles) + { + var thumbprint = GetThumbPrint(file); + Assert.NotEqual(thumbPrints[file], thumbprint); + } + + var path = Assert.FileExists(result, OutputPath, "AppWithPackageAndP2PReference.dll"); + var assembly = Assert.ContainsEmbeddedResource(path, "Microsoft.AspNetCore.StaticWebAssets.xml"); + using (var reader = new StreamReader(assembly)) + { + var data = reader.ReadToEnd(); + Assert.Equal(expectedManifest, data); + } + } + + [Fact] + [InitializeTestProject("AppWithPackageAndP2PReference")] + public async Task GenerateStaticWebAssetsManifest_IncrementalBuild_ReusesManifest() + { + var result = await DotnetMSBuild("GenerateStaticWebAssetsManifest", "/restore"); + + Assert.BuildPassed(result); + + // GenerateStaticWebAssetsManifest should generate the manifest and the cache. + Assert.FileExists(result, IntermediateOutputPath, "AppWithPackageAndP2PReference.StaticWebAssets.xml"); + Assert.FileExists(result, IntermediateOutputPath, "AppWithPackageAndP2PReference.StaticWebAssets.cache"); + + var directoryPath = Path.Combine(result.Project.DirectoryPath, IntermediateOutputPath); + var thumbPrints = new Dictionary(); + var thumbPrintFiles = new[] + { + Path.Combine(directoryPath, "AppWithPackageAndP2PReference.StaticWebAssets.xml"), + Path.Combine(directoryPath, "AppWithPackageAndP2PReference.StaticWebAssets.cache"), + }; + + foreach (var file in thumbPrintFiles) + { + var thumbprint = GetThumbPrint(file); + thumbPrints[file] = thumbprint; + } + + // Act + var incremental = await DotnetMSBuild("GenerateStaticWebAssetsManifest"); + + // Assert + Assert.BuildPassed(incremental); + + foreach (var file in thumbPrintFiles) + { + var thumbprint = GetThumbPrint(file); + Assert.Equal(thumbPrints[file], thumbprint); + } + } + + public Task InitializeAsync() + { + return PackageTestProjects.PackAsync(Output); + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } + + private string GetExpectedManifest() + { + var restorePath = LocalNugetPackagesCacheTempPath; + var projects = new[] + { + Path.Combine(restorePath, "packagelibrarytransitivedependency", "1.0.0", "buildTransitive", "..", "razorContent") + Path.DirectorySeparatorChar, + Path.Combine(restorePath, "packagelibrarydirectdependency", "1.0.0", "build", "..", "razorContent") + Path.DirectorySeparatorChar + }; + + return $@" + + +"; + } + } + + public class PackageTestProjectsFixture + { + private bool _packed; + + internal async Task PackAsync(ITestOutputHelper output) + { + if (_packed) + { + return; + } + + var projectsToPack = GetProjectsToPack(); + + foreach (var project in projectsToPack) + { + output.WriteLine(project); + } + + foreach (var project in projectsToPack) + { + var psi = new ProcessStartInfo + { + FileName = DotNetMuxer.MuxerPathOrDefault(), +#if DEBUG + Arguments = "msbuild /t:Restore;Pack /p:Configuration=Debug", +#else + Arguments = "msbuild /t:Restore;Pack /p:Configuration=Release", +#endif + WorkingDirectory = project, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + var result = await MSBuildProcessManager.RunProcessCoreAsync( + psi, + TimeSpan.FromMinutes(2)); + + output.WriteLine(result.Output); + Assert.Equal(0, result.ExitCode); + } + + _packed = true; + } + + public static string[] GetProjectsToPack() + { + return typeof(PackageTestProjectsFixture).Assembly.GetCustomAttributes() + .Where(a => a.Key == "Testing.ProjectToPack") + .Select(a => a.Value) + .ToArray(); + } + } +} diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/test/Microsoft.NET.Sdk.Razor.Test.csproj b/src/Razor/Microsoft.NET.Sdk.Razor/test/Microsoft.NET.Sdk.Razor.Test.csproj index 3cc014e3e2..b3097334e7 100644 --- a/src/Razor/Microsoft.NET.Sdk.Razor/test/Microsoft.NET.Sdk.Razor.Test.csproj +++ b/src/Razor/Microsoft.NET.Sdk.Razor/test/Microsoft.NET.Sdk.Razor.Test.csproj @@ -21,11 +21,30 @@ + + + + <_Parameter1>Testing.ProjectToPack + <_Parameter2>$(MSBuildThisFileDirectory)..\testapps\PackageLibraryDirectDependency + + + + <_Parameter1>Testing.ProjectToPack + <_Parameter2>$(MSBuildThisFileDirectory)..\testapps\PackageLibraryTransitiveDependency + + + + <_Parameter1>Testing.AdditionalRestoreSources + <_Parameter2>$(MSBuildThisFileDirectory)..\testapps\TestPackageRestoreSource + + + + @@ -51,11 +70,7 @@ Inputs="$(MSBuildAllProjects)" Outputs="$(MSBuildLocationFileOutput)"> - + diff --git a/src/Razor/test/testassets/AppWithPackageAndP2PReference/AppWithPackageAndP2PReference.csproj b/src/Razor/test/testassets/AppWithPackageAndP2PReference/AppWithPackageAndP2PReference.csproj new file mode 100644 index 0000000000..4eb52fbfa3 --- /dev/null +++ b/src/Razor/test/testassets/AppWithPackageAndP2PReference/AppWithPackageAndP2PReference.csproj @@ -0,0 +1,43 @@ + + + + $(RazorSdkArtifactsDirectory)$(Configuration)\sdk-output\ + + + + netcoreapp3.0 + $(MSBuildThisFileDirectory)..\TestPackageRestoreSource\ + + $(RestoreSources); + $(RuntimeAdditionalRestoreSources) + + + + + + + + + + + + false + + + + + + + + + + + $(RepositoryRoot)artifacts\bin\Microsoft.AspNetCore.Razor.Test.MvcShim.ClassLib\$(Configuration)\netstandard2.0\ + + + + + + + + diff --git a/src/Razor/test/testassets/AppWithPackageAndP2PReference/Program.cs b/src/Razor/test/testassets/AppWithPackageAndP2PReference/Program.cs new file mode 100644 index 0000000000..dc3ac4e250 --- /dev/null +++ b/src/Razor/test/testassets/AppWithPackageAndP2PReference/Program.cs @@ -0,0 +1,13 @@ + +namespace AppWithP2PReference +{ + public class Program + { + public static void Main(string[] args) + { + // Just make sure we have a reference to the MvcShim + var t = typeof(Microsoft.AspNetCore.Mvc.IActionResult); + System.Console.WriteLine(t.FullName); + } + } +} diff --git a/src/Razor/test/testassets/PackageLibraryDirectDependency/Microsoft.AspNetCore.StaticWebAssets.cache b/src/Razor/test/testassets/PackageLibraryDirectDependency/Microsoft.AspNetCore.StaticWebAssets.cache new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Razor/test/testassets/PackageLibraryDirectDependency/PackageLibraryDirectDependency.csproj b/src/Razor/test/testassets/PackageLibraryDirectDependency/PackageLibraryDirectDependency.csproj new file mode 100644 index 0000000000..075a8e6852 --- /dev/null +++ b/src/Razor/test/testassets/PackageLibraryDirectDependency/PackageLibraryDirectDependency.csproj @@ -0,0 +1,46 @@ + + + + $(RazorSdkArtifactsDirectory)$(Configuration)\sdk-output\ + + + + true + + + + netcoreapp3.0 + © Microsoft + Razor Test + Microsoft + PackageLibraryDirectDependency Description + $(MSBuildThisFileDirectory)..\TestPackageRestoreSource + false + + + + + false + + + + + $(RepositoryRoot)artifacts\bin\Microsoft.AspNetCore.Razor.Test.MvcShim.ClassLib\$(Configuration)\netstandard2.0\ + + + + + + + + + + + + + + + + + + diff --git a/src/Razor/test/testassets/PackageLibraryDirectDependency/build/PackageLibraryDirectDependency.props b/src/Razor/test/testassets/PackageLibraryDirectDependency/build/PackageLibraryDirectDependency.props new file mode 100644 index 0000000000..eaa14f2c3a --- /dev/null +++ b/src/Razor/test/testassets/PackageLibraryDirectDependency/build/PackageLibraryDirectDependency.props @@ -0,0 +1,11 @@ + + + + Package + PackageLibraryDirectDependency + $([MSBuild]::EnsureTrailingSlash('$(MSBuildThisFileDirectory)..\razorContent')) + _content\PackageLibraryDirectDependency + %(RecursiveDir)%(FileName)%(Extension) + + + \ No newline at end of file diff --git a/src/Razor/test/testassets/PackageLibraryDirectDependency/wwwroot/css/site.css b/src/Razor/test/testassets/PackageLibraryDirectDependency/wwwroot/css/site.css new file mode 100644 index 0000000000..c9d72143d2 --- /dev/null +++ b/src/Razor/test/testassets/PackageLibraryDirectDependency/wwwroot/css/site.css @@ -0,0 +1 @@ +div.fluent { display: inline-block } \ No newline at end of file diff --git a/src/Razor/test/testassets/PackageLibraryDirectDependency/wwwroot/js/pkg-direct-dep.js b/src/Razor/test/testassets/PackageLibraryDirectDependency/wwwroot/js/pkg-direct-dep.js new file mode 100644 index 0000000000..0e3ece9c93 --- /dev/null +++ b/src/Razor/test/testassets/PackageLibraryDirectDependency/wwwroot/js/pkg-direct-dep.js @@ -0,0 +1,3 @@ +(function () { + document.getElementById('pkg-direct-dep').innerHTML = 'pkg-direct-dep'; +})() \ No newline at end of file diff --git a/src/Razor/test/testassets/PackageLibraryTransitiveDependency/PackageLibraryTransitiveDependency.csproj b/src/Razor/test/testassets/PackageLibraryTransitiveDependency/PackageLibraryTransitiveDependency.csproj new file mode 100644 index 0000000000..e4ac49a002 --- /dev/null +++ b/src/Razor/test/testassets/PackageLibraryTransitiveDependency/PackageLibraryTransitiveDependency.csproj @@ -0,0 +1,44 @@ + + + + $(RazorSdkArtifactsDirectory)$(Configuration)\sdk-output\ + + + + true + + + + netstandard2.0 + © Microsoft + Razor Test + Microsoft + PackageLibraryTransitiveDependency Description + $(MSBuildThisFileDirectory)..\TestPackageRestoreSource + false + + + + + false + + + + + $(RepositoryRoot)artifacts\bin\Microsoft.AspNetCore.Razor.Test.MvcShim.ClassLib\$(Configuration)\netstandard2.0\ + + + + + + + + + + + + + + + + diff --git a/src/Razor/test/testassets/PackageLibraryTransitiveDependency/build/PackageLibraryTransitiveDependency.props b/src/Razor/test/testassets/PackageLibraryTransitiveDependency/build/PackageLibraryTransitiveDependency.props new file mode 100644 index 0000000000..64cdf1337b --- /dev/null +++ b/src/Razor/test/testassets/PackageLibraryTransitiveDependency/build/PackageLibraryTransitiveDependency.props @@ -0,0 +1,11 @@ + + + + Package + PackageLibraryTransitiveDependency + $([MSBuild]::EnsureTrailingSlash('$(MSBuildThisFileDirectory)..\razorContent')) + _content\PackageLibraryTransitiveDependency + %(RecursiveDir)%(FileName)%(Extension) + + + \ No newline at end of file diff --git a/src/Razor/test/testassets/PackageLibraryTransitiveDependency/wwwroot/js/pkg-transitive-dep.js b/src/Razor/test/testassets/PackageLibraryTransitiveDependency/wwwroot/js/pkg-transitive-dep.js new file mode 100644 index 0000000000..93e15b6ce1 --- /dev/null +++ b/src/Razor/test/testassets/PackageLibraryTransitiveDependency/wwwroot/js/pkg-transitive-dep.js @@ -0,0 +1,3 @@ +(function () { + document.getElementById('pkg-transitive-dep').innerHTML = 'pkg-transitive-dep'; +})() \ No newline at end of file diff --git a/src/Razor/test/testassets/RestoreTestProjects/RestoreTestProjects.csproj b/src/Razor/test/testassets/RestoreTestProjects/RestoreTestProjects.csproj index 59d6a44c00..c5aa7665e8 100644 --- a/src/Razor/test/testassets/RestoreTestProjects/RestoreTestProjects.csproj +++ b/src/Razor/test/testassets/RestoreTestProjects/RestoreTestProjects.csproj @@ -4,6 +4,12 @@ + + + + + + @@ -14,12 +20,15 @@ - - - - + +