diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs
index 00d4f39c69e1..c8483fca3427 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,7 +38,8 @@ public class GenerateStaticWebAssetsDevelopmentManifest : Task
public override bool Execute()
{
- if (File.Exists(ManifestPath) && File.GetLastWriteTimeUtc(ManifestPath) > File.GetLastWriteTimeUtc(CacheFilePath))
+ 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;
@@ -49,10 +54,10 @@ public override bool Execute()
}
var manifest = ComputeDevelopmentManifest(
- StaticWebAsset.FromTaskItemGroup(Assets),
+ StaticWebAsset.FromTaskItemGroup(Assets, TaskEnvironment),
DiscoveryPatterns.Select(StaticWebAssetsDiscoveryPattern.FromTaskItem));
- PersistManifest(manifest);
+ PersistManifest(manifest, manifestPath);
}
catch (Exception ex)
{
@@ -125,7 +130,7 @@ private IEnumerable ComputeManifestAssets(IEnumerable().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);
+ }
+ }
+ }
+
+ [TestMethod]
+ 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();
+ }
+}
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