From 27520c1f4a8ce7bc98ee30bfc67fdb4bd6bd8e52 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Fri, 24 Apr 2026 14:03:24 -0700 Subject: [PATCH] Migrate direct Newtonsoft.Json usage to System.Text.Json Replace production CLI and sdk-tasks Newtonsoft.Json usage with System.Text.Json APIs, including JsonNode-based DOM handling and source-generated serializer contexts for AOT/trimming-safe serialization. Enable AOT compatibility for sdk-tasks, remove direct Newtonsoft.Json package references from migrated projects, and add coverage for JSON mutation helpers, installer serialization, host data, aliases, and TemplateEngine CLI parsing parity. Co-authored-by: Copilot --- .../TemplateSearch/CliHostSearchCacheData.cs | 5 + .../ProjectCapabilityConstraint.cs | 11 +- .../Installer/Windows/InstallMessageBase.cs | 17 +-- .../Windows/InstallRequestMessage.cs | 5 +- .../Windows/InstallResponseMessage.cs | 5 +- .../Windows/InstallerJsonSerializerContext.cs | 12 ++ .../Installer/Windows/MsiPackageCache.cs | 6 +- .../dotnet/Installer/Windows/MsiPayload.cs | 4 +- .../Installer/Windows/RelatedProduct.cs | 11 +- .../ToolPackage/ToolPackageDownloaderBase.cs | 11 +- src/Cli/dotnet/dotnet.csproj | 1 - src/Tasks/sdk-tasks/GetRuntimePackRids.cs | 6 +- .../ProcessRuntimeAnalyzerVersions.cs | 8 +- .../sdk-tasks/PublishMutationUtilities.cs | 51 ++++---- .../sdk-tasks/RemoveAssetFromDepsPackages.cs | 40 +++---- src/Tasks/sdk-tasks/UpdateRuntimeConfig.cs | 17 +-- src/Tasks/sdk-tasks/sdk-tasks.csproj | 2 +- .../JExtensionsTests.cs | 23 ++++ ...rosoft.TemplateEngine.Cli.UnitTests.csproj | 2 +- .../TemplateSearchCoordinatorTests.cs | 9 ++ test/dotnet.Tests/WindowsInstallerTests.cs | 16 +++ .../PublishMutationUtilitiesTests.cs | 70 +++++++++++ .../RemoveAssetFromDepsPackagesTests.cs | 69 +++++++++++ .../UpdateRuntimeConfigTests.cs | 113 ++++++++++++++++++ 24 files changed, 404 insertions(+), 110 deletions(-) create mode 100644 src/Cli/dotnet/Installer/Windows/InstallerJsonSerializerContext.cs create mode 100644 test/Microsoft.TemplateEngine.Cli.UnitTests/JExtensionsTests.cs create mode 100644 test/sdk-tasks.Tests/PublishMutationUtilitiesTests.cs create mode 100644 test/sdk-tasks.Tests/RemoveAssetFromDepsPackagesTests.cs create mode 100644 test/sdk-tasks.Tests/UpdateRuntimeConfigTests.cs diff --git a/src/Cli/Microsoft.TemplateEngine.Cli/TemplateSearch/CliHostSearchCacheData.cs b/src/Cli/Microsoft.TemplateEngine.Cli/TemplateSearch/CliHostSearchCacheData.cs index 3df2d3d5ee9c..3f64d9abdcdf 100644 --- a/src/Cli/Microsoft.TemplateEngine.Cli/TemplateSearch/CliHostSearchCacheData.cs +++ b/src/Cli/Microsoft.TemplateEngine.Cli/TemplateSearch/CliHostSearchCacheData.cs @@ -20,6 +20,11 @@ public static class CliHostSearchCacheData } try { + if (cacheObject.Count == 0) + { + return HostSpecificTemplateData.Default; + } + var keys = new HashSet(cacheObject.Select(p => p.Key), StringComparer.OrdinalIgnoreCase); if (_hostDataPropertyNames.Any(keys.Contains)) { diff --git a/src/Cli/dotnet/Commands/New/MSBuildEvaluation/ProjectCapabilityConstraint.cs b/src/Cli/dotnet/Commands/New/MSBuildEvaluation/ProjectCapabilityConstraint.cs index 594f8e892215..685b00df9370 100644 --- a/src/Cli/dotnet/Commands/New/MSBuildEvaluation/ProjectCapabilityConstraint.cs +++ b/src/Cli/dotnet/Commands/New/MSBuildEvaluation/ProjectCapabilityConstraint.cs @@ -6,7 +6,8 @@ using Microsoft.TemplateEngine.Abstractions; using Microsoft.TemplateEngine.Abstractions.Constraints; using Microsoft.TemplateEngine.Cli.Commands; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; using MSBuildProject = Microsoft.Build.Evaluation.Project; namespace Microsoft.DotNet.Cli.Commands.New.MSBuildEvaluation; @@ -69,10 +70,10 @@ public TemplateConstraintResult Evaluate(string? args) } _logger.LogDebug("Configuration: '{0}'", args); - JToken? token; + JsonNode? token; try { - token = JToken.Parse(args!); + token = JsonNode.Parse(args!); } catch (Exception e) { @@ -82,9 +83,9 @@ public TemplateConstraintResult Evaluate(string? args) string configuredCapabiltiesExpression; - if (token.Type == JTokenType.String) + if (token is JsonValue v && v.GetValueKind() == JsonValueKind.String) { - string? configuredCapability = token.Value(); + string? configuredCapability = token.GetValue(); if (string.IsNullOrWhiteSpace(configuredCapability)) { _logger.LogDebug("Invalid configuration: '{0}', reason: arguments should not contain empty values.", args); diff --git a/src/Cli/dotnet/Installer/Windows/InstallMessageBase.cs b/src/Cli/dotnet/Installer/Windows/InstallMessageBase.cs index 18e8284fd23d..4bc97c9a762b 100644 --- a/src/Cli/dotnet/Installer/Windows/InstallMessageBase.cs +++ b/src/Cli/dotnet/Installer/Windows/InstallMessageBase.cs @@ -3,7 +3,7 @@ #nullable disable -using Newtonsoft.Json; +using System.Text.Json; namespace Microsoft.DotNet.Cli.Installer.Windows; @@ -12,18 +12,13 @@ namespace Microsoft.DotNet.Cli.Installer.Windows; /// internal abstract class InstallMessageBase { - /// - /// Default serialization settings for a message. - /// - protected static JsonSerializerSettings DefaultSerializerSettings; - /// /// Serializes the message to a JSON string. /// /// The serialized message. public override string ToString() { - return JsonConvert.SerializeObject(this, DefaultSerializerSettings); + return JsonSerializer.Serialize(this, GetType(), InstallerJsonSerializerContext.Default); } /// @@ -34,12 +29,4 @@ public byte[] ToByteArray() { return Encoding.UTF8.GetBytes(ToString()); } - - static InstallMessageBase() - { - DefaultSerializerSettings = new JsonSerializerSettings() - { - NullValueHandling = NullValueHandling.Ignore - }; - } } diff --git a/src/Cli/dotnet/Installer/Windows/InstallRequestMessage.cs b/src/Cli/dotnet/Installer/Windows/InstallRequestMessage.cs index e06511adff84..aac92ec98ee8 100644 --- a/src/Cli/dotnet/Installer/Windows/InstallRequestMessage.cs +++ b/src/Cli/dotnet/Installer/Windows/InstallRequestMessage.cs @@ -3,7 +3,7 @@ #nullable disable -using Newtonsoft.Json; +using System.Text.Json; namespace Microsoft.DotNet.Cli.Installer.Windows; @@ -154,6 +154,7 @@ public string WorkloadSetVersion public static InstallRequestMessage Create(byte[] bytes) { string json = Encoding.UTF8.GetString(bytes); - return JsonConvert.DeserializeObject(json, DefaultSerializerSettings); + return JsonSerializer.Deserialize(json, InstallerJsonSerializerContext.Default.InstallRequestMessage) + ?? throw new JsonException("The install request message payload deserialized to null."); } } diff --git a/src/Cli/dotnet/Installer/Windows/InstallResponseMessage.cs b/src/Cli/dotnet/Installer/Windows/InstallResponseMessage.cs index 9038a6f27d02..7482430c2e52 100644 --- a/src/Cli/dotnet/Installer/Windows/InstallResponseMessage.cs +++ b/src/Cli/dotnet/Installer/Windows/InstallResponseMessage.cs @@ -3,7 +3,7 @@ #nullable disable -using Newtonsoft.Json; +using System.Text.Json; using static Microsoft.Win32.Msi.Error; namespace Microsoft.DotNet.Cli.Installer.Windows; @@ -60,7 +60,8 @@ public static InstallResponseMessage Create(byte[] bytes) { string json = Encoding.UTF8.GetString(bytes); - return JsonConvert.DeserializeObject(json, DefaultSerializerSettings); + return JsonSerializer.Deserialize(json, InstallerJsonSerializerContext.Default.InstallResponseMessage) + ?? throw new JsonException("The install response message payload deserialized to null."); } public static InstallResponseMessage Create(Exception e) diff --git a/src/Cli/dotnet/Installer/Windows/InstallerJsonSerializerContext.cs b/src/Cli/dotnet/Installer/Windows/InstallerJsonSerializerContext.cs new file mode 100644 index 000000000000..b22562a9de6c --- /dev/null +++ b/src/Cli/dotnet/Installer/Windows/InstallerJsonSerializerContext.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Cli.Installer.Windows; + +[JsonSerializable(typeof(InstallRequestMessage))] +[JsonSerializable(typeof(InstallResponseMessage))] +[JsonSerializable(typeof(MsiManifest))] +[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal partial class InstallerJsonSerializerContext : JsonSerializerContext; diff --git a/src/Cli/dotnet/Installer/Windows/MsiPackageCache.cs b/src/Cli/dotnet/Installer/Windows/MsiPackageCache.cs index 98f1204b8987..75aa83d93cb8 100644 --- a/src/Cli/dotnet/Installer/Windows/MsiPackageCache.cs +++ b/src/Cli/dotnet/Installer/Windows/MsiPackageCache.cs @@ -6,7 +6,7 @@ using Microsoft.DotNet.Cli.Commands.Workload; using Microsoft.DotNet.Cli.Installer.Windows.Security; -using Newtonsoft.Json; +using System.Text.Json; namespace Microsoft.DotNet.Cli.Installer.Windows; @@ -73,7 +73,7 @@ public void CachePayload(string packageId, string packageVersion, string manifes // We cannot assume that the MSI adjacent to the manifest is the one to cache. We'll trust // the manifest to provide the MSI filename. - MsiManifest? msiManifest = JsonConvert.DeserializeObject(File.ReadAllText(manifestPath)); + MsiManifest? msiManifest = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), InstallerJsonSerializerContext.Default.MsiManifest); // Only use the filename+extension of the payload property in case the manifest has been altered. string msiPath = Path.Combine(Path.GetDirectoryName(manifestPath)!, Path.GetFileName(msiManifest?.Payload ?? string.Empty)); @@ -146,7 +146,7 @@ public bool TryGetMsiPathFromPackageData(string packageDataPath, [NotNullWhen(tr // The msi.json manifest contains the name of the actual MSI. The filename does not necessarily match the package // ID as it may have been shortened to support VS caching. - MsiManifest? msiManifest = JsonConvert.DeserializeObject(File.ReadAllText(manifestPath)); + MsiManifest? msiManifest = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), InstallerJsonSerializerContext.Default.MsiManifest); string possibleMsiPath = Path.Combine(Path.GetDirectoryName(manifestPath)!, msiManifest?.Payload ?? string.Empty); if (!File.Exists(possibleMsiPath)) diff --git a/src/Cli/dotnet/Installer/Windows/MsiPayload.cs b/src/Cli/dotnet/Installer/Windows/MsiPayload.cs index f683e143c49a..f25bf1710f97 100644 --- a/src/Cli/dotnet/Installer/Windows/MsiPayload.cs +++ b/src/Cli/dotnet/Installer/Windows/MsiPayload.cs @@ -3,7 +3,7 @@ #nullable disable -using Newtonsoft.Json; +using System.Text.Json; namespace Microsoft.DotNet.Cli.Installer.Windows; @@ -68,7 +68,7 @@ public MsiManifest Manifest { get { - _manifest ??= JsonConvert.DeserializeObject(File.ReadAllText(ManifestPath)); + _manifest ??= JsonSerializer.Deserialize(File.ReadAllText(ManifestPath), InstallerJsonSerializerContext.Default.MsiManifest); return _manifest; } diff --git a/src/Cli/dotnet/Installer/Windows/RelatedProduct.cs b/src/Cli/dotnet/Installer/Windows/RelatedProduct.cs index aa100ce59c0b..9169f9cf70b0 100644 --- a/src/Cli/dotnet/Installer/Windows/RelatedProduct.cs +++ b/src/Cli/dotnet/Installer/Windows/RelatedProduct.cs @@ -3,13 +3,12 @@ #nullable disable -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; +using System.Text.Json.Serialization; namespace Microsoft.DotNet.Cli.Installer.Windows; /// -/// Describes a single row from the MSI Upgrade +/// Describes a single row from the MSI Upgrade /// table. /// internal class RelatedProduct @@ -26,7 +25,7 @@ public UpgradeAttributes Attributes } /// - /// A comma separated list of languages identifiers (LANGID) that can be detected. When empty, all languages can be + /// A comma separated list of languages identifiers (LANGID) that can be detected. When empty, all languages can be /// detected. If msidbUpgradeAttributesLanguagesExclusive is set, the list becomes exclusive. /// public string Language @@ -47,7 +46,7 @@ public IEnumerable Languages } /// - /// The upgrade code of the related product. + /// The upgrade code of the related product. /// public string UpgradeCode { @@ -58,7 +57,6 @@ public string UpgradeCode /// /// Upper boundary of the range of product versions detected by the FindRelatedProducts action. /// - [JsonConverter(typeof(VersionConverter))] public Version VersionMax { get; @@ -68,7 +66,6 @@ public Version VersionMax /// /// The lower boundary of the range of product versions detected by the FindRelatedProducts action. /// - [JsonConverter(typeof(VersionConverter))] public Version VersionMin { get; diff --git a/src/Cli/dotnet/ToolPackage/ToolPackageDownloaderBase.cs b/src/Cli/dotnet/ToolPackage/ToolPackageDownloaderBase.cs index 95bb5c1b900e..b5f0ef3931b4 100644 --- a/src/Cli/dotnet/ToolPackage/ToolPackageDownloaderBase.cs +++ b/src/Cli/dotnet/ToolPackage/ToolPackageDownloaderBase.cs @@ -7,7 +7,8 @@ using Microsoft.DotNet.Cli.NuGetPackageDownloader; using Microsoft.DotNet.Cli.Utils; using Microsoft.Extensions.EnvironmentAbstractions; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; using NuGet.Configuration; using NuGet.Packaging; using NuGet.Packaging.Core; @@ -18,6 +19,8 @@ namespace Microsoft.DotNet.Cli.ToolPackage; internal abstract class ToolPackageDownloaderBase : IToolPackageDownloader { + private static readonly JsonSerializerOptions s_writeIndentedOptions = new() { WriteIndented = true }; + private readonly IToolPackageStore _toolPackageStore; protected readonly IFileSystem _fileSystem; @@ -384,11 +387,11 @@ ToolPackageInstance toolPackageInstance { string existingJson = _fileSystem.File.ReadAllText(runtimeConfigFilePath); - var jsonObject = JObject.Parse(existingJson); - if (jsonObject["runtimeOptions"] is JObject runtimeOptions) + var jsonObject = JsonNode.Parse(existingJson)!.AsObject(); + if (jsonObject["runtimeOptions"] is JsonObject runtimeOptions) { runtimeOptions["rollForward"] = "Major"; - string updateJson = jsonObject.ToString(); + string updateJson = jsonObject.ToJsonString(s_writeIndentedOptions); _fileSystem.File.WriteAllText(runtimeConfigFilePath, updateJson); } } diff --git a/src/Cli/dotnet/dotnet.csproj b/src/Cli/dotnet/dotnet.csproj index a874b4d28697..485762ac26d3 100644 --- a/src/Cli/dotnet/dotnet.csproj +++ b/src/Cli/dotnet/dotnet.csproj @@ -79,7 +79,6 @@ - diff --git a/src/Tasks/sdk-tasks/GetRuntimePackRids.cs b/src/Tasks/sdk-tasks/GetRuntimePackRids.cs index 88e47acc1e33..48be9b347a07 100644 --- a/src/Tasks/sdk-tasks/GetRuntimePackRids.cs +++ b/src/Tasks/sdk-tasks/GetRuntimePackRids.cs @@ -3,7 +3,7 @@ #nullable disable -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace Microsoft.DotNet.Build.Tasks { @@ -19,8 +19,8 @@ public override bool Execute() { string runtimeJsonPath = Path.Combine(MetapackagePath, "runtime.json"); string runtimeJsonContents = File.ReadAllText(runtimeJsonPath); - var runtimeJsonRoot = JObject.Parse(runtimeJsonContents); - string [] runtimeIdentifiers = ((JObject)runtimeJsonRoot["runtimes"]).Properties().Select(p => p.Name).ToArray(); + var runtimeJsonRoot = JsonNode.Parse(runtimeJsonContents)!.AsObject(); + string [] runtimeIdentifiers = runtimeJsonRoot["runtimes"]!.AsObject().Select(p => p.Key).ToArray(); AvailableRuntimePackRuntimeIdentifiers = runtimeIdentifiers.Select(rid => new TaskItem(rid)).ToArray(); return true; diff --git a/src/Tasks/sdk-tasks/ProcessRuntimeAnalyzerVersions.cs b/src/Tasks/sdk-tasks/ProcessRuntimeAnalyzerVersions.cs index fea916fb0b7a..5febe38cde05 100644 --- a/src/Tasks/sdk-tasks/ProcessRuntimeAnalyzerVersions.cs +++ b/src/Tasks/sdk-tasks/ProcessRuntimeAnalyzerVersions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using System.Text.Json.Serialization; namespace Microsoft.DotNet.Build.Tasks; @@ -58,15 +59,18 @@ public override bool Execute() } Directory.CreateDirectory(Path.GetDirectoryName(MetadataFilePath)!); - File.WriteAllText(path: MetadataFilePath!, JsonSerializer.Serialize(metadata)); + File.WriteAllText(path: MetadataFilePath!, JsonSerializer.Serialize(metadata, SdkTasksJsonSerializerContext.Default.DictionaryStringMetadataEntry)); Outputs = Inputs; return true; } - private sealed class MetadataEntry(string version) + internal sealed class MetadataEntry(string version) { public string Version { get; } = version; public List Files { get; } = []; } } + +[JsonSerializable(typeof(Dictionary))] +internal partial class SdkTasksJsonSerializerContext : JsonSerializerContext; diff --git a/src/Tasks/sdk-tasks/PublishMutationUtilities.cs b/src/Tasks/sdk-tasks/PublishMutationUtilities.cs index 65413e2b029c..f0303ff22a20 100644 --- a/src/Tasks/sdk-tasks/PublishMutationUtilities.cs +++ b/src/Tasks/sdk-tasks/PublishMutationUtilities.cs @@ -3,56 +3,47 @@ #nullable disable -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; namespace Microsoft.DotNet.Build.Tasks { public class PublishMutationUtilities { + private static readonly JsonSerializerOptions s_writeOptions = new() { WriteIndented = true }; + public static void ChangeEntryPointLibraryName(string depsFile, string newName) { - JToken deps; - using (var file = File.OpenText(depsFile)) - using (JsonTextReader reader = new(file)) - { - deps = JToken.ReadFrom(reader); - } + var deps = JsonNode.Parse(File.ReadAllText(depsFile)); string version = null; - foreach (JProperty target in deps["targets"]) + foreach (var target in deps["targets"]!.AsObject()) { - var targetLibrary = target.Value.Children().FirstOrDefault(); - if (targetLibrary == null) + var targetObj = target.Value!.AsObject(); + var targetLibrary = targetObj.FirstOrDefault(); + if (targetLibrary.Key == null) { continue; } - version = targetLibrary.Name.Substring(targetLibrary.Name.IndexOf('/') + 1); - if (newName == null) - { - targetLibrary.Remove(); - } - else + version = targetLibrary.Key.Substring(targetLibrary.Key.IndexOf('/') + 1); + var targetLibraryValue = targetLibrary.Value; + targetObj.Remove(targetLibrary.Key); + if (newName != null) { - targetLibrary.Replace(new JProperty(newName + '/' + version, targetLibrary.Value)); + targetObj.Add(newName + '/' + version, targetLibraryValue); } } if (version != null) { - var library = deps["libraries"].Children().First(); - if (newName == null) - { - library.Remove(); - } - else - { - library.Replace(new JProperty(newName + '/' + version, library.Value)); - } - using (var file = File.CreateText(depsFile)) - using (var writer = new JsonTextWriter(file) { Formatting = Formatting.Indented }) + var librariesObj = deps["libraries"]!.AsObject(); + var library = librariesObj.First(); + var libraryValue = library.Value; + librariesObj.Remove(library.Key); + if (newName != null) { - deps.WriteTo(writer); + librariesObj.Add(newName + '/' + version, libraryValue); } + File.WriteAllText(depsFile, deps.ToJsonString(s_writeOptions)); } } } diff --git a/src/Tasks/sdk-tasks/RemoveAssetFromDepsPackages.cs b/src/Tasks/sdk-tasks/RemoveAssetFromDepsPackages.cs index c12539507cd5..d76324706ce3 100644 --- a/src/Tasks/sdk-tasks/RemoveAssetFromDepsPackages.cs +++ b/src/Tasks/sdk-tasks/RemoveAssetFromDepsPackages.cs @@ -3,14 +3,16 @@ #nullable disable +using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Build.Framework; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Microsoft.DotNet.Build.Tasks { public class RemoveAssetFromDepsPackages : Task { + private static readonly JsonSerializerOptions s_writeOptions = new() { WriteIndented = true }; + [Required] public string DepsFile { get; set; } @@ -29,33 +31,25 @@ public override bool Execute() public static void DoRemoveAssetFromDepsPackages(string depsFile, string sectionName, string assetPath) { - JToken deps; - using (var file = File.OpenText(depsFile)) - using (JsonTextReader reader = new(file)) - { - deps = JToken.ReadFrom(reader); - } + var deps = JsonNode.Parse(File.ReadAllText(depsFile)); bool found = false; - foreach (JProperty target in deps["targets"]) + foreach (var target in deps["targets"]!.AsObject()) { - foreach (JProperty pv in target.Value.Children()) + foreach (var pv in target.Value!.AsObject()) { - var section = pv.Value[sectionName]; + var section = pv.Value![sectionName]; if (section != null) { - foreach (JProperty relPath in section) + var sectionObj = section.AsObject(); + if (assetPath.Equals("*")) { - if (assetPath.Equals(relPath.Name)) - { - relPath.Remove(); - found = true; - break; - } + pv.Value.AsObject().Remove(sectionName); + found = true; } - if (assetPath.Equals("*")) + else if (sectionObj.ContainsKey(assetPath)) { - section.Parent.Remove(); + sectionObj.Remove(assetPath); found = true; } } @@ -64,11 +58,7 @@ public static void DoRemoveAssetFromDepsPackages(string depsFile, string section if (found) { - using (var file = File.CreateText(depsFile)) - using (var writer = new JsonTextWriter(file) { Formatting = Formatting.Indented }) - { - deps.WriteTo(writer); - } + File.WriteAllText(depsFile, deps.ToJsonString(s_writeOptions)); } } } diff --git a/src/Tasks/sdk-tasks/UpdateRuntimeConfig.cs b/src/Tasks/sdk-tasks/UpdateRuntimeConfig.cs index 110abf40e799..e3a95fefcbbe 100644 --- a/src/Tasks/sdk-tasks/UpdateRuntimeConfig.cs +++ b/src/Tasks/sdk-tasks/UpdateRuntimeConfig.cs @@ -3,12 +3,15 @@ #nullable disable -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; namespace Microsoft.DotNet.Build.Tasks { public sealed class UpdateRuntimeConfig : Task { + private static readonly JsonSerializerOptions s_writeOptions = new() { WriteIndented = true }; + [Required] public ITaskItem[] RuntimeConfigPaths { get; set; } @@ -31,12 +34,12 @@ public override bool Execute() private void UpdateFile(string file) { var text = File.ReadAllText(file); - JObject config = JObject.Parse(text); + var config = JsonNode.Parse(text)!.AsObject(); var frameworks = config["runtimeOptions"]?["frameworks"]; var framework = config["runtimeOptions"]?["framework"]; if (frameworks != null) { - foreach (var item in frameworks) + foreach (var item in frameworks.AsArray()) { UpdateFramework(item); } @@ -46,13 +49,13 @@ private void UpdateFile(string file) UpdateFramework(framework); } - File.WriteAllText(file, config.ToString()); + File.WriteAllText(file, config.ToJsonString(s_writeOptions)); } - private void UpdateFramework(JToken item) + private void UpdateFramework(JsonNode item) { - var framework = (JObject)item; - var name = framework["name"].Value(); + var framework = item.AsObject(); + var name = framework["name"]!.GetValue(); if (name == "Microsoft.NETCore.App") { framework["version"] = MicrosoftNetCoreAppVersion; diff --git a/src/Tasks/sdk-tasks/sdk-tasks.csproj b/src/Tasks/sdk-tasks/sdk-tasks.csproj index 896d3ea68711..d930f5924648 100644 --- a/src/Tasks/sdk-tasks/sdk-tasks.csproj +++ b/src/Tasks/sdk-tasks/sdk-tasks.csproj @@ -5,6 +5,7 @@ false true true + true @@ -17,7 +18,6 @@ - diff --git a/test/Microsoft.TemplateEngine.Cli.UnitTests/JExtensionsTests.cs b/test/Microsoft.TemplateEngine.Cli.UnitTests/JExtensionsTests.cs new file mode 100644 index 000000000000..9dc32256cfd2 --- /dev/null +++ b/test/Microsoft.TemplateEngine.Cli.UnitTests/JExtensionsTests.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +extern alias TemplateEngineCli; + +using System.Text.Json.Nodes; + +namespace Microsoft.TemplateEngine.Cli.UnitTests; + +public class JExtensionsTests +{ + [Fact] + public void ToInt32ParsesStringPropertyValues() + { + JsonObject json = JsonNode.Parse(""" + { + "precedence": "100" + } + """)!.AsObject(); + + TemplateEngineCli::Microsoft.TemplateEngine.JExtensions.ToInt32(json, "precedence").Should().Be(100); + } +} diff --git a/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj b/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj index 9d1bdf113db8..386ce0a9a8d9 100644 --- a/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj +++ b/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj @@ -24,7 +24,7 @@ - + diff --git a/test/Microsoft.TemplateEngine.Cli.UnitTests/TemplateSearchCoordinatorTests.cs b/test/Microsoft.TemplateEngine.Cli.UnitTests/TemplateSearchCoordinatorTests.cs index 472c628d8a82..9bbb81829cc7 100644 --- a/test/Microsoft.TemplateEngine.Cli.UnitTests/TemplateSearchCoordinatorTests.cs +++ b/test/Microsoft.TemplateEngine.Cli.UnitTests/TemplateSearchCoordinatorTests.cs @@ -16,6 +16,7 @@ using Microsoft.TemplateSearch.Common.Abstractions; using Microsoft.TemplateSearch.Common.Providers; using System.Text.Json; +using System.Text.Json.Nodes; namespace Microsoft.TemplateEngine.Cli.UnitTests { @@ -58,6 +59,14 @@ public class TemplateSearchCoordinatorTests : BaseTest #pragma warning restore SA1311 // Static readonly fields should begin with upper-case letter #pragma warning restore SA1308 // Variable names should not be prefixed + [Fact] + public void CliHostSearchCacheDataReaderReturnsDefaultForEmptyObject() + { + var result = CliHostSearchCacheData.Reader(new JsonObject()); + + Assert.Same(HostSpecificTemplateData.Default, result); + } + [Fact] public async Task CacheSearchNameMatchTest() { diff --git a/test/dotnet.Tests/WindowsInstallerTests.cs b/test/dotnet.Tests/WindowsInstallerTests.cs index 40e41cbe6730..f2332fea82b3 100644 --- a/test/dotnet.Tests/WindowsInstallerTests.cs +++ b/test/dotnet.Tests/WindowsInstallerTests.cs @@ -79,6 +79,22 @@ public void InstallMessageDispatcherProcessesMessages() Assert.Equal("Shutting down!", r2.Message); } + [WindowsOnlyFact] + public void InstallRequestMessageCreateThrowsForNullPayload() + { + Action action = () => InstallRequestMessage.Create(System.Text.Encoding.UTF8.GetBytes("null")); + + action.Should().Throw(); + } + + [WindowsOnlyFact] + public void InstallResponseMessageCreateThrowsForNullPayload() + { + Action action = () => InstallResponseMessage.Create(System.Text.Encoding.UTF8.GetBytes("null")); + + action.Should().Throw(); + } + [WindowsOnlyTheory] [InlineData("1033,1041,1049", UpgradeAttributes.MigrateFeatures, 1041, false)] [InlineData(null, UpgradeAttributes.LanguagesExclusive, 3082, false)] diff --git a/test/sdk-tasks.Tests/PublishMutationUtilitiesTests.cs b/test/sdk-tasks.Tests/PublishMutationUtilitiesTests.cs new file mode 100644 index 000000000000..32873b870767 --- /dev/null +++ b/test/sdk-tasks.Tests/PublishMutationUtilitiesTests.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Build.Tasks; + +namespace Microsoft.CoreSdkTasks.Tests; + +public class PublishMutationUtilitiesTests(ITestOutputHelper log) : SdkTest(log) +{ + private const string SampleDepsJson = """ + { + "targets": { + ".NETCoreApp,Version=v9.0": { + "OldApp/1.0.0": { + "runtime": { + "OldApp.dll": {} + } + } + } + }, + "libraries": { + "OldApp/1.0.0": { + "type": "project" + } + } + } + """; + + [Fact] + public void ItRenamesEntryPointLibrary() + { + var dir = TestAssetsManager.CreateTestDirectory().Path; + var depsPath = Path.Combine(dir, "rename.deps.json"); + File.WriteAllText(depsPath, SampleDepsJson); + + PublishMutationUtilities.ChangeEntryPointLibraryName(depsPath, "NewApp"); + + var result = File.ReadAllText(depsPath); + result.Should().Contain("NewApp/1.0.0"); + result.Should().NotContain("OldApp/1.0.0"); + } + + [Fact] + public void ItRemovesEntryPointLibraryWhenNewNameIsNull() + { + var dir = TestAssetsManager.CreateTestDirectory().Path; + var depsPath = Path.Combine(dir, "remove.deps.json"); + File.WriteAllText(depsPath, SampleDepsJson); + + PublishMutationUtilities.ChangeEntryPointLibraryName(depsPath, null); + + var result = File.ReadAllText(depsPath); + result.Should().NotContain("OldApp/1.0.0"); + result.Should().NotContain("NewApp"); + } + + [Fact] + public void ItPreservesVersionInRenamedLibrary() + { + var dir = TestAssetsManager.CreateTestDirectory().Path; + var depsPath = Path.Combine(dir, "version.deps.json"); + File.WriteAllText(depsPath, SampleDepsJson); + + PublishMutationUtilities.ChangeEntryPointLibraryName(depsPath, "RenamedApp"); + + var result = File.ReadAllText(depsPath); + result.Should().Contain("RenamedApp/1.0.0"); + result.Should().Contain("\"type\": \"project\""); + } +} diff --git a/test/sdk-tasks.Tests/RemoveAssetFromDepsPackagesTests.cs b/test/sdk-tasks.Tests/RemoveAssetFromDepsPackagesTests.cs new file mode 100644 index 000000000000..53a06de2d122 --- /dev/null +++ b/test/sdk-tasks.Tests/RemoveAssetFromDepsPackagesTests.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Build.Tasks; + +namespace Microsoft.CoreSdkTasks.Tests; + +public class RemoveAssetFromDepsPackagesTests(ITestOutputHelper log) : SdkTest(log) +{ + private const string SampleDepsJson = """ + { + "targets": { + ".NETCoreApp,Version=v9.0": { + "MyApp/1.0.0": { + "runtime": { + "MyApp.dll": {}, + "Helper.dll": {} + }, + "resources": { + "en/MyApp.resources.dll": {} + } + } + } + } + } + """; + + [Fact] + public void ItRemovesSpecificAssetFromDeps() + { + var dir = TestAssetsManager.CreateTestDirectory().Path; + var depsPath = Path.Combine(dir, "specific.deps.json"); + File.WriteAllText(depsPath, SampleDepsJson); + + RemoveAssetFromDepsPackages.DoRemoveAssetFromDepsPackages(depsPath, "runtime", "Helper.dll"); + + var result = File.ReadAllText(depsPath); + result.Should().NotContain("Helper.dll"); + result.Should().Contain("MyApp.dll"); + } + + [Fact] + public void ItRemovesWildcardSection() + { + var dir = TestAssetsManager.CreateTestDirectory().Path; + var depsPath = Path.Combine(dir, "wildcard.deps.json"); + File.WriteAllText(depsPath, SampleDepsJson); + + RemoveAssetFromDepsPackages.DoRemoveAssetFromDepsPackages(depsPath, "resources", "*"); + + var result = File.ReadAllText(depsPath); + result.Should().NotContain("resources"); + result.Should().Contain("runtime"); + } + + [Fact] + public void ItDoesNotModifyFileWhenAssetNotFound() + { + var dir = TestAssetsManager.CreateTestDirectory().Path; + var depsPath = Path.Combine(dir, "noop.deps.json"); + File.WriteAllText(depsPath, SampleDepsJson); + var originalContent = File.ReadAllText(depsPath); + + RemoveAssetFromDepsPackages.DoRemoveAssetFromDepsPackages(depsPath, "runtime", "NonExistent.dll"); + + // File should not be rewritten when nothing was found + File.ReadAllText(depsPath).Should().Be(originalContent); + } +} diff --git a/test/sdk-tasks.Tests/UpdateRuntimeConfigTests.cs b/test/sdk-tasks.Tests/UpdateRuntimeConfigTests.cs new file mode 100644 index 000000000000..4155544cc288 --- /dev/null +++ b/test/sdk-tasks.Tests/UpdateRuntimeConfigTests.cs @@ -0,0 +1,113 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Utilities; +using Microsoft.DotNet.Build.Tasks; + +namespace Microsoft.CoreSdkTasks.Tests; + +public class UpdateRuntimeConfigTests(ITestOutputHelper log) : SdkTest(log) +{ + [Fact] + public void ItUpdatesSingleFrameworkVersion() + { + var dir = TestAssetsManager.CreateTestDirectory().Path; + var configPath = Path.Combine(dir, "single.runtimeconfig.json"); + File.WriteAllText(configPath, """ + { + "runtimeOptions": { + "framework": { + "name": "Microsoft.NETCore.App", + "version": "1.0.0" + } + } + } + """); + + var task = new UpdateRuntimeConfig + { + BuildEngine = new MockBuildEngine(), + RuntimeConfigPaths = [new TaskItem(configPath)], + MicrosoftNetCoreAppVersion = "9.0.0", + MicrosoftAspNetCoreAppVersion = "9.0.0" + }; + + task.Execute().Should().BeTrue(); + + var result = File.ReadAllText(configPath); + result.Should().Contain("\"version\": \"9.0.0\""); + result.Should().Contain("\"name\": \"Microsoft.NETCore.App\""); + } + + [Fact] + public void ItUpdatesMultipleFrameworkVersions() + { + var dir = TestAssetsManager.CreateTestDirectory().Path; + var configPath = Path.Combine(dir, "multi.runtimeconfig.json"); + File.WriteAllText(configPath, """ + { + "runtimeOptions": { + "frameworks": [ + { + "name": "Microsoft.NETCore.App", + "version": "1.0.0" + }, + { + "name": "Microsoft.AspNetCore.App", + "version": "1.0.0" + } + ] + } + } + """); + + var task = new UpdateRuntimeConfig + { + BuildEngine = new MockBuildEngine(), + RuntimeConfigPaths = [new TaskItem(configPath)], + MicrosoftNetCoreAppVersion = "9.0.0", + MicrosoftAspNetCoreAppVersion = "8.0.0" + }; + + task.Execute().Should().BeTrue(); + + var result = File.ReadAllText(configPath); + result.Should().Contain("\"Microsoft.NETCore.App\""); + result.Should().Contain("\"Microsoft.AspNetCore.App\""); + // Verify the versions were actually updated by checking the output contains both new versions + result.Should().Contain("\"9.0.0\""); + result.Should().Contain("\"8.0.0\""); + result.Should().NotContain("\"1.0.0\""); + } + + [Fact] + public void ItPreservesUnknownFrameworks() + { + var dir = TestAssetsManager.CreateTestDirectory().Path; + var configPath = Path.Combine(dir, "unknown.runtimeconfig.json"); + File.WriteAllText(configPath, """ + { + "runtimeOptions": { + "framework": { + "name": "Microsoft.WindowsDesktop.App", + "version": "1.0.0" + } + } + } + """); + + var task = new UpdateRuntimeConfig + { + BuildEngine = new MockBuildEngine(), + RuntimeConfigPaths = [new TaskItem(configPath)], + MicrosoftNetCoreAppVersion = "9.0.0", + MicrosoftAspNetCoreAppVersion = "9.0.0" + }; + + task.Execute().Should().BeTrue(); + + var result = File.ReadAllText(configPath); + // Unknown framework should retain original version + result.Should().Contain("\"version\": \"1.0.0\""); + } +}