From 238c89d122af26a571c6198cc438a289d18c32d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Thu, 18 Jun 2026 09:44:15 +0200 Subject: [PATCH 1/5] migrate GenerateStaticWebAssetsDevelopmentManifest --- ...erateStaticWebAssetsDevelopmentManifest.cs | 43 +++--- ...tsDevelopmentManifestMultiThreadingTest.cs | 144 ++++++++++++++++++ 2 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest.cs diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs index 00d4f39c69e1..05cb458246c7 100644 --- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs +++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs @@ -13,10 +13,14 @@ namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; // The manifest needs to always be case sensitive, since we don't know what the final runtime environment // will be. The runtime is responsible for merging the tree nodes in the manifest when the underlying OS // is case insensitive. -public class GenerateStaticWebAssetsDevelopmentManifest : Task +[MSBuildMultiThreadableTask] +public class GenerateStaticWebAssetsDevelopmentManifest : Task, IMultiThreadableTask { private static readonly char[] _separator = ['/']; + /// + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public string Source { get; set; } @@ -34,10 +38,14 @@ public class GenerateStaticWebAssetsDevelopmentManifest : Task public override bool Execute() { - if (File.Exists(ManifestPath) && File.GetLastWriteTimeUtc(ManifestPath) > File.GetLastWriteTimeUtc(CacheFilePath)) + if (!string.IsNullOrEmpty(ManifestPath)) { - Log.LogMessage(MessageImportance.Low, "Skipping manifest generation because manifest file '{0}' is up to date.", ManifestPath); - return true; + AbsolutePath manifestPath = TaskEnvironment.GetAbsolutePath(ManifestPath); + if (File.Exists(manifestPath) && File.GetLastWriteTimeUtc(manifestPath) > File.GetLastWriteTimeUtc(TaskEnvironment.GetAbsolutePath(CacheFilePath))) + { + Log.LogMessage(MessageImportance.Low, "Skipping manifest generation because manifest file '{0}' is up to date.", ManifestPath); + return true; + } } try @@ -49,10 +57,10 @@ public override bool Execute() } var manifest = ComputeDevelopmentManifest( - StaticWebAsset.FromTaskItemGroup(Assets), + StaticWebAsset.FromTaskItemGroup(Assets, TaskEnvironment), DiscoveryPatterns.Select(StaticWebAssetsDiscoveryPattern.FromTaskItem)); - PersistManifest(manifest); + PersistManifest(manifest, TaskEnvironment.GetAbsolutePath(ManifestPath)); } catch (Exception ex) { @@ -93,7 +101,7 @@ public StaticWebAssetsDevelopmentManifest ComputeDevelopmentManifest( return 0; }); - var manifest = CreateManifest(assetsWithPathSegments, discoveryPatternsByBasePath); + var manifest = CreateManifest(assetsWithPathSegments, discoveryPatternsByBasePath, TaskEnvironment); return manifest; } @@ -125,7 +133,7 @@ private IEnumerable ComputeManifestAssets(IEnumerable(); var root = new StaticWebAssetNode() { }; @@ -187,7 +196,7 @@ private static StaticWebAssetsDevelopmentManifest CreateManifest( } var matchingAsset = new StaticWebAssetMatch { - SubPath = ResolveSubPath(asset), + SubPath = ResolveSubPath(asset, env), ContentRootIndex = index }; currentNode.Children ??= new Dictionary(StringComparer.Ordinal); @@ -306,9 +315,9 @@ private static StaticWebAssetsDevelopmentManifest CreateManifest( Root = root }; - static string ResolveSubPath(StaticWebAsset asset) + static string ResolveSubPath(StaticWebAsset asset, TaskEnvironment env) { - if (File.Exists(asset.Identity)) + if (!string.IsNullOrEmpty(asset.Identity) && File.Exists(env.GetAbsolutePath(asset.Identity))) { if (asset.Identity.StartsWith(asset.ContentRoot, OSPath.PathComparison)) { diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest.cs new file mode 100644 index 000000000000..3d56b2684256 --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest.cs @@ -0,0 +1,144 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Microsoft.Build.Framework; +using Moq; + +namespace Microsoft.NET.Sdk.StaticWebAssets.Tests; + +// Test parallelization is disabled assembly-wide via +// [assembly:CollectionBehavior(DisableTestParallelization = true)] in +// LegacyStaticWebAssetsV1IntegrationTest.cs, which already isolates the +// process-CWD mutation these tests perform. +public class GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest +{ + [Fact] + public void WritesManifestRelativeToTaskEnvironmentProjectDirectory_NotProcessCurrentDirectory() + { + var testRoot = Path.Combine(AppContext.BaseDirectory, nameof(GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest), Guid.NewGuid().ToString("N")); + var projectDir = Path.Combine(testRoot, "project"); + var spawnDir = Path.Combine(testRoot, "spawn"); + var relativeManifestPath = Path.Combine("obj", "staticwebassets.development.json"); + var relativeCacheFilePath = Path.Combine("obj", "staticwebassets.build.cache.json"); + Directory.CreateDirectory(Path.Combine(projectDir, "obj")); + Directory.CreateDirectory(Path.Combine(spawnDir, "obj")); + + var originalCurrentDirectory = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(spawnDir); + + var task = new GenerateStaticWebAssetsDevelopmentManifest + { + BuildEngine = new Mock().Object, + TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(projectDir), + Source = "CurrentProjectId", + Assets = [CreateAssetItem("css/site.css")], + DiscoveryPatterns = [], + ManifestPath = relativeManifestPath, + CacheFilePath = relativeCacheFilePath + }; + + task.Execute().Should().BeTrue(); + + var expectedPath = Path.Combine(projectDir, relativeManifestPath); + File.Exists(expectedPath).Should().BeTrue("the manifest should be written under the project dir, not the process CWD"); + + var incorrectPath = Path.Combine(spawnDir, relativeManifestPath); + File.Exists(incorrectPath).Should().BeFalse(); + } + finally + { + Directory.SetCurrentDirectory(originalCurrentDirectory); + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, recursive: true); + } + } + } + + [Fact] + public void ResolvesExistingManifestProbeRelativeToProjectDirectory_NotProcessCurrentDirectory() + { + // Verifies that the File.Exists/File.ReadAllBytes change-detection probe in PersistManifest (and + // the up-to-date check in Execute) is rooted against TaskEnvironment.ProjectDirectory rather than + // the process CWD. A decoy manifest is planted in the process CWD at the same relative path. If the + // task read that decoy as the "existing" manifest it would overwrite the decoy or skip writing the + // project-dir file. The correct behavior is to ignore the decoy entirely: create the manifest under + // the project dir and leave the decoy untouched. + var testRoot = Path.Combine(AppContext.BaseDirectory, nameof(GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest), Guid.NewGuid().ToString("N")); + var projectDir = Path.Combine(testRoot, "project"); + var spawnDir = Path.Combine(testRoot, "spawn"); + var relativeManifestPath = Path.Combine("obj", "staticwebassets.development.json"); + var relativeCacheFilePath = Path.Combine("obj", "staticwebassets.build.cache.json"); + Directory.CreateDirectory(Path.Combine(projectDir, "obj")); + Directory.CreateDirectory(Path.Combine(spawnDir, "obj")); + + const string decoyContents = "DECOY - must not be read or overwritten"; + var decoyPath = Path.Combine(spawnDir, relativeManifestPath); + File.WriteAllText(decoyPath, decoyContents); + + var originalCurrentDirectory = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(spawnDir); + + var task = new GenerateStaticWebAssetsDevelopmentManifest + { + BuildEngine = new Mock().Object, + TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(projectDir), + Source = "CurrentProjectId", + Assets = [CreateAssetItem("css/site.css")], + DiscoveryPatterns = [], + ManifestPath = relativeManifestPath, + CacheFilePath = relativeCacheFilePath + }; + + task.Execute().Should().BeTrue(); + + var expectedPath = Path.Combine(projectDir, relativeManifestPath); + File.Exists(expectedPath).Should().BeTrue("the existence probe must target the project dir, find nothing, and create the manifest there"); + + File.ReadAllText(decoyPath).Should().Be(decoyContents, "the decoy in the process CWD must be neither read nor overwritten"); + } + finally + { + Directory.SetCurrentDirectory(originalCurrentDirectory); + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, recursive: true); + } + } + } + + private static ITaskItem CreateAssetItem(string relativePath) + { + var asset = new StaticWebAsset + { + Identity = relativePath, + SourceId = "OtherPackage", + SourceType = StaticWebAsset.SourceTypes.Discovered, + ContentRoot = "wwwroot", + BasePath = "_content/Base", + RelativePath = relativePath, + AssetKind = StaticWebAsset.AssetKinds.All, + AssetMode = StaticWebAsset.AssetModes.All, + AssetRole = StaticWebAsset.AssetRoles.Primary, + RelatedAsset = "", + AssetTraitName = "", + AssetTraitValue = "", + CopyToOutputDirectory = "", + CopyToPublishDirectory = "", + OriginalItemSpec = relativePath, + Integrity = "integrity", + Fingerprint = "fingerprint", + LastWriteTime = DateTime.UtcNow, + FileLength = 10, + }; + + return asset.ToTaskItem(); + } +} From 4d45bbeb8b5a217cec3022f0a61c6f6f56a404cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Fri, 19 Jun 2026 15:00:44 +0200 Subject: [PATCH 2/5] addressed comments --- ...erateStaticWebAssetsDevelopmentManifest.cs | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs index 05cb458246c7..815213aa79fc 100644 --- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs +++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs @@ -38,14 +38,17 @@ public class GenerateStaticWebAssetsDevelopmentManifest : Task, IMultiThreadable public override bool Execute() { - if (!string.IsNullOrEmpty(ManifestPath)) + if (string.IsNullOrWhiteSpace(ManifestPath)) { - AbsolutePath manifestPath = TaskEnvironment.GetAbsolutePath(ManifestPath); - if (File.Exists(manifestPath) && File.GetLastWriteTimeUtc(manifestPath) > File.GetLastWriteTimeUtc(TaskEnvironment.GetAbsolutePath(CacheFilePath))) - { - Log.LogMessage(MessageImportance.Low, "Skipping manifest generation because manifest file '{0}' is up to date.", ManifestPath); - return true; - } + Log.LogError("The 'ManifestPath' parameter is required and cannot be empty or whitespace."); + return false; + } + + AbsolutePath manifestPath = TaskEnvironment.GetAbsolutePath(ManifestPath); + if (File.Exists(manifestPath) && File.GetLastWriteTimeUtc(manifestPath) > File.GetLastWriteTimeUtc(TaskEnvironment.GetAbsolutePath(CacheFilePath))) + { + Log.LogMessage(MessageImportance.Low, "Skipping manifest generation because manifest file '{0}' is up to date.", ManifestPath); + return true; } try @@ -60,7 +63,7 @@ public override bool Execute() StaticWebAsset.FromTaskItemGroup(Assets, TaskEnvironment), DiscoveryPatterns.Select(StaticWebAssetsDiscoveryPattern.FromTaskItem)); - PersistManifest(manifest, TaskEnvironment.GetAbsolutePath(ManifestPath)); + PersistManifest(manifest, manifestPath); } catch (Exception ex) { @@ -101,7 +104,7 @@ public StaticWebAssetsDevelopmentManifest ComputeDevelopmentManifest( return 0; }); - var manifest = CreateManifest(assetsWithPathSegments, discoveryPatternsByBasePath, TaskEnvironment); + var manifest = CreateManifest(assetsWithPathSegments, discoveryPatternsByBasePath); return manifest; } @@ -176,8 +179,7 @@ private void PersistManifest(StaticWebAssetsDevelopmentManifest manifest, Absolu private static StaticWebAssetsDevelopmentManifest CreateManifest( SegmentsAssetPair[] assetsWithPathSegments, - (string[], StaticWebAssetsDiscoveryPattern[] values)[] discoveryPatternsByBasePath, - TaskEnvironment env) + (string[], StaticWebAssetsDiscoveryPattern[] values)[] discoveryPatternsByBasePath) { var contentRootIndex = new Dictionary(); var root = new StaticWebAssetNode() { }; @@ -196,7 +198,7 @@ private static StaticWebAssetsDevelopmentManifest CreateManifest( } var matchingAsset = new StaticWebAssetMatch { - SubPath = ResolveSubPath(asset, env), + SubPath = ResolveSubPath(asset), ContentRootIndex = index }; currentNode.Children ??= new Dictionary(StringComparer.Ordinal); @@ -315,9 +317,9 @@ private static StaticWebAssetsDevelopmentManifest CreateManifest( Root = root }; - static string ResolveSubPath(StaticWebAsset asset, TaskEnvironment env) + static string ResolveSubPath(StaticWebAsset asset) { - if (!string.IsNullOrEmpty(asset.Identity) && File.Exists(env.GetAbsolutePath(asset.Identity))) + if (File.Exists(asset.Identity)) { if (asset.Identity.StartsWith(asset.ContentRoot, OSPath.PathComparison)) { From de9c402c69e628a5ea641edf7a494f4fbb6933df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Mon, 22 Jun 2026 08:35:28 +0200 Subject: [PATCH 3/5] addressed comments --- .../Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs index 815213aa79fc..c8483fca3427 100644 --- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs +++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs @@ -38,12 +38,6 @@ public class GenerateStaticWebAssetsDevelopmentManifest : Task, IMultiThreadable public override bool Execute() { - if (string.IsNullOrWhiteSpace(ManifestPath)) - { - Log.LogError("The 'ManifestPath' parameter is required and cannot be empty or whitespace."); - return false; - } - AbsolutePath manifestPath = TaskEnvironment.GetAbsolutePath(ManifestPath); if (File.Exists(manifestPath) && File.GetLastWriteTimeUtc(manifestPath) > File.GetLastWriteTimeUtc(TaskEnvironment.GetAbsolutePath(CacheFilePath))) { From 4270c8b66f1f1bbbe56612fa5d6f0f0a189cc44a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Thu, 25 Jun 2026 10:29:33 +0200 Subject: [PATCH 4/5] Convert new dev-manifest multithreading test to MSTest Microsoft.NET.Sdk.StaticWebAssets.Tests was migrated to MSTest.Sdk on main. The new test file still used xUnit [Fact], so after merging main it failed to compile (CS0246: Fact/FactAttribute not found) on every CI Build leg. Convert it to [TestClass]/[TestMethod] with [DoNotParallelize] to match the migrated project. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...bAssetsDevelopmentManifestMultiThreadingTest.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest.cs index 3d56b2684256..a5656adda840 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest.cs @@ -5,17 +5,19 @@ using Microsoft.AspNetCore.StaticWebAssets.Tasks; using Microsoft.Build.Framework; +using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; namespace Microsoft.NET.Sdk.StaticWebAssets.Tests; -// Test parallelization is disabled assembly-wide via -// [assembly:CollectionBehavior(DisableTestParallelization = true)] in -// LegacyStaticWebAssetsV1IntegrationTest.cs, which already isolates the -// process-CWD mutation these tests perform. +// These tests mutate the process current directory, so they must run sequentially. +// The project disables parallelization (MSTestParallelizeScope=None) and +// [DoNotParallelize] enforces that for this class. +[DoNotParallelize] +[TestClass] public class GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest { - [Fact] + [TestMethod] public void WritesManifestRelativeToTaskEnvironmentProjectDirectory_NotProcessCurrentDirectory() { var testRoot = Path.Combine(AppContext.BaseDirectory, nameof(GenerateStaticWebAssetsDevelopmentManifestMultiThreadingTest), Guid.NewGuid().ToString("N")); @@ -60,7 +62,7 @@ public void WritesManifestRelativeToTaskEnvironmentProjectDirectory_NotProcessCu } } - [Fact] + [TestMethod] public void ResolvesExistingManifestProbeRelativeToProjectDirectory_NotProcessCurrentDirectory() { // Verifies that the File.Exists/File.ReadAllBytes change-detection probe in PersistManifest (and From bf0e51d13d877f0e0f9e511dfa84f93fd540c385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Prokop?= Date: Thu, 25 Jun 2026 15:58:17 +0200 Subject: [PATCH 5/5] Set required ManifestPath/CacheFilePath in SkipsManifestGenerationWhen_ThereAreNoAssetsNorDiscoveryPatterns The multithreaded migration changed Execute() to call TaskEnvironment.GetAbsolutePath(ManifestPath) unconditionally. The test left the [Required] ManifestPath/CacheFilePath unset, so GetAbsolutePath(null) threw ArgumentNullException where File.Exists(null) previously returned false. Provide non-null paths so the no-assets early-out is exercised. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GenerateStaticWebAssetsDevelopmentManifestTest.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestTest.cs index e16a9d7c3203..a6e711c76888 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsDevelopmentManifestTest.cs @@ -28,11 +28,14 @@ public void SkipsManifestGenerationWhen_ThereAreNoAssetsNorDiscoveryPatterns() buildEngine.Setup(e => e.LogMessageEvent(It.IsAny())) .Callback(args => messages.Add(args.Message)); + var manifestDirectory = Path.Combine(AppContext.BaseDirectory, nameof(SkipsManifestGenerationWhen_ThereAreNoAssetsNorDiscoveryPatterns), Guid.NewGuid().ToString("N")); var task = new GenerateStaticWebAssetsDevelopmentManifest() { BuildEngine = buildEngine.Object, Assets = Array.Empty(), - DiscoveryPatterns = Array.Empty() + DiscoveryPatterns = Array.Empty(), + ManifestPath = Path.Combine(manifestDirectory, "staticwebassets.development.json"), + CacheFilePath = Path.Combine(manifestDirectory, "staticwebassets.build.cache.json") }; // Act