Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e5763fd
Migrate manifest-handling tasks to TaskEnvironment API
JanProvaznik Feb 2, 2026
96ef4a4
Add path canonicalization and TaskEnvironment tests
JanProvaznik Feb 4, 2026
5d24c3b
Merge branch 'main' into resource-manifests-migration
JanProvaznik Feb 10, 2026
b2876ad
Fix compatibility issues: preserve original paths in error messages a…
JanProvaznik Feb 10, 2026
f6d9071
Address PR review: AbsolutePath types, GetCanonicalForm, remove unuse…
JanProvaznik Feb 10, 2026
6d3a74b
Deduplicate skill file: merge Sin 5+7, unify checklists, trim redunda…
JanProvaznik Feb 10, 2026
86f7785
Remove skill file changes (extracted to separate PR)
JanProvaznik Feb 10, 2026
ccbec40
Merge remote-tracking branch 'origin/main' into resource-manifests-mi…
JanProvaznik Feb 17, 2026
93cbd5e
Fix Constants ambiguity after merge with main
JanProvaznik Feb 17, 2026
d92d315
Remove [MSBuildMultiThreadableTask] from abstract classes (Inherited=…
JanProvaznik Feb 19, 2026
1cd9eb3
Fix CWD dependency in Util.SortItems: use TaskEnvironment for path ca…
JanProvaznik Feb 19, 2026
651ea49
Change CreateFileStream delegate to take AbsolutePath instead of string
JanProvaznik Feb 19, 2026
80bd870
Merge branch 'main' into resource-manifests-migration
JanProvaznik Feb 23, 2026
c8b44ad
Merge branch 'main' into resource-manifests-migration
JanProvaznik Mar 6, 2026
7b75e5a
Merge branch 'main' into resource-manifests-migration
JanProvaznik Apr 28, 2026
a8525b7
fallback behavior
JanProvaznik Apr 28, 2026
c98970d
remove obsolete test setup
JanProvaznik Apr 28, 2026
a8b1981
note the behavior of Manifest.ResolveFiles for consumers
JanProvaznik Apr 28, 2026
a697593
absolutize before resolving
JanProvaznik Apr 28, 2026
d383811
fix test absolutepath handling
JanProvaznik Apr 28, 2026
9795b84
fix test pr comments
JanProvaznik Apr 28, 2026
a2e90df
Merge branch 'main' into resource-manifests-migration
JanProvaznik Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Tasks.UnitTests/AddToWin32Manifest_Tests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
Expand Down Expand Up @@ -35,7 +35,7 @@ public void ManifestPopulationCheck(string? manifestName, bool expectedResult)
{
AddToWin32Manifest task = new AddToWin32Manifest()
{
BuildEngine = new MockEngine(_testOutput)
BuildEngine = new MockEngine(_testOutput),
};

using (TestEnvironment env = TestEnvironment.Create())
Expand Down
4 changes: 2 additions & 2 deletions src/Tasks.UnitTests/CreateCSharpManifestResourceName_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -773,9 +773,9 @@ class MyForm
/// <param name="mode">File mode</param>
/// <param name="access">Access type</param>
/// <returns>The Stream</returns>
private Stream CreateFileStream(string path, FileMode mode, FileAccess access)
private Stream CreateFileStream(AbsolutePath path, FileMode mode, FileAccess access)
{
if (String.Equals(path, "SR1.strings", StringComparison.OrdinalIgnoreCase))
if (String.Equals(Path.GetFileName(path.Value), "SR1.strings", StringComparison.OrdinalIgnoreCase))
{
return StreamHelpers.StringToStream("namespace MyStuff.Namespace { class Class {} }");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,9 +410,9 @@ End Class
/// <param name="mode">File mode</param>
/// <param name="access">Access type</param>
/// <returns>The Stream</returns>
private Stream CreateFileStream(string path, FileMode mode, FileAccess access)
private Stream CreateFileStream(AbsolutePath path, FileMode mode, FileAccess access)
{
if (String.Equals(path, "SR1.strings", StringComparison.OrdinalIgnoreCase))
if (String.Equals(Path.GetFileName(path.Value), "SR1.strings", StringComparison.OrdinalIgnoreCase))
{
return StreamHelpers.StringToStream(
@"
Expand Down
232 changes: 232 additions & 0 deletions src/Tasks.UnitTests/ManifestTaskEnvironmentTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.IO;
using Microsoft.Build.Framework;
using Microsoft.Build.Tasks;
using Microsoft.Build.UnitTests;
using Microsoft.Build.Utilities;
using Shouldly;
using Xunit;

namespace Microsoft.Build.Tasks.UnitTests
{
/// <summary>
/// Tests verifying TaskEnvironment migration compatibility for manifest tasks.
/// These tests focus on path handling changes from the migration.
/// </summary>
public class ManifestTaskEnvironmentTests
{
Comment thread
JanProvaznik marked this conversation as resolved.
private readonly ITestOutputHelper _output;

public ManifestTaskEnvironmentTests(ITestOutputHelper output) => _output = output;
// Test 1: Empty ItemSpec - verifies exception handling matches pre-migration behavior
// GetAbsolutePath throws on empty, but this flows through existing exception handling
[Fact]
public void CreateManifestResourceName_EmptyItemSpec_ShouldFail()
{
var engine = new MockEngine(_output);
var task = new CreateCSharpManifestResourceName
{
BuildEngine = engine,
ResourceFiles = new ITaskItem[] { new TaskItem("") },
RootNamespace = "Test"
};

// On .NET Framework: returns false with logged error
// On .NET Core+: throws ArgumentNullException (pre-existing behavior in Path.GetDirectoryName)
#if NETFRAMEWORK
bool result = task.Execute();
result.ShouldBeFalse();
#else
Should.Throw<ArgumentNullException>(() => task.Execute());
#endif
}

// Test 2: Path with .. segments - critical test for canonicalization
// GetAbsolutePath does NOT canonicalize, so we wrap with Path.GetFullPath where needed
[Fact]
public void CreateManifestResourceName_PathWithDotDot_ShouldResolve()
{
using var env = TestEnvironment.Create(_output);
var folder = env.CreateFolder();
var subFolder = Path.Combine(folder.Path, "sub");
Directory.CreateDirectory(subFolder);

var resxPath = Path.Combine(subFolder, "Test.resx");
File.WriteAllText(resxPath, "<root></root>");

var csPath = Path.Combine(subFolder, "Test.cs");
File.WriteAllText(csPath, "namespace Test { class Test { } }");

// Use path with .. segments - tests canonicalization
var pathWithDotDot = Path.Combine(folder.Path, "sub", "..", "sub", "Test.resx");

var task = new CreateCSharpManifestResourceName
{
BuildEngine = new MockEngine(_output),
ResourceFiles = new ITaskItem[] { new TaskItem(pathWithDotDot) },
RootNamespace = "Test",
UseDependentUponConvention = true
};

bool result = task.Execute();
result.ShouldBeTrue();
}

// Test 3: Forward slashes - tests path normalization
[Fact]
public void CreateManifestResourceName_ForwardSlashes_ShouldWork()
{
using var env = TestEnvironment.Create(_output);
var folder = env.CreateFolder();
var subFolder = Path.Combine(folder.Path, "Resources");
Directory.CreateDirectory(subFolder);

var resxPath = Path.Combine(subFolder, "Strings.resx");
File.WriteAllText(resxPath, "<root></root>");

// Replace backslashes with forward slashes
var pathWithForwardSlashes = resxPath.Replace('\\', '/');

var task = new CreateCSharpManifestResourceName
{
BuildEngine = new MockEngine(_output),
ResourceFiles = new ITaskItem[] { new TaskItem(pathWithForwardSlashes) },
RootNamespace = "Test"
};

bool result = task.Execute();
result.ShouldBeTrue();
}

// Test 4: Mixed slashes - tests path normalization handles both
[Fact]
public void CreateManifestResourceName_MixedSlashes_ShouldWork()
{
using var env = TestEnvironment.Create(_output);
var folder = env.CreateFolder();
var subFolder = Path.Combine(folder.Path, "Sub", "Folder");
Directory.CreateDirectory(subFolder);

var resxPath = Path.Combine(subFolder, "Test.resx");
File.WriteAllText(resxPath, "<root></root>");

// Mix forward and back slashes
var mixedPath = folder.Path + "/Sub\\Folder/Test.resx";

var task = new CreateCSharpManifestResourceName
{
BuildEngine = new MockEngine(_output),
ResourceFiles = new ITaskItem[] { new TaskItem(mixedPath) },
RootNamespace = "Test"
};

bool result = task.Execute();
result.ShouldBeTrue();
}

// Test 5: AddToWin32Manifest with null ApplicationManifest - tests graceful handling
[Fact]
public void AddToWin32Manifest_NullApplicationManifest_HandledGracefully()
{
using var env = TestEnvironment.Create(_output);
var folder = env.CreateFolder();

var task = new AddToWin32Manifest
{
BuildEngine = new MockEngine(_output),
ApplicationManifest = null,
OutputDirectory = folder.Path,
SupportedArchitectures = "amd64"
};

// Null is treated as "no manifest" - should generate new one
bool result = task.Execute();
result.ShouldBeTrue();
}

// Test 6: Batch processing - one error should not abort remaining items
[Fact]
public void CreateManifestResourceName_BatchProcessing_ContinuesAfterError()
{
using var env = TestEnvironment.Create(_output);
var folder = env.CreateFolder();

// Create one valid resource
var validPath = Path.Combine(folder.Path, "Valid.resx");
File.WriteAllText(validPath, "<root></root>");

// Create another valid resource
var valid2Path = Path.Combine(folder.Path, "Valid2.resx");
File.WriteAllText(valid2Path, "<root></root>");

// Invalid: DependentUpon points to non-existent file
var invalidItem = new TaskItem(validPath);
invalidItem.SetMetadata("DependentUpon", "NonExistent.cs");

var validItem = new TaskItem(valid2Path);

var task = new CreateCSharpManifestResourceName
{
BuildEngine = new MockEngine(_output),
ResourceFiles = new ITaskItem[] { invalidItem, validItem },
RootNamespace = "Test"
};

// Should return false due to error, but should still process both items
bool result = task.Execute();
result.ShouldBeFalse();
// The valid item should still have been processed successfully
task.ManifestResourceNames[1].ShouldNotBeNull();
task.ManifestResourceNames[1].ItemSpec.ShouldNotBeNullOrEmpty();
}

// Test 7: Deeply nested folder - tests path handling with many segments
[Fact]
public void CreateManifestResourceName_DeepNesting_ShouldWork()
{
using var env = TestEnvironment.Create(_output);
var folder = env.CreateFolder();
var deepFolder = Path.Combine(folder.Path, "a", "b", "c", "d", "e");
Directory.CreateDirectory(deepFolder);

var resxPath = Path.Combine(deepFolder, "Test.resx");
File.WriteAllText(resxPath, "<root></root>");

var task = new CreateCSharpManifestResourceName
{
BuildEngine = new MockEngine(_output),
ResourceFiles = new ITaskItem[] { new TaskItem(resxPath) },
RootNamespace = "Test"
};

bool result = task.Execute();
result.ShouldBeTrue();
}

// Test 8: Path with spaces - tests no issues with space handling
[Fact]
public void CreateManifestResourceName_PathWithSpaces_ShouldWork()
{
using var env = TestEnvironment.Create(_output);
var folder = env.CreateFolder();
var spaceFolder = Path.Combine(folder.Path, "My Resources");
Directory.CreateDirectory(spaceFolder);

var resxPath = Path.Combine(spaceFolder, "My Strings.resx");
File.WriteAllText(resxPath, "<root></root>");

var task = new CreateCSharpManifestResourceName
{
BuildEngine = new MockEngine(_output),
ResourceFiles = new ITaskItem[] { new TaskItem(resxPath) },
RootNamespace = "Test"
};

bool result = task.Execute();
result.ShouldBeTrue();
}
}
}
42 changes: 28 additions & 14 deletions src/Tasks/AddToWin32Manifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ namespace Microsoft.Build.Tasks
/// <summary>
/// Generates an application manifest or adds an entry to the existing one when PreferNativeArm64 property is true.
/// </summary>
public sealed class AddToWin32Manifest : TaskExtension
[MSBuildMultiThreadableTask]
public sealed class AddToWin32Manifest : TaskExtension, IMultiThreadableTask
{
private const string supportedArchitectures = "supportedArchitectures";
private const string windowsSettings = "windowsSettings";
Expand Down Expand Up @@ -86,51 +87,62 @@ public string ManifestPath
private set => _generatedManifestFullPath = value;
}

private string? GetManifestPath()
/// <inheritdoc />
public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;

private AbsolutePath? GetManifestPath()
{
if (ApplicationManifest != null)
{
if (string.IsNullOrEmpty(ApplicationManifest.ItemSpec) || !FileSystems.Default.FileExists(ApplicationManifest?.ItemSpec))
if (string.IsNullOrEmpty(ApplicationManifest.ItemSpec))
{
Log.LogErrorWithCodeFromResources(null, ApplicationManifest.ItemSpec, 0, 0, 0, 0, "AddToWin32Manifest.SpecifiedApplicationManifestCanNotBeFound");
return null;
}

AbsolutePath absolutePath = TaskEnvironment.GetAbsolutePath(ApplicationManifest.ItemSpec);
if (!FileSystems.Default.FileExists(absolutePath))
{
Log.LogErrorWithCodeFromResources(null, ApplicationManifest?.ItemSpec, 0, 0, 0, 0, "AddToWin32Manifest.SpecifiedApplicationManifestCanNotBeFound");
Log.LogErrorWithCodeFromResources(null, ApplicationManifest.ItemSpec, 0, 0, 0, 0, "AddToWin32Manifest.SpecifiedApplicationManifestCanNotBeFound");
return null;
}

return ApplicationManifest!.ItemSpec;
return absolutePath;
}

string? defaultManifestPath = ToolLocationHelper.GetPathToDotNetFrameworkFile(DefaultManifestName, TargetDotNetFrameworkVersion.Version46);
Comment thread
JanProvaznik marked this conversation as resolved.

return defaultManifestPath;
return defaultManifestPath is null ? null : new AbsolutePath(defaultManifestPath);
}

private Stream? GetManifestStream(string? path)
private Stream? GetManifestStream(AbsolutePath? path)
{
// The logic for getting default manifest is similar to the one from Roslyn:
// If Roslyn logic returns null, we fall back to reading embedded manifest.
return path is null
? typeof(AddToWin32Manifest).Assembly.GetManifestResourceStream($"Microsoft.Build.Tasks.Resources.{DefaultManifestName}")
: File.OpenRead(path);
: File.OpenRead(path.Value);
}

public override bool Execute()
{
string? manifestPath = GetManifestPath();
AbsolutePath? manifestPath = GetManifestPath();
string? manifestDisplayPath = manifestPath?.OriginalValue;
try
{
using Stream? stream = GetManifestStream(manifestPath);

if (stream is null)
{
Log.LogErrorWithCodeFromResources(null, manifestPath, 0, 0, 0, 0, "AddToWin32Manifest.ManifestCanNotBeOpened");
Log.LogErrorWithCodeFromResources(null, manifestDisplayPath, 0, 0, 0, 0, "AddToWin32Manifest.ManifestCanNotBeOpened");

return !Log.HasLoggedErrors;
}

XmlDocument document = LoadManifest(stream);
XmlNamespaceManager xmlNamespaceManager = XmlNamespaces.GetNamespaceManager(document.NameTable);

ManifestValidationResult validationResult = ValidateManifest(manifestPath, document, xmlNamespaceManager);
ManifestValidationResult validationResult = ValidateManifest(manifestDisplayPath, document, xmlNamespaceManager);

switch (validationResult)
{
Expand All @@ -148,7 +160,7 @@ public override bool Execute()
}
catch (Exception ex)
{
Log.LogErrorWithCodeFromResources(null, manifestPath, 0, 0, 0, 0, "AddToWin32Manifest.ManifestCanNotBeOpenedWithException", ex.Message);
Log.LogErrorWithCodeFromResources(null, manifestDisplayPath, 0, 0, 0, 0, "AddToWin32Manifest.ManifestCanNotBeOpenedWithException", ex.Message);

return !Log.HasLoggedErrors;
}
Expand All @@ -168,8 +180,10 @@ private XmlDocument LoadManifest(Stream stream)

private void SaveManifest(XmlDocument document, string manifestName)
{
ManifestPath = Path.Combine(OutputDirectory, manifestName);
using (var xmlWriter = new XmlTextWriter(ManifestPath, Encoding.UTF8))
string originalPath = Path.Combine(OutputDirectory, manifestName);
AbsolutePath outputPath = TaskEnvironment.GetAbsolutePath(originalPath);
ManifestPath = originalPath;
using (var xmlWriter = new XmlTextWriter(outputPath, Encoding.UTF8))
{
xmlWriter.Formatting = Formatting.Indented;
xmlWriter.Indentation = 4;
Expand Down
1 change: 1 addition & 0 deletions src/Tasks/CreateCSharpManifestResourceName.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ namespace Microsoft.Build.Tasks
/// Base class for task that determines the appropriate manifest resource name to
/// assign to a given resx or other resource.
/// </summary>
[MSBuildMultiThreadableTask]
public class CreateCSharpManifestResourceName : CreateManifestResourceName
{
protected override string SourceFileExtension => ".cs";
Expand Down
Loading