Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ['/'];

/// <inheritdoc/>
public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;

[Required]
public string Source { get; set; }

Expand All @@ -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);
Comment thread
OvesN marked this conversation as resolved.
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;
Expand All @@ -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)
{
Expand Down Expand Up @@ -125,7 +130,7 @@ private IEnumerable<SegmentsAssetPair> ComputeManifestAssets(IEnumerable<StaticW
}
}

private void PersistManifest(StaticWebAssetsDevelopmentManifest manifest)
private void PersistManifest(StaticWebAssetsDevelopmentManifest manifest, AbsolutePath manifestPath)
{
var data = JsonSerializer.SerializeToUtf8Bytes(manifest, StaticWebAssetsJsonSerializerContext.RelaxedEscaping.StaticWebAssetsDevelopmentManifest);
#if !NET9_0_OR_GREATER
Expand All @@ -134,19 +139,19 @@ private void PersistManifest(StaticWebAssetsDevelopmentManifest manifest)
#else
var currentHash = SHA256.HashData(data);
#endif
var fileExists = File.Exists(ManifestPath);
var fileExists = File.Exists(manifestPath);
var existingManifestHash = fileExists ?
#if !NET9_0_OR_GREATER
sha256.ComputeHash(File.ReadAllBytes(ManifestPath)) :
sha256.ComputeHash(File.ReadAllBytes(manifestPath)) :
#else
SHA256.HashData(File.ReadAllBytes(ManifestPath)) :
SHA256.HashData(File.ReadAllBytes(manifestPath)) :
#endif
[];

if (!fileExists)
{
Log.LogMessage(MessageImportance.Low, "Creating manifest because manifest file '{0}' does not exist.", ManifestPath);
File.WriteAllBytes(ManifestPath, data);
File.WriteAllBytes(manifestPath, data);
}
else if (!currentHash.SequenceEqual(existingManifestHash))
{
Expand All @@ -155,7 +160,7 @@ private void PersistManifest(StaticWebAssetsDevelopmentManifest manifest)
"Updating manifest because manifest version '{0}' is different from existing manifest hash '{1}'.",
Convert.ToBase64String(currentHash),
Convert.ToBase64String(existingManifestHash));
File.WriteAllBytes(ManifestPath, data);
File.WriteAllBytes(manifestPath, data);
}
else
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// 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 Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

namespace Microsoft.NET.Sdk.StaticWebAssets.Tests;

// 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
{
[TestMethod]
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<IBuildEngine>().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<IBuildEngine>().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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ public void SkipsManifestGenerationWhen_ThereAreNoAssetsNorDiscoveryPatterns()
buildEngine.Setup(e => e.LogMessageEvent(It.IsAny<BuildMessageEventArgs>()))
.Callback<BuildMessageEventArgs>(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<ITaskItem>(),
DiscoveryPatterns = Array.Empty<ITaskItem>()
DiscoveryPatterns = Array.Empty<ITaskItem>(),
ManifestPath = Path.Combine(manifestDirectory, "staticwebassets.development.json"),
CacheFilePath = Path.Combine(manifestDirectory, "staticwebassets.build.cache.json")
};

// Act
Expand Down
Loading