diff --git a/eng/Subsets.props b/eng/Subsets.props
index 337bca6fa63caf..1d8f64a5ca9fb3 100644
--- a/eng/Subsets.props
+++ b/eng/Subsets.props
@@ -171,6 +171,7 @@
$(AllSubsetsExpansion)+clr.paltests+clr.paltestlist+clr.hosts+clr.jit+clr.alljits+clr.alljitscommunity+clr.spmi+clr.corelib+clr.nativecorelib+clr.tools+clr.toolstests+clr.packages
$(AllSubsetsExpansion)+linuxdac+alpinedac
$(AllSubsetsExpansion)+mono.runtime+provision.emsdk+mono.aotcross+mono.corelib+mono.manifests+mono.packages+mono.tools+mono.wasmruntime+mono.wasiruntime+mono.wasmworkload+mono.mscordbi
+ $(AllSubsetsExpansion)+apple.buildtests
$(AllSubsetsExpansion)+tools.illink+tools.cdac+tools.illinktests+tools.cdactests+tools.cdacdumptests
$(AllSubsetsExpansion)+host.native+host.pkg+host.tools+host.pretest+host.tests
$(AllSubsetsExpansion)+libs.native+libs.sfx+libs.oob+libs.pretest+libs.tests
@@ -253,6 +254,9 @@
+
+
+
@@ -656,6 +660,12 @@
+
+
+
+
+
+
diff --git a/eng/pipelines/common/evaluate-default-paths.yml b/eng/pipelines/common/evaluate-default-paths.yml
index 94c75642341b46..4572842233d8c3 100644
--- a/eng/pipelines/common/evaluate-default-paths.yml
+++ b/eng/pipelines/common/evaluate-default-paths.yml
@@ -182,6 +182,12 @@ jobs:
- src/native/managed/cdac/*
- src/coreclr/debug/runtimeinfo/*
+ # The Apple mobile MSBuild targets and the tests that cover them. The tests only drive MSBuild, so
+ # they run on ordinary build hosts and need none of the mobile or WebAssembly tooling.
+ - subset: applebuildtests
+ include:
+ - src/mono/msbuild/apple/*
+
- subset: installer
include:
exclude:
diff --git a/eng/pipelines/runtime.yml b/eng/pipelines/runtime.yml
index af2fc6e3392cf5..a90a3e2df02b1e 100644
--- a/eng/pipelines/runtime.yml
+++ b/eng/pipelines/runtime.yml
@@ -1057,6 +1057,29 @@ extends:
eq(stageDependencies.EvaluatePaths.evaluate_paths.outputs['SetPathVars_installer.containsChange'], true),
eq(variables['isRollingBuild'], true))
+ #
+ # Apple mobile MSBuild targets tests
+ # Only when the Apple MSBuild targets change
+ #
+ # These only drive MSBuild and file orchestration in a child process, so they run on an ordinary
+ # macOS build host and need no Apple mobile workload or device.
+ - template: /eng/pipelines/common/platform-matrix.yml
+ parameters:
+ jobTemplate: /eng/pipelines/common/global-build-job.yml
+ buildConfig: Release
+ platforms:
+ - osx_x64
+ jobParameters:
+ timeoutInMinutes: 30
+ nameSuffix: AppleBuildTests
+ buildArgs: -s apple.buildtests -c $(_BuildConfig) -test
+ enablePublishTestResults: true
+ testResultsFormat: 'xunit'
+ condition: >-
+ or(
+ eq(stageDependencies.EvaluatePaths.evaluate_paths.outputs['SetPathVars_applebuildtests.containsChange'], true),
+ eq(variables['isRollingBuild'], true))
+
#
# WebAssembly legs
#
diff --git a/src/mono/msbuild/apple/data/ProxyProjectForAOTOnHelix.proj b/src/mono/msbuild/apple/data/ProxyProjectForAOTOnHelix.proj
index 45fb92f779e169..878aa9dd49688c 100644
--- a/src/mono/msbuild/apple/data/ProxyProjectForAOTOnHelix.proj
+++ b/src/mono/msbuild/apple/data/ProxyProjectForAOTOnHelix.proj
@@ -160,6 +160,10 @@
<_R2ROutputPath>$(IntermediateOutputPath)R2R
+
+ <_R2RAppPayloadDir>$([MSBuild]::NormalizeDirectory($(BaseIntermediateOutputPath), 'r2r-app-payload'))
@@ -167,18 +171,36 @@
<_R2RDylibFiles Include="$(_R2ROutputPath)\*.r2r.dylib" />
+
-
+
+
+
+
+
+ <_R2RAppPayloadSourceFiles Include="$(OriginalPublishDir)**\*" />
+
+
+
-
+
+ $(_R2RAppPayloadDir)
+
+
-
+
+
+
+
+
+
@@ -188,6 +210,11 @@
$(TargetOS)-$(TargetArchitecture)
+
+
+
<_R2RPublishAssembly Include="$(OriginalPublishDir)*.dll" Exclude="$(OriginalPublishDir)*.resources.dll" />
diff --git a/src/mono/msbuild/apple/tests/Apple.Build.Tests/Apple.Build.Tests.csproj b/src/mono/msbuild/apple/tests/Apple.Build.Tests/Apple.Build.Tests.csproj
new file mode 100644
index 00000000000000..eb2886c5aadd18
--- /dev/null
+++ b/src/mono/msbuild/apple/tests/Apple.Build.Tests/Apple.Build.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+
+
+ $(NetCoreAppToolCurrent)
+ enable
+
+
+
+
+
+
+
+
diff --git a/src/mono/msbuild/apple/tests/Apple.Build.Tests/AppleHelixR2RTests.cs b/src/mono/msbuild/apple/tests/Apple.Build.Tests/AppleHelixR2RTests.cs
new file mode 100644
index 00000000000000..63f4204a443345
--- /dev/null
+++ b/src/mono/msbuild/apple/tests/Apple.Build.Tests/AppleHelixR2RTests.cs
@@ -0,0 +1,475 @@
+// 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.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Runtime.Versioning;
+using System.Security.Cryptography;
+using System.Text;
+using Xunit;
+using Xunit.Abstractions;
+using Xunit.Sdk;
+
+namespace Apple.Build.Tests;
+
+// Covers the Apple Helix ReadyToRun proxy build. The test drives the real
+// src/mono/msbuild/apple/data/ProxyProjectForAOTOnHelix.proj - shipped into the test payload as
+// content - with `dotnet msbuild`, and stands in for crossgen2 by planting files where it would write
+// them.
+//
+// A Helix retry re-runs the whole work item command in the same directory, so the R2R step has to
+// leave the publish directory - its own crossgen2 input - byte for byte identical, and has to rebuild
+// everything it hands to the app builder from scratch on every attempt.
+//
+// Only MSBuild path handling is under test, so this runs on any ordinary build host: no Apple SDK, no
+// device, and no workload provisioning.
+public class AppleHelixR2RTests
+{
+ private const string ProxyProjectFileName = "ProxyProjectForAOTOnHelix.proj";
+ private const string ProxyPropsFileName = "ProxyProjectForAOTOnHelix.props";
+ private const string BundleStateFileName = "bundle-state.txt";
+ private const string ExtraFileName = "libExtra.dylib";
+
+ private static readonly TimeSpan s_buildTimeout = TimeSpan.FromMinutes(5);
+
+ private static readonly StringComparer s_pathComparer =
+ OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
+
+ // Payload the build machine ships in the work item, which crossgen2 reads and must never rewrite.
+ private static readonly (string RelativePath, string Content)[] s_pristinePayload =
+ {
+ ("AppleTestRunner.dll", "pristine-il:AppleTestRunner"),
+ ("Lib.dll", "pristine-il:Lib"),
+ ("Lib.resources.dll", "pristine-resources:Lib"),
+ ("libnative.dylib", "pristine-native"),
+ ("nested/data.bin", "pristine-nested-data"),
+ };
+
+ private static readonly (string RelativePath, string Content)[] s_firstAttemptR2ROutput =
+ {
+ ("AppleTestRunner.dll", "r2r-attempt1:AppleTestRunner"),
+ ("Lib.dll", "r2r-attempt1:Lib"),
+ ("FirstAttemptOnly.dll", "r2r-attempt1:FirstAttemptOnly"),
+ ("app.r2r.dylib", "r2r-attempt1:composite"),
+ };
+
+ private static readonly (string RelativePath, string Content)[] s_secondAttemptR2ROutput =
+ {
+ ("AppleTestRunner.dll", "r2r-attempt2:AppleTestRunner"),
+ ("Lib.dll", "r2r-attempt2:Lib"),
+ ("app.r2r.dylib", "r2r-attempt2:composite"),
+ };
+
+ private readonly ITestOutputHelper _testOutput;
+
+ public AppleHelixR2RTests(ITestOutputHelper testOutput) => _testOutput = testOutput;
+
+ [Fact]
+ public void R2RAppPayloadLeavesPublishPristineAndIsRebuiltOnRetry()
+ {
+ DirectoryInfo workItemRoot = Directory.CreateTempSubdirectory("apple-helix-r2r-");
+ try
+ {
+ string publishDir = Path.Combine(workItemRoot.FullName, "publish");
+ string extraFilesDir = Path.Combine(workItemRoot.FullName, "extraFiles");
+ string crossgenOutputDir = Path.Combine(workItemRoot.FullName, "crossgen-output");
+ string r2rIntermediateDir = Path.Combine(workItemRoot.FullName, "obj", "R2R");
+ string appPayloadDir = Path.Combine(workItemRoot.FullName, "obj", "r2r-app-payload");
+
+ CreateWorkItemLayout(workItemRoot.FullName, publishDir, extraFilesDir);
+
+ Dictionary publishManifest = Manifest(publishDir);
+
+ RunAttempt(workItemRoot.FullName, publishDir, crossgenOutputDir, r2rIntermediateDir, appPayloadDir, s_firstAttemptR2ROutput);
+ AssertAttempt(workItemRoot.FullName, publishDir, publishManifest, extraFilesDir, appPayloadDir, s_firstAttemptR2ROutput);
+
+ RunAttempt(workItemRoot.FullName, publishDir, crossgenOutputDir, r2rIntermediateDir, appPayloadDir, s_secondAttemptR2ROutput);
+ AssertAttempt(workItemRoot.FullName, publishDir, publishManifest, extraFilesDir, appPayloadDir, s_secondAttemptR2ROutput);
+ }
+ finally
+ {
+ TryDeleteDirectory(workItemRoot.FullName);
+ }
+ }
+
+ private static void CreateWorkItemLayout(string workItemRoot, string publishDir, string extraFilesDir)
+ {
+ foreach ((string relativePath, string content) in s_pristinePayload)
+ WriteFile(Path.Combine(publishDir, ToNativePath(relativePath)), content);
+
+ WriteFile(Path.Combine(extraFilesDir, ExtraFileName), "extra-native");
+
+ string proxyProject = Path.Combine(AppContext.BaseDirectory, "data", ProxyProjectFileName);
+ Assert.True(File.Exists(proxyProject), $"Expected the proxy project to be copied into the test payload at '{proxyProject}'.");
+ File.Copy(proxyProject, Path.Combine(publishDir, ProxyProjectFileName));
+
+ WriteFile(Path.Combine(publishDir, ProxyPropsFileName), BuildProxyPropsStub());
+
+ // The work item layout is created under the system temp directory, so shield the proxy
+ // project from any Directory.Build.props/targets that might sit above it.
+ WriteFile(Path.Combine(workItemRoot, "Directory.Build.props"), "");
+ WriteFile(Path.Combine(workItemRoot, "Directory.Build.targets"), "");
+
+ // The SDK resolver walks up from the working directory looking for a global.json, which is a
+ // separate lookup from the Directory.Build one above and is not covered by it. A stray
+ // global.json anywhere above the temp directory pins the child to an SDK version the resolved
+ // host does not have, and the child then fails before MSBuild even starts. Wasm.Build.Tests
+ // shields its own child builds the same way.
+ WriteFile(Path.Combine(workItemRoot, "global.json"), "{}");
+ }
+
+ // The props file the proxy project imports. On Helix it carries the settings of the test project
+ // being proxied; here it stands in for crossgen2 and records what the R2R target handed to the
+ // Apple app builder.
+ private static string BuildProxyPropsStub()
+ {
+ var targetFramework = new FrameworkName(AppContext.TargetFrameworkName!);
+ return $"""
+
+
+ net{targetFramework.Version.Major}.{targetFramework.Version.Minor}
+ <_TestCrossgenOutputDir>$([MSBuild]::NormalizeDirectory($(TestRootDir), '..', 'crossgen-output'))
+ <_TestBundleStateFile>$([MSBuild]::NormalizePath($(TestRootDir), '..', '{BundleStateFileName}'))
+
+
+
+
+ <_TestCrossgenOutput Include="$(_TestCrossgenOutputDir)*" />
+
+
+
+
+
+
+
+ <_TestBundleState Include="AppleBuildDir=$(AppleBuildDir)" />
+ <_TestBundleState Include="@(AppleAssembliesToBundle->'Assembly=%(FullPath)')" />
+ <_TestBundleState Include="@(AppleNativeFilesToBundle->'Native=%(FullPath)')" />
+
+
+
+
+
+ """;
+ }
+
+ private void RunAttempt(
+ string workItemRoot,
+ string publishDir,
+ string crossgenOutputDir,
+ string r2rIntermediateDir,
+ string appPayloadDir,
+ (string RelativePath, string Content)[] r2rOutput)
+ {
+ ReplaceDirectoryContent(crossgenOutputDir, r2rOutput);
+
+ // A previous attempt that was killed mid write leaves output behind in both directories.
+ // Neither leftover may reach the app: the R2R intermediate dir is wiped before compiling,
+ // the app payload dir is recreated before the payload is staged into it.
+ WriteFile(Path.Combine(r2rIntermediateDir, "KilledAttempt.dll"), "leftover-r2r-output");
+ WriteFile(Path.Combine(appPayloadDir, "stale-marker.txt"), "leftover-payload-file");
+ WriteFile(Path.Combine(appPayloadDir, "bin-previous", "leftover.txt"), "leftover-payload-tree");
+ WriteFile(Path.Combine(appPayloadDir, "app.r2r.dylib"), "leftover-composite");
+
+ string bundleStateFile = Path.Combine(workItemRoot, BundleStateFileName);
+ File.Delete(bundleStateFile);
+
+ RunMSBuild(workItemRoot, publishDir);
+
+ Assert.True(File.Exists(bundleStateFile), $"The proxy build did not record the bundle state, so '{ProxyPropsFileName}' was not imported.");
+ }
+
+ private void RunMSBuild(string workItemRoot, string publishDir)
+ {
+ string host = ResolveDotNetHost();
+ var startInfo = new ProcessStartInfo(host)
+ {
+ WorkingDirectory = publishDir,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+
+ startInfo.ArgumentList.Add("msbuild");
+ startInfo.ArgumentList.Add(ProxyProjectFileName);
+ startInfo.ArgumentList.Add("-t:_PrepareForAppleBuildAppOnHelix;_PrepareR2RItemsOnHelix;_AddR2RFilesToAppleBundle");
+ startInfo.ArgumentList.Add("-p:PublishReadyToRunContainerFormat=macho");
+ startInfo.ArgumentList.Add("-nodeReuse:false");
+ startInfo.ArgumentList.Add("-nologo");
+ startInfo.ArgumentList.Add("-v:minimal");
+ startInfo.Environment["HELIX_WORKITEM_ROOT"] = workItemRoot;
+ startInfo.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1";
+ // The repo build sets this, and it makes the child pick up the wrong SDK.
+ startInfo.Environment.Remove("MSBuildSDKsPath");
+
+ // The repo build environment points these at its own dotnet, and the child has to use the one
+ // that was just resolved.
+ string hostDir = Path.GetDirectoryName(host)!;
+ startInfo.Environment["DOTNET_ROOT"] = hostDir;
+ startInfo.Environment["DOTNET_INSTALL_DIR"] = hostDir;
+ // An empty PATH entry means the working directory on unix, so do not leave a trailing
+ // separator behind when the parent has no PATH.
+ string? path = Environment.GetEnvironmentVariable("PATH");
+ startInfo.Environment["PATH"] = string.IsNullOrEmpty(path)
+ ? hostDir
+ : $"{hostDir}{Path.PathSeparator}{path}";
+
+ var output = new StringBuilder();
+ using var process = new Process { StartInfo = startInfo };
+ process.OutputDataReceived += (_, e) => AppendLine(output, e.Data);
+ process.ErrorDataReceived += (_, e) => AppendLine(output, e.Data);
+
+ process.Start();
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ if (!process.WaitForExit((int)s_buildTimeout.TotalMilliseconds))
+ {
+ process.Kill(entireProcessTree: true);
+ process.WaitForExit();
+ throw new XunitException($"The proxy project build did not finish within {s_buildTimeout}.{Environment.NewLine}{output}");
+ }
+
+ // Flushes the redirected streams.
+ process.WaitForExit();
+
+ if (process.ExitCode != 0)
+ throw new XunitException($"The proxy project build failed with exit code {process.ExitCode}.{Environment.NewLine}{output}");
+
+ _testOutput.WriteLine(output.ToString());
+ }
+
+ private void AssertAttempt(
+ string workItemRoot,
+ string publishDir,
+ Dictionary expectedPublishManifest,
+ string extraFilesDir,
+ string appPayloadDir,
+ (string RelativePath, string Content)[] r2rOutput)
+ {
+ AssertManifest("publish directory", expectedPublishManifest, Manifest(publishDir));
+
+ Dictionary expectedPayload = new(expectedPublishManifest, s_pathComparer);
+ foreach ((string relativePath, string content) in r2rOutput)
+ expectedPayload[relativePath] = Hash(Encoding.UTF8.GetBytes(content));
+
+ AssertManifest("app payload directory", expectedPayload, Manifest(appPayloadDir));
+
+ (string appleBuildDir, List assemblies, List nativeFiles) = ReadBundleState(workItemRoot);
+
+ Assert.Equal(NormalizeDirectory(appPayloadDir), NormalizeDirectory(appleBuildDir), s_pathComparer);
+
+ IEnumerable topLevelAssemblies = expectedPayload.Keys
+ .Where(relativePath => IsTopLevel(relativePath)
+ && relativePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
+ && !relativePath.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase));
+ AssertPathSet("AppleAssembliesToBundle", topLevelAssemblies.Select(relativePath => Path.Combine(appPayloadDir, ToNativePath(relativePath))), assemblies);
+
+ IEnumerable payloadNativeFiles = expectedPayload.Keys
+ .Where(relativePath => !IsTopLevel(relativePath) || !relativePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
+ .Select(relativePath => Path.Combine(appPayloadDir, ToNativePath(relativePath)));
+ AssertPathSet("AppleNativeFilesToBundle", payloadNativeFiles.Append(Path.Combine(extraFilesDir, ExtraFileName)), nativeFiles);
+ }
+
+ private static (string AppleBuildDir, List Assemblies, List NativeFiles) ReadBundleState(string workItemRoot)
+ {
+ string? appleBuildDir = null;
+ List assemblies = new();
+ List nativeFiles = new();
+
+ foreach (string line in File.ReadAllLines(Path.Combine(workItemRoot, BundleStateFileName)))
+ {
+ string[] parts = line.Split('=', 2);
+ switch (parts)
+ {
+ case ["AppleBuildDir", string value]:
+ appleBuildDir = value;
+ break;
+ case ["Assembly", string value]:
+ assemblies.Add(value);
+ break;
+ case ["Native", string value]:
+ nativeFiles.Add(value);
+ break;
+ default:
+ throw new XunitException($"Unexpected line in {BundleStateFileName}: '{line}'.");
+ }
+ }
+
+ if (appleBuildDir is null)
+ throw new XunitException($"{BundleStateFileName} did not record AppleBuildDir.");
+
+ return (appleBuildDir, assemblies, nativeFiles);
+ }
+
+ private static void AssertManifest(string what, Dictionary expected, Dictionary actual)
+ {
+ List differences = new();
+
+ foreach ((string relativePath, string hash) in expected.OrderBy(entry => entry.Key, s_pathComparer))
+ {
+ if (!actual.TryGetValue(relativePath, out string? actualHash))
+ differences.Add($" missing: {relativePath}");
+ else if (actualHash != hash)
+ differences.Add($" content changed: {relativePath}");
+ }
+
+ foreach (string relativePath in actual.Keys.Where(key => !expected.ContainsKey(key)).OrderBy(key => key, s_pathComparer))
+ differences.Add($" unexpected: {relativePath}");
+
+ if (differences.Count > 0)
+ throw new XunitException($"Unexpected content in the {what}:{Environment.NewLine}{string.Join(Environment.NewLine, differences)}");
+ }
+
+ private static void AssertPathSet(string what, IEnumerable expected, IEnumerable actual)
+ {
+ List expectedPaths = expected.Select(Path.GetFullPath).Order(s_pathComparer).ToList();
+ List actualPaths = actual.Select(Path.GetFullPath).Order(s_pathComparer).ToList();
+
+ if (!expectedPaths.SequenceEqual(actualPaths, s_pathComparer))
+ {
+ throw new XunitException(
+ $"Unexpected {what}:{Environment.NewLine}expected:{Environment.NewLine} {string.Join($"{Environment.NewLine} ", expectedPaths)}" +
+ $"{Environment.NewLine}actual:{Environment.NewLine} {string.Join($"{Environment.NewLine} ", actualPaths)}");
+ }
+ }
+
+ private static Dictionary Manifest(string root)
+ {
+ Dictionary manifest = new(s_pathComparer);
+ foreach (string file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
+ manifest[ToRelativePath(root, file)] = Hash(File.ReadAllBytes(file));
+
+ return manifest;
+ }
+
+ // The proxy project is an SDK project, so the child needs a full SDK and not just a runtime host.
+ // Everything is resolved to an absolute path here because the child runs with a different working
+ // directory, and nothing is looked up relative to the current one for the same reason.
+ private static string ResolveDotNetHost()
+ {
+ string fileName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet";
+ List candidates = new();
+
+ // The SDK sets this for everything it launches, so under `dotnet build`/`dotnet test` it is
+ // already the muxer that is driving this build.
+ AddHost(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH"));
+
+ // The xunit console runner is started as `dotnet exec ...`, so the current process is a muxer.
+ AddHost(Environment.ProcessPath);
+
+ // Set by the repo build scripts.
+ AddHostIn(Environment.GetEnvironmentVariable("DOTNET_ROOT"));
+
+ // The dotnet the repo provisions, found by walking up from the test assembly rather than from
+ // the working directory, which the test host owns.
+ AddHostIn(FindRepoDotNetDirectory(fileName));
+
+ foreach (string candidate in candidates)
+ {
+ if (HasSdk(candidate))
+ return candidate;
+ }
+
+ throw new XunitException(
+ $"Could not find a dotnet host with an SDK. Tried:{Environment.NewLine} {string.Join($"{Environment.NewLine} ", candidates)}");
+
+ void AddHost(string? host)
+ {
+ if (!string.IsNullOrEmpty(host) && Path.GetFileName(host).Equals(fileName, StringComparison.OrdinalIgnoreCase))
+ candidates.Add(Path.GetFullPath(host));
+ }
+
+ void AddHostIn(string? directory)
+ {
+ if (!string.IsNullOrEmpty(directory))
+ candidates.Add(Path.GetFullPath(Path.Combine(directory, fileName)));
+ }
+ }
+
+ private static string? FindRepoDotNetDirectory(string fileName)
+ {
+ for (DirectoryInfo? directory = new(AppContext.BaseDirectory); directory is not null; directory = directory.Parent)
+ {
+ string candidate = Path.Combine(directory.FullName, ".dotnet");
+ if (File.Exists(Path.Combine(candidate, fileName)))
+ return candidate;
+ }
+
+ return null;
+ }
+
+ // A host that only ships a runtime cannot restore or build the proxy project, so keep looking.
+ private static bool HasSdk(string host)
+ {
+ if (!File.Exists(host))
+ return false;
+
+ string sdkDir = Path.Combine(Path.GetDirectoryName(host)!, "sdk");
+ return Directory.Exists(sdkDir) && Directory.EnumerateDirectories(sdkDir).Any();
+ }
+
+ private static void ReplaceDirectoryContent(string directory, (string RelativePath, string Content)[] files)
+ {
+ DeleteDirectory(directory);
+ foreach ((string relativePath, string content) in files)
+ WriteFile(Path.Combine(directory, ToNativePath(relativePath)), content);
+ }
+
+ private static void WriteFile(string path, string content)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, content);
+ }
+
+ // Used to set the test up, so a directory that cannot be emptied has to fail the test instead of
+ // leaving content from the previous attempt in place and asserting against the wrong inputs.
+ private static void DeleteDirectory(string path)
+ {
+ try
+ {
+ Directory.Delete(path, recursive: true);
+ }
+ catch (DirectoryNotFoundException)
+ {
+ }
+ }
+
+ // Cleanup only: the work item root lives under the system temp directory, and failing to remove it
+ // must not turn a passing test into a failing one.
+ private static void TryDeleteDirectory(string path)
+ {
+ try
+ {
+ DeleteDirectory(path);
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+
+ private static void AppendLine(StringBuilder output, string? line)
+ {
+ if (line is not null)
+ {
+ lock (output)
+ output.AppendLine(line);
+ }
+ }
+
+ private static bool IsTopLevel(string relativePath) => !relativePath.Contains('/');
+
+ private static string ToNativePath(string relativePath) => relativePath.Replace('/', Path.DirectorySeparatorChar);
+
+ private static string ToRelativePath(string root, string path) => Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/');
+
+ private static string NormalizeDirectory(string path) => Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));
+
+ private static string Hash(byte[] content) => Convert.ToHexString(SHA256.HashData(content));
+}
diff --git a/src/mono/msbuild/apple/tests/Directory.Build.props b/src/mono/msbuild/apple/tests/Directory.Build.props
new file mode 100644
index 00000000000000..0d9929a957ea51
--- /dev/null
+++ b/src/mono/msbuild/apple/tests/Directory.Build.props
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/mono/msbuild/apple/tests/Directory.Build.targets b/src/mono/msbuild/apple/tests/Directory.Build.targets
new file mode 100644
index 00000000000000..b3e6fe3fa3a6b3
--- /dev/null
+++ b/src/mono/msbuild/apple/tests/Directory.Build.targets
@@ -0,0 +1,5 @@
+
+
+
+