From f76389ce751a02ba6e8903e5ec2554db0ee7f9b4 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 17 Jun 2026 16:10:12 +0200 Subject: [PATCH 1/4] [tests] Add unit test for content items defined by SDK far from project Add a test that reproduces the issue where Content/BundleResource items defined by an SDK (like the Razor SDK) have their LogicalName computed relative to the SDK directory instead of the project directory, resulting in paths like '../../../../../src/MyProject/file.png' that get rejected. This test currently fails, demonstrating the bug described in #23898. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CollectBundleResourcesTaskTests.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs index e83faf0659e6..3d48c63f0c73 100644 --- a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs +++ b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs @@ -36,5 +36,67 @@ public void LogicalNameOutsideAppBundle () Environment.CurrentDirectory = currentDirectory; } } + + // Ref: https://github.com/dotnet/macios/issues/23898 + // Items defined by an SDK (not default items) where the defining + // project is in a completely different directory tree than the + // project should still resolve their LogicalName correctly. + [Test] + public void ContentDefinedBySdkFarFromProject () + { + var currentDirectory = Environment.CurrentDirectory; + try { + var tmpdir = Cache.CreateTemporaryDirectory (); + + // Simulate the project directory + var projDir = Path.Combine (tmpdir, "src", "MyProject"); + Directory.CreateDirectory (projDir); + + // Simulate an SDK directory far from the project (like a Razor/Blazor SDK) + var sdkDir = Path.Combine (tmpdir, "sdk", "packs", "Microsoft.NET.Sdk.Razor", "10.0.0", "build"); + Directory.CreateDirectory (sdkDir); + + Environment.CurrentDirectory = projDir; + + // Create several content files in the project directory + var files = new [] { "wwwroot/background.png", "wwwroot/script.js", "Component1.razor" }; + var items = new List (); + foreach (var file in files) { + var fullPath = Path.Combine (projDir, file); + Directory.CreateDirectory (Path.GetDirectoryName (fullPath)!); + File.WriteAllText (fullPath, "content"); + + var item = new TaskItem (file); + // The defining project is the SDK file, not a default item + item.SetMetadata ("LocalDefiningProjectFullPath", Path.Combine (sdkDir, "Microsoft.NET.Sdk.Razor.DefaultItems.props")); + item.SetMetadata ("LocalMSBuildProjectFullPath", Path.Combine (projDir, "MyProject.csproj")); + items.Add (item); + } + + var task = CreateTask (); + task.ProjectDir = projDir + Path.DirectorySeparatorChar; + task.ResourcePrefix = ""; + task.BundleResources = items.ToArray (); + ExecuteTask (task); + + // With the bug, the LogicalName would be something like + // '../../../../../src/MyProject/wwwroot/background.png' which starts + // with '../' and gets rejected with a warning. + // After fix, items should be included with correct LogicalNames. + Assert.That (Engine.Logger.WarningsEvents.Count, Is.EqualTo (0), $"Warnings: {string.Join (", ", Engine.Logger.WarningsEvents.Select (e => e.Message))}"); + Assert.That (Engine.Logger.ErrorEvents.Count, Is.EqualTo (0), $"Errors: {string.Join (", ", Engine.Logger.ErrorEvents.Select (e => e.Message))}"); + Assert.That (task.BundleResourcesWithLogicalNames.Length, Is.EqualTo (3), "BundleResourcesWithLogicalNames count"); + + var logicalNames = task.BundleResourcesWithLogicalNames + .Select (i => i.GetMetadata ("LogicalName")) + .OrderBy (n => n) + .ToArray (); + Assert.That (logicalNames [0], Is.EqualTo ("Component1.razor"), "LogicalName[0]"); + Assert.That (logicalNames [1], Is.EqualTo ("wwwroot/background.png"), "LogicalName[1]"); + Assert.That (logicalNames [2], Is.EqualTo ("wwwroot/script.js"), "LogicalName[2]"); + } finally { + Environment.CurrentDirectory = currentDirectory; + } + } } } From 5d1c8b706436ce3873f42cc53ebbb42564676709 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Fri, 19 Jun 2026 12:42:05 +0200 Subject: [PATCH 2/4] [msbuild] Resolve Content and BundleResource items using the project's location. Fixes #23898. Always compute the LogicalName for Content and BundleResource items relative to the project file (LocalMSBuildProjectFullPath) instead of the defining project file (LocalDefiningProjectFullPath). This fixes the issue where SDKs (like the Razor SDK) that add Content items from a directory far from the project would produce nonsensical paths like '../../../../../src/MyProject/file.png' that get rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Xamarin.MacDev.Tasks/BundleResource.cs | 48 +++++++------------ .../CollectBundleResourcesTaskTests.cs | 8 +++- 2 files changed, 22 insertions(+), 34 deletions(-) diff --git a/msbuild/Xamarin.MacDev.Tasks/BundleResource.cs b/msbuild/Xamarin.MacDev.Tasks/BundleResource.cs index 6b58fffb930e..bc528d8642bb 100644 --- a/msbuild/Xamarin.MacDev.Tasks/BundleResource.cs +++ b/msbuild/Xamarin.MacDev.Tasks/BundleResource.cs @@ -87,38 +87,29 @@ public static string GetVirtualProjectPath (T task, ITaskItem item) where T : // Note that '/' is a valid path separator on Windows (in addition to '\'), so canonicalize the paths to use '/' as the path separator. - var isDefaultItem = item.GetMetadata ("IsDefaultItem") == "true"; var localMSBuildProjectFullPath = item.GetMetadata ("LocalMSBuildProjectFullPath").Replace ('\\', '/'); - var localDefiningProjectFullPath = item.GetMetadata ("LocalDefiningProjectFullPath").Replace ('\\', '/'); - if (string.IsNullOrEmpty (localDefiningProjectFullPath)) { - task.Log.LogError (null, null, null, item.ItemSpec, 0, 0, 0, 0, MSBStrings.E7133 /* The item '{0}' does not have a '{1}' value set. */, item.ItemSpec, "LocalDefiningProjectFullPath"); - return "placeholder"; - } if (string.IsNullOrEmpty (localMSBuildProjectFullPath)) { task.Log.LogError (null, null, null, item.ItemSpec, 0, 0, 0, 0, MSBStrings.E7133 /* The item '{0}' does not have a '{1}' value set. */, item.ItemSpec, "LocalMSBuildProjectFullPath"); return "placeholder"; } - // * If we're not a default item, compute the path relative to the - // file that declared the item in question. - // * If we're a default item (IsDefaultItem=true), compute - // relative to the user's project file (because the file that - // declared the item is our Microsoft.Sdk.DefaultItems.template.props file, - // and the path relative to that file is certainly not what we want). + // Always compute relative to the user's project file, not the + // file that declared the item. This is important because SDKs + // (such as the Razor SDK) may add items from a directory far + // from the project, and computing relative to the SDK directory + // would produce nonsensical paths. + // Ref: https://github.com/dotnet/macios/issues/23898 // - // We use the 'LocalMSBuildProjectFullPath' and - // 'LocalDefiningProjectFullPath' metadata because the - // 'MSBuildProjectFullPath' and 'DefiningProjectFullPath' are not - // necessarily correct when building remotely (the relative path - // between files might not be the same on macOS once XVS has - // copied them there, in particular for files outside the project - // directory). + // We use the 'LocalMSBuildProjectFullPath' metadata because the + // 'MSBuildProjectFullPath' is not necessarily correct when + // building remotely (the relative path between files might not + // be the same on macOS once XVS has copied them there, in + // particular for files outside the project directory). // - // The 'LocalMSBuildProjectFullPath' and 'LocalDefiningProjectFullPath' - // values are set to the Windows version of 'MSBuildProjectFullPath' - // and 'DefiningProjectFullPath' when building remotely, and the macOS - // version when building on macOS. + // The 'LocalMSBuildProjectFullPath' value is set to the Windows + // version of 'MSBuildProjectFullPath' when building remotely, + // and the macOS version when building on macOS. // First find the absolute path to the item var projectAbsoluteDir = task.ProjectDir; @@ -131,13 +122,8 @@ public static string GetVirtualProjectPath (T task, ITaskItem item) where T : } var originalItemAbsolutePath = itemAbsolutePath; - // Then find the directory we should use to compute the result relative to. - string relativeToDirectory; // this is an absolute path. - if (isDefaultItem) { - relativeToDirectory = Path.GetDirectoryName (localMSBuildProjectFullPath)!; - } else { - relativeToDirectory = Path.GetDirectoryName (localDefiningProjectFullPath)!; - } + // Compute the result relative to the project directory. + string relativeToDirectory = Path.GetDirectoryName (localMSBuildProjectFullPath)!; var originalRelativeToDirectory = relativeToDirectory; // On macOS we need to resolve symlinks before computing the relative path. @@ -159,9 +145,7 @@ public static string GetVirtualProjectPath (T task, ITaskItem item) where T : Trace (task, $"BundleResource.GetVirtualProjectPath ({item.ItemSpec}) => {rv}\n" + $"\t\t\tprojectAbsoluteDir={projectAbsoluteDir}\n" + $"\t\t\tIsRemoteBuild={isRemoteBuild}\n" + - $"\t\t\tisDefaultItem={isDefaultItem}\n" + $"\t\t\tLocalMSBuildProjectFullPath={localMSBuildProjectFullPath}\n" + - $"\t\t\tLocalDefiningProjectFullPath={localDefiningProjectFullPath}\n" + $"\t\t\toriginalItemAbsolutePath={originalItemAbsolutePath}\n" + $"\t\t\titemAbsolutePath={itemAbsolutePath}\n" + $"\t\t\toriginalRelativeToDirectory={originalRelativeToDirectory}\n" + diff --git a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs index 3d48c63f0c73..5f360030eb31 100644 --- a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs +++ b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs @@ -30,8 +30,12 @@ public void LogicalNameOutsideAppBundle () item.SetMetadata ("LocalMSBuildProjectFullPath", Path.Combine (projDir, "Project.csproj")); task.BundleResources = [item]; ExecuteTask (task); - Assert.That (Engine.Logger.WarningsEvents.Count, Is.EqualTo (1), "Warnings"); - Assert.That (Engine.Logger.WarningsEvents [0].Message, Is.EqualTo ("The path '../B/image.png' would result in a file outside of the app bundle and cannot be used."), "Warning Message"); + // After the fix for #23898, the LogicalName is computed relative + // to the project directory, so this item resolves to "image.png" + // and no longer produces an outside-app-bundle warning. + Assert.That (Engine.Logger.WarningsEvents.Count, Is.EqualTo (0), "Warnings"); + Assert.That (task.BundleResourcesWithLogicalNames.Length, Is.EqualTo (1), "BundleResourcesWithLogicalNames count"); + Assert.That (task.BundleResourcesWithLogicalNames [0].GetMetadata ("LogicalName"), Is.EqualTo ("image.png"), "LogicalName"); } finally { Environment.CurrentDirectory = currentDirectory; } From 3f13e9f2a73a645a80af3a6d3d9ad1602b285f6c Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Fri, 19 Jun 2026 13:04:25 +0200 Subject: [PATCH 3/4] [msbuild] Make ResolveResourceItemsRelativeToProject opt-in for .NET 10/11, opt-out for .NET 12+ Add a new MSBuild property ResolveResourceItemsRelativeToProject that controls whether Content and BundleResource items have their LogicalName computed relative to the project file (new behavior) or the defining project file (legacy behavior). - .NET 10 and .NET 11: defaults to false (opt-in) - .NET 12+: defaults to true (opt-out) The property is passed through a new IHasResolveResourceItemsRelativeToProject interface so that GetVirtualProjectPath can check the flag without changing its generic constraints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/building-apps/build-properties.md | 25 ++++++++ .../Xamarin.MacDev.Tasks/BundleResource.cs | 62 ++++++++++++++----- .../Tasks/CollectBundleResources.cs | 4 +- .../Xamarin.MacDev.Tasks/Tasks/Interfaces.cs | 4 ++ msbuild/Xamarin.Shared/Xamarin.Shared.props | 7 +++ msbuild/Xamarin.Shared/Xamarin.Shared.targets | 1 + .../CollectBundleResourcesTaskTests.cs | 19 +++--- 7 files changed, 95 insertions(+), 27 deletions(-) diff --git a/docs/building-apps/build-properties.md b/docs/building-apps/build-properties.md index 634396282b90..4ee7ed9c59c3 100644 --- a/docs/building-apps/build-properties.md +++ b/docs/building-apps/build-properties.md @@ -1195,6 +1195,31 @@ only scan libraries with the `[LinkWith]` attribute for Objective-C classes: ``` +## ResolveResourceItemsRelativeToProject + +This property determines whether Content and BundleResource items have their +logical names computed relative to the project file or relative to the file +that declared them. + +When set to `true`, item paths are always computed relative to the project +file. This fixes issues where SDKs (such as the Razor SDK) add Content items +from a directory far from the project, producing incorrect paths that get +rejected at build time. + +When set to `false` (the default for .NET 10 and .NET 11), the legacy behavior +is preserved: paths are computed relative to the file that declared the item. + +| .NET version | Default | +|---|---| +| .NET 10 - .NET 11 | `false` (opt-in) | +| .NET 12+ | `true` (opt-out) | + +```xml + + true + +``` + ## RunWithOpen This property determines whether apps are launched using the `open` command on diff --git a/msbuild/Xamarin.MacDev.Tasks/BundleResource.cs b/msbuild/Xamarin.MacDev.Tasks/BundleResource.cs index bc528d8642bb..3974c1feedff 100644 --- a/msbuild/Xamarin.MacDev.Tasks/BundleResource.cs +++ b/msbuild/Xamarin.MacDev.Tasks/BundleResource.cs @@ -88,28 +88,48 @@ public static string GetVirtualProjectPath (T task, ITaskItem item) where T : // Note that '/' is a valid path separator on Windows (in addition to '\'), so canonicalize the paths to use '/' as the path separator. var localMSBuildProjectFullPath = item.GetMetadata ("LocalMSBuildProjectFullPath").Replace ('\\', '/'); + var localDefiningProjectFullPath = item.GetMetadata ("LocalDefiningProjectFullPath").Replace ('\\', '/'); if (string.IsNullOrEmpty (localMSBuildProjectFullPath)) { task.Log.LogError (null, null, null, item.ItemSpec, 0, 0, 0, 0, MSBStrings.E7133 /* The item '{0}' does not have a '{1}' value set. */, item.ItemSpec, "LocalMSBuildProjectFullPath"); return "placeholder"; } - // Always compute relative to the user's project file, not the - // file that declared the item. This is important because SDKs - // (such as the Razor SDK) may add items from a directory far - // from the project, and computing relative to the SDK directory - // would produce nonsensical paths. + // Check if the task has opted into resolving items relative to + // the project file instead of the defining project file. // Ref: https://github.com/dotnet/macios/issues/23898 + var resolveRelativeToProject = task is IHasResolveResourceItemsRelativeToProject rr && rr.ResolveResourceItemsRelativeToProject; + + // When resolveRelativeToProject is true, always compute + // relative to the user's project file. This is important + // because SDKs (such as the Razor SDK) may add items from a + // directory far from the project, and computing relative to + // the SDK directory would produce nonsensical paths. + // + // When resolveRelativeToProject is false, use the legacy + // behavior: compute relative to the file that declared the + // item (DefiningProjectFullPath), unless the item is a default + // item (IsDefaultItem=true), in which case compute relative to + // the user's project file. // - // We use the 'LocalMSBuildProjectFullPath' metadata because the - // 'MSBuildProjectFullPath' is not necessarily correct when - // building remotely (the relative path between files might not - // be the same on macOS once XVS has copied them there, in - // particular for files outside the project directory). + // We use the 'LocalMSBuildProjectFullPath' and + // 'LocalDefiningProjectFullPath' metadata because the + // 'MSBuildProjectFullPath' and 'DefiningProjectFullPath' are + // not necessarily correct when building remotely (the relative + // path between files might not be the same on macOS once XVS + // has copied them there, in particular for files outside the + // project directory). // - // The 'LocalMSBuildProjectFullPath' value is set to the Windows - // version of 'MSBuildProjectFullPath' when building remotely, - // and the macOS version when building on macOS. + // The 'LocalMSBuildProjectFullPath' and + // 'LocalDefiningProjectFullPath' values are set to the Windows + // version of 'MSBuildProjectFullPath' and + // 'DefiningProjectFullPath' when building remotely, and the + // macOS version when building on macOS. + + if (!resolveRelativeToProject && string.IsNullOrEmpty (localDefiningProjectFullPath)) { + task.Log.LogError (null, null, null, item.ItemSpec, 0, 0, 0, 0, MSBStrings.E7133 /* The item '{0}' does not have a '{1}' value set. */, item.ItemSpec, "LocalDefiningProjectFullPath"); + return "placeholder"; + } // First find the absolute path to the item var projectAbsoluteDir = task.ProjectDir; @@ -122,8 +142,18 @@ public static string GetVirtualProjectPath (T task, ITaskItem item) where T : } var originalItemAbsolutePath = itemAbsolutePath; - // Compute the result relative to the project directory. - string relativeToDirectory = Path.GetDirectoryName (localMSBuildProjectFullPath)!; + // Then find the directory we should use to compute the result relative to. + string relativeToDirectory; // this is an absolute path. + if (resolveRelativeToProject) { + relativeToDirectory = Path.GetDirectoryName (localMSBuildProjectFullPath)!; + } else { + var isDefaultItem = item.GetMetadata ("IsDefaultItem") == "true"; + if (isDefaultItem) { + relativeToDirectory = Path.GetDirectoryName (localMSBuildProjectFullPath)!; + } else { + relativeToDirectory = Path.GetDirectoryName (localDefiningProjectFullPath)!; + } + } var originalRelativeToDirectory = relativeToDirectory; // On macOS we need to resolve symlinks before computing the relative path. @@ -145,7 +175,9 @@ public static string GetVirtualProjectPath (T task, ITaskItem item) where T : Trace (task, $"BundleResource.GetVirtualProjectPath ({item.ItemSpec}) => {rv}\n" + $"\t\t\tprojectAbsoluteDir={projectAbsoluteDir}\n" + $"\t\t\tIsRemoteBuild={isRemoteBuild}\n" + + $"\t\t\tresolveRelativeToProject={resolveRelativeToProject}\n" + $"\t\t\tLocalMSBuildProjectFullPath={localMSBuildProjectFullPath}\n" + + $"\t\t\tLocalDefiningProjectFullPath={localDefiningProjectFullPath}\n" + $"\t\t\toriginalItemAbsolutePath={originalItemAbsolutePath}\n" + $"\t\t\titemAbsolutePath={itemAbsolutePath}\n" + $"\t\t\toriginalRelativeToDirectory={originalRelativeToDirectory}\n" + diff --git a/msbuild/Xamarin.MacDev.Tasks/Tasks/CollectBundleResources.cs b/msbuild/Xamarin.MacDev.Tasks/Tasks/CollectBundleResources.cs index 32feaa6761fa..06cf25eea29e 100644 --- a/msbuild/Xamarin.MacDev.Tasks/Tasks/CollectBundleResources.cs +++ b/msbuild/Xamarin.MacDev.Tasks/Tasks/CollectBundleResources.cs @@ -11,7 +11,7 @@ using Xamarin.Utils; namespace Xamarin.MacDev.Tasks { - public class CollectBundleResources : XamarinTask, ICancelableTask, IHasProjectDir, IHasResourcePrefix { + public class CollectBundleResources : XamarinTask, ICancelableTask, IHasProjectDir, IHasResourcePrefix, IHasResolveResourceItemsRelativeToProject { #region Inputs public ITaskItem [] BundleResources { get; set; } = Array.Empty (); @@ -26,6 +26,8 @@ public class CollectBundleResources : XamarinTask, ICancelableTask, IHasProjectD [Required] public string ResourcePrefix { get; set; } = string.Empty; + public bool ResolveResourceItemsRelativeToProject { get; set; } + #endregion #region Outputs diff --git a/msbuild/Xamarin.MacDev.Tasks/Tasks/Interfaces.cs b/msbuild/Xamarin.MacDev.Tasks/Tasks/Interfaces.cs index 4ccf4f12f781..2d3b69048a0a 100644 --- a/msbuild/Xamarin.MacDev.Tasks/Tasks/Interfaces.cs +++ b/msbuild/Xamarin.MacDev.Tasks/Tasks/Interfaces.cs @@ -10,4 +10,8 @@ public interface IHasProjectDir { public interface IHasSessionId { string SessionId { get; set; } } + + public interface IHasResolveResourceItemsRelativeToProject { + bool ResolveResourceItemsRelativeToProject { get; set; } + } } diff --git a/msbuild/Xamarin.Shared/Xamarin.Shared.props b/msbuild/Xamarin.Shared/Xamarin.Shared.props index 43b3f5fa0091..627e7f6731ca 100644 --- a/msbuild/Xamarin.Shared/Xamarin.Shared.props +++ b/msbuild/Xamarin.Shared/Xamarin.Shared.props @@ -200,6 +200,13 @@ Copyright (C) 2020 Microsoft. All rights reserved. <_BundleOriginalResources Condition="'$(OutputType)' == 'Library' And '$(IsAppExtension)' != 'true' And '$(BundleOriginalResources)' == 'true'">true + + true + false + true diff --git a/msbuild/Xamarin.Shared/Xamarin.Shared.targets b/msbuild/Xamarin.Shared/Xamarin.Shared.targets index 8307a85ebf48..334e3d4e7303 100644 --- a/msbuild/Xamarin.Shared/Xamarin.Shared.targets +++ b/msbuild/Xamarin.Shared/Xamarin.Shared.targets @@ -460,6 +460,7 @@ Copyright (C) 2018 Microsoft. All rights reserved. BundleResources="@(Content);@(BundleResource)" ProjectDir="$(MSBuildProjectDirectory)" ResourcePrefix="$(_ResourcePrefix)" + ResolveResourceItemsRelativeToProject="$(ResolveResourceItemsRelativeToProject)" TargetFrameworkMoniker="$(_ComputedTargetFrameworkMoniker)" UnpackedResources="@(_UnpackedBundleResourceWithLogicalName)" > diff --git a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs index 5f360030eb31..d19ba926d7c1 100644 --- a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs +++ b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs @@ -30,12 +30,8 @@ public void LogicalNameOutsideAppBundle () item.SetMetadata ("LocalMSBuildProjectFullPath", Path.Combine (projDir, "Project.csproj")); task.BundleResources = [item]; ExecuteTask (task); - // After the fix for #23898, the LogicalName is computed relative - // to the project directory, so this item resolves to "image.png" - // and no longer produces an outside-app-bundle warning. - Assert.That (Engine.Logger.WarningsEvents.Count, Is.EqualTo (0), "Warnings"); - Assert.That (task.BundleResourcesWithLogicalNames.Length, Is.EqualTo (1), "BundleResourcesWithLogicalNames count"); - Assert.That (task.BundleResourcesWithLogicalNames [0].GetMetadata ("LogicalName"), Is.EqualTo ("image.png"), "LogicalName"); + Assert.That (Engine.Logger.WarningsEvents.Count, Is.EqualTo (1), "Warnings"); + Assert.That (Engine.Logger.WarningsEvents [0].Message, Is.EqualTo ("The path '../B/image.png' would result in a file outside of the app bundle and cannot be used."), "Warning Message"); } finally { Environment.CurrentDirectory = currentDirectory; } @@ -44,7 +40,8 @@ public void LogicalNameOutsideAppBundle () // Ref: https://github.com/dotnet/macios/issues/23898 // Items defined by an SDK (not default items) where the defining // project is in a completely different directory tree than the - // project should still resolve their LogicalName correctly. + // project should still resolve their LogicalName correctly + // when ResolveResourceItemsRelativeToProject is enabled. [Test] public void ContentDefinedBySdkFarFromProject () { @@ -80,13 +77,13 @@ public void ContentDefinedBySdkFarFromProject () var task = CreateTask (); task.ProjectDir = projDir + Path.DirectorySeparatorChar; task.ResourcePrefix = ""; + task.ResolveResourceItemsRelativeToProject = true; task.BundleResources = items.ToArray (); ExecuteTask (task); - // With the bug, the LogicalName would be something like - // '../../../../../src/MyProject/wwwroot/background.png' which starts - // with '../' and gets rejected with a warning. - // After fix, items should be included with correct LogicalNames. + // With ResolveResourceItemsRelativeToProject enabled, the + // LogicalName is computed relative to the project directory, + // so items are correctly included. Assert.That (Engine.Logger.WarningsEvents.Count, Is.EqualTo (0), $"Warnings: {string.Join (", ", Engine.Logger.WarningsEvents.Select (e => e.Message))}"); Assert.That (Engine.Logger.ErrorEvents.Count, Is.EqualTo (0), $"Errors: {string.Join (", ", Engine.Logger.ErrorEvents.Select (e => e.Message))}"); Assert.That (task.BundleResourcesWithLogicalNames.Length, Is.EqualTo (3), "BundleResourcesWithLogicalNames count"); From cc871fcf999463521d88c59b31aceed5582f5a7d Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Mon, 22 Jun 2026 20:03:58 +0200 Subject: [PATCH 4/4] [tests] Parameterize ContentDefinedBySdkFarFromProject to test both code paths Test with ResolveResourceItemsRelativeToProject=true (.NET 12+ default) and ResolveResourceItemsRelativeToProject=false (.NET 10/11 default) to ensure both the fixed and legacy behaviors are covered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CollectBundleResourcesTaskTests.cs | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs index d19ba926d7c1..6fcae7bdb986 100644 --- a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs +++ b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs @@ -42,8 +42,9 @@ public void LogicalNameOutsideAppBundle () // project is in a completely different directory tree than the // project should still resolve their LogicalName correctly // when ResolveResourceItemsRelativeToProject is enabled. - [Test] - public void ContentDefinedBySdkFarFromProject () + [TestCase (true)] // .NET 12+ default: resolve relative to project + [TestCase (false)] // .NET 10/11 default: resolve relative to defining project (legacy behavior) + public void ContentDefinedBySdkFarFromProject (bool resolveRelativeToProject) { var currentDirectory = Environment.CurrentDirectory; try { @@ -77,24 +78,32 @@ public void ContentDefinedBySdkFarFromProject () var task = CreateTask (); task.ProjectDir = projDir + Path.DirectorySeparatorChar; task.ResourcePrefix = ""; - task.ResolveResourceItemsRelativeToProject = true; + task.ResolveResourceItemsRelativeToProject = resolveRelativeToProject; task.BundleResources = items.ToArray (); ExecuteTask (task); - // With ResolveResourceItemsRelativeToProject enabled, the - // LogicalName is computed relative to the project directory, - // so items are correctly included. - Assert.That (Engine.Logger.WarningsEvents.Count, Is.EqualTo (0), $"Warnings: {string.Join (", ", Engine.Logger.WarningsEvents.Select (e => e.Message))}"); - Assert.That (Engine.Logger.ErrorEvents.Count, Is.EqualTo (0), $"Errors: {string.Join (", ", Engine.Logger.ErrorEvents.Select (e => e.Message))}"); - Assert.That (task.BundleResourcesWithLogicalNames.Length, Is.EqualTo (3), "BundleResourcesWithLogicalNames count"); + if (resolveRelativeToProject) { + // With ResolveResourceItemsRelativeToProject enabled, the + // LogicalName is computed relative to the project directory, + // so items are correctly included. + Assert.That (Engine.Logger.WarningsEvents.Count, Is.EqualTo (0), $"Warnings: {string.Join (", ", Engine.Logger.WarningsEvents.Select (e => e.Message))}"); + Assert.That (Engine.Logger.ErrorEvents.Count, Is.EqualTo (0), $"Errors: {string.Join (", ", Engine.Logger.ErrorEvents.Select (e => e.Message))}"); + Assert.That (task.BundleResourcesWithLogicalNames.Length, Is.EqualTo (3), "BundleResourcesWithLogicalNames count"); - var logicalNames = task.BundleResourcesWithLogicalNames - .Select (i => i.GetMetadata ("LogicalName")) - .OrderBy (n => n) - .ToArray (); - Assert.That (logicalNames [0], Is.EqualTo ("Component1.razor"), "LogicalName[0]"); - Assert.That (logicalNames [1], Is.EqualTo ("wwwroot/background.png"), "LogicalName[1]"); - Assert.That (logicalNames [2], Is.EqualTo ("wwwroot/script.js"), "LogicalName[2]"); + var logicalNames = task.BundleResourcesWithLogicalNames + .Select (i => i.GetMetadata ("LogicalName")) + .OrderBy (n => n) + .ToArray (); + Assert.That (logicalNames [0], Is.EqualTo ("Component1.razor"), "LogicalName[0]"); + Assert.That (logicalNames [1], Is.EqualTo ("wwwroot/background.png"), "LogicalName[1]"); + Assert.That (logicalNames [2], Is.EqualTo ("wwwroot/script.js"), "LogicalName[2]"); + } else { + // Without ResolveResourceItemsRelativeToProject, the LogicalName + // is computed relative to the defining project (SDK) directory, + // which produces paths outside the app bundle. + Assert.That (Engine.Logger.WarningsEvents.Count, Is.EqualTo (3), $"Warnings: {string.Join (", ", Engine.Logger.WarningsEvents.Select (e => e.Message))}"); + Assert.That (task.BundleResourcesWithLogicalNames.Length, Is.EqualTo (0), "BundleResourcesWithLogicalNames count"); + } } finally { Environment.CurrentDirectory = currentDirectory; }