|
// 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;
using System.Text.Json.Nodes;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Tests.Projects;
public class ProjectUpdaterTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task UpdateProjectFileAsync_DoesAttemptToUpdateIfNoUpdatesRequired()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var srcFolder = workspace.CreateDirectory("src");
var serviceDefaultsFolder = workspace.CreateDirectory("UpdateTester.ServiceDefaults");
var serviceDefaultsProjectFile = new FileInfo(Path.Combine(serviceDefaultsFolder.FullName, "UpdateTester.ServiceDefaults.csproj"));
var webAppFolder = workspace.CreateDirectory("UpdateTester.WebApp");
var webAppProjectFile = new FileInfo(Path.Combine(webAppFolder.FullName, "UpdateTester.WebApp.csproj"));
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1-preview.1" />
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.4.1", Source = "nuget.org" },
"Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.4.1", Source = "nuget.org" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.4.1", Source = "nuget.org" },
"Aspire.StackExchange.Redis.OutputCaching" => new NuGetPackageCli { Id = "Aspire.StackExchange.Redis.OutputCaching", Version = "9.4.1", Source = "nuget.org" },
"Microsoft.Extensions.ServiceDiscovery" => new NuGetPackageCli { Id = "Microsoft.Extensions.ServiceDiscovery", Version = "9.4.1", Source = "nuget.org" },
_ => throw new InvalidOperationException("Unexpected package query."),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
if (projectFile.FullName == appHostProjectFile.FullName)
{
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
itemsAndProperties.WithProjectReference(webAppProjectFile.FullName);
}
else if (projectFile.FullName == webAppProjectFile.FullName)
{
itemsAndProperties.WithPackageReference("Aspire.StackExchange.Redis.OutputCaching", "9.4.1");
itemsAndProperties.WithProjectReference(serviceDefaultsProjectFile.FullName);
}
else if (projectFile.FullName == serviceDefaultsProjectFile.FullName)
{
itemsAndProperties.WithPackageReference("Microsoft.ServiceDiscovery.Extensions", "9.4.1");
}
else
{
throw new InvalidOperationException("Unexpected project file.");
}
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestConsoleInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
throw new InvalidOperationException("Should not prompt when no work required.");
};
return interactionService;
};
});
var provider = services.BuildServiceProvider();
// Services we need for project updater.
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
// If this throws then it means that the updater prompted
// for confirmation to do an update when no update was required!
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
Assert.False(updateResult.UpdatedApplied);
}
[Fact]
public async Task UpdateProjectFileAsync_CanUpdateFromStableToDaily()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var srcFolder = workspace.CreateDirectory("src");
var serviceDefaultsFolder = workspace.CreateDirectory("UpdateTester.ServiceDefaults");
var serviceDefaultsProjectFile = new FileInfo(Path.Combine(serviceDefaultsFolder.FullName, "UpdateTester.ServiceDefaults.csproj"));
var webAppFolder = workspace.CreateDirectory("UpdateTester.WebApp");
var webAppProjectFile = new FileInfo(Path.Combine(webAppFolder.FullName, "UpdateTester.WebApp.csproj"));
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
</Project>
""");
var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0-preview.1", Source = "daily" },
"Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0-preview.1", Source = "daily" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0-preview.1", Source = "daily" },
"Aspire.StackExchange.Redis.OutputCaching" => new NuGetPackageCli { Id = "Aspire.StackExchange.Redis.OutputCaching", Version = "9.5.0-preview.1", Source = "daily" },
"Microsoft.Extensions.ServiceDiscovery" => new NuGetPackageCli { Id = "Microsoft.Extensions.ServiceDiscovery", Version = "9.5.0-preview.1", Source = "daily" },
_ => throw new InvalidOperationException("Unexpected package query."),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
if (projectFile.FullName == appHostProjectFile.FullName)
{
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
itemsAndProperties.WithProjectReference(webAppProjectFile.FullName);
}
else if (projectFile.FullName == webAppProjectFile.FullName)
{
itemsAndProperties.WithPackageReference("Aspire.StackExchange.Redis.OutputCaching", "9.4.1");
itemsAndProperties.WithProjectReference(serviceDefaultsProjectFile.FullName);
}
else if (projectFile.FullName == serviceDefaultsProjectFile.FullName)
{
itemsAndProperties.WithPackageReference("Microsoft.Extensions.ServiceDiscovery", "9.4.1");
}
else
{
throw new InvalidOperationException("Unexpected project file.");
}
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
},
// FileInfo, string, string, string?, DotNetCliRunnerInvocationOptions, CancellationToken, int
AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, _, _) =>
{
packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!));
return 0;
}
};
};
config.InteractionServiceFactory = (s) =>
{
var interactionService = new TestConsoleInteractionService();
return interactionService;
};
});
var provider = services.BuildServiceProvider();
// Services we need for project updater.
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "daily");
// If this throws then it means that the updater prompted
// for confirmation to do an update when no update was required!
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
Assert.True(updateResult.UpdatedApplied);
Assert.Collection(
packagesAddsExecuted,
item =>
{
Assert.Equal("Aspire.Hosting.AppHost", item.PackageId);
Assert.Equal("9.5.0-preview.1", item.PackageVersion);
Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
},
item =>
{
Assert.Equal("Aspire.Hosting.Redis", item.PackageId);
Assert.Equal("9.5.0-preview.1", item.PackageVersion);
Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
},
item =>
{
Assert.Equal("Aspire.StackExchange.Redis.OutputCaching", item.PackageId);
Assert.Equal("9.5.0-preview.1", item.PackageVersion);
Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
Assert.Equal(webAppProjectFile.FullName, item.ProjectFile.FullName);
},
item =>
{
Assert.Equal("Microsoft.Extensions.ServiceDiscovery", item.PackageId);
Assert.Equal("9.5.0-preview.1", item.PackageVersion);
Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
Assert.Equal(serviceDefaultsProjectFile.FullName, item.ProjectFile.FullName);
}
);
}
[Fact]
public async Task UpdateProjectFileAsync_CanUpdateFromDailyToStableWhereOnePackageIsUnstableOnly()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var srcFolder = workspace.CreateDirectory("src");
var serviceDefaultsFolder = workspace.CreateDirectory("UpdateTester.ServiceDefaults");
var serviceDefaultsProjectFile = new FileInfo(Path.Combine(serviceDefaultsFolder.FullName, "UpdateTester.ServiceDefaults.csproj"));
var webAppFolder = workspace.CreateDirectory("UpdateTester.WebApp");
var webAppProjectFile = new FileInfo(Path.Combine(webAppFolder.FullName, "UpdateTester.WebApp.csproj"));
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
</Project>
""");
var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, prerelease, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
var matchedPackage = (query, prerelease) switch
{
{ query: "Aspire.AppHost.Sdk", prerelease: false } => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.4.1", Source = "nuget" },
{ query: "Aspire.Hosting.AppHost", prerelease: false } => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.4.1", Source = "nuget" },
{ query: "Aspire.Hosting.Redis", prerelease: false } => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.4.1", Source = "nuget" },
{ query: "Aspire.Hosting.Docker", prerelease: true } => new NuGetPackageCli { Id = "Aspire.Hosting.Docker", Version = "9.4.1-preview.1", Source = "nuget" },
{ query: "Aspire.Hosting.Docker", prerelease: false } => null, // Not in feed.
{ query: "Aspire.StackExchange.Redis.OutputCaching", prerelease: false } => new NuGetPackageCli { Id = "Aspire.StackExchange.Redis.OutputCaching", Version = "9.4.1", Source = "nuget" },
{ query: "Microsoft.Extensions.ServiceDiscovery", prerelease: false } => new NuGetPackageCli { Id = "Microsoft.Extensions.ServiceDiscovery", Version = "9.4.1", Source = "nuget" },
_ => throw new InvalidOperationException("Unexpected package query."),
};
if (matchedPackage != null)
{
packages.Add(matchedPackage);
}
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
if (projectFile.FullName == appHostProjectFile.FullName)
{
itemsAndProperties.WithSdkVersion("9.5.1-preview.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.5.1-preview.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.5.1-preview.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.Docker", "9.5.1-preview.1");
itemsAndProperties.WithProjectReference(webAppProjectFile.FullName);
}
else if (projectFile.FullName == webAppProjectFile.FullName)
{
itemsAndProperties.WithPackageReference("Aspire.StackExchange.Redis.OutputCaching", "9.5.1-preview.1");
itemsAndProperties.WithProjectReference(serviceDefaultsProjectFile.FullName);
}
else if (projectFile.FullName == serviceDefaultsProjectFile.FullName)
{
itemsAndProperties.WithPackageReference("Microsoft.Extensions.ServiceDiscovery", "9.5.1-preview.1");
}
else
{
throw new InvalidOperationException("Unexpected project file.");
}
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
},
// FileInfo, string, string, string?, DotNetCliRunnerInvocationOptions, CancellationToken, int
AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, _, _) =>
{
packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!));
return 0;
}
};
};
config.InteractionServiceFactory = (s) =>
{
var interactionService = new TestConsoleInteractionService();
return interactionService;
};
});
var provider = services.BuildServiceProvider();
// Services we need for project updater.
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "stable");
// If this throws then it means that the updater prompted
// for confirmation to do an update when no update was required!
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
Assert.True(updateResult.UpdatedApplied);
Assert.Collection(
packagesAddsExecuted,
item =>
{
Assert.Equal("Aspire.Hosting.AppHost", item.PackageId);
Assert.Equal("9.4.1", item.PackageVersion);
Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
},
item =>
{
Assert.Equal("Aspire.Hosting.Redis", item.PackageId);
Assert.Equal("9.4.1", item.PackageVersion);
Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
},
item =>
{
Assert.Equal("Aspire.Hosting.Docker", item.PackageId);
Assert.Equal("9.4.1-preview.1", item.PackageVersion);
Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
},
item =>
{
Assert.Equal("Aspire.StackExchange.Redis.OutputCaching", item.PackageId);
Assert.Equal("9.4.1", item.PackageVersion);
Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
Assert.Equal(webAppProjectFile.FullName, item.ProjectFile.FullName);
},
item =>
{
Assert.Equal("Microsoft.Extensions.ServiceDiscovery", item.PackageId);
Assert.Equal("9.4.1", item.PackageVersion);
Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
Assert.Equal(serviceDefaultsProjectFile.FullName, item.ProjectFile.FullName);
}
);
}
[Fact]
public async Task UpdateProjectFileAsync_DiamondDependency_DoesNotDuplicateUpdates()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
// Create diamond dependency scenario:
// AppHost -> ProjectA, ProjectB
// ProjectA -> SharedProject
// ProjectB -> SharedProject
// SharedProject has updatable package that should only appear once in update steps
var sharedProjectFolder = workspace.CreateDirectory("SharedProject");
var sharedProjectFile = new FileInfo(Path.Combine(sharedProjectFolder.FullName, "SharedProject.csproj"));
var projectAFolder = workspace.CreateDirectory("ProjectA");
var projectAFile = new FileInfo(Path.Combine(projectAFolder.FullName, "ProjectA.csproj"));
var projectBFolder = workspace.CreateDirectory("ProjectB");
var projectBFile = new FileInfo(Path.Combine(projectBFolder.FullName, "ProjectB.csproj"));
var appHostFolder = workspace.CreateDirectory("DiamondTest.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "DiamondTest.AppHost.csproj"));
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
</Project>
""");
var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
"Microsoft.Extensions.ServiceDiscovery" => new NuGetPackageCli { Id = "Microsoft.Extensions.ServiceDiscovery", Version = "9.5.0", Source = "nuget.org" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
if (projectFile.FullName == appHostProjectFile.FullName)
{
// AppHost references both ProjectA and ProjectB
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
itemsAndProperties.WithProjectReference(projectAFile.FullName);
itemsAndProperties.WithProjectReference(projectBFile.FullName);
}
else if (projectFile.FullName == projectAFile.FullName)
{
// ProjectA references SharedProject - needs empty package reference array
itemsAndProperties.WithPackageReference("DummyPackage", "1.0.0"); // Add dummy first to create structure
itemsAndProperties["Items"]!["PackageReference"]!.AsArray().Clear(); // Then clear it
itemsAndProperties.WithProjectReference(sharedProjectFile.FullName);
}
else if (projectFile.FullName == projectBFile.FullName)
{
// ProjectB also references SharedProject (creating the diamond) - needs empty package reference array
itemsAndProperties.WithPackageReference("DummyPackage", "1.0.0"); // Add dummy first to create structure
itemsAndProperties["Items"]!["PackageReference"]!.AsArray().Clear(); // Then clear it
itemsAndProperties.WithProjectReference(sharedProjectFile.FullName);
}
else if (projectFile.FullName == sharedProjectFile.FullName)
{
// SharedProject has an updatable package
itemsAndProperties.WithPackageReference("Microsoft.Extensions.ServiceDiscovery", "9.4.1");
}
else
{
throw new InvalidOperationException($"Unexpected project file: {projectFile.FullName}");
}
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
},
AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, _, _) =>
{
packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!));
return 0;
}
};
};
config.InteractionServiceFactory = (s) =>
{
var interactionService = new TestConsoleInteractionService();
return interactionService;
};
});
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
Assert.True(updateResult.UpdatedApplied);
// Verify that the SharedProject's package update only appears once, not twice
var sharedProjectUpdates = packagesAddsExecuted.Where(p => p.ProjectFile.FullName == sharedProjectFile.FullName).ToList();
Assert.Single(sharedProjectUpdates);
var sharedProjectUpdate = sharedProjectUpdates.Single();
Assert.Equal("Microsoft.Extensions.ServiceDiscovery", sharedProjectUpdate.PackageId);
Assert.Equal("9.5.0", sharedProjectUpdate.PackageVersion);
// Should also have the AppHost package update
var appHostUpdates = packagesAddsExecuted.Where(p => p.ProjectFile.FullName == appHostProjectFile.FullName).ToList();
Assert.Single(appHostUpdates);
Assert.Equal("Aspire.Hosting.AppHost", appHostUpdates.Single().PackageId);
}
[Fact]
public async Task UpdateProjectFileAsync_CentralPackageManagement_UpdatesDirectoryPackagesProps()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var serviceDefaultsFolder = workspace.CreateDirectory("UpdateTester.ServiceDefaults");
var serviceDefaultsProjectFile = new FileInfo(Path.Combine(serviceDefaultsFolder.FullName, "UpdateTester.ServiceDefaults.csproj"));
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
// Create AppHost project file
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
</Project>
""");
// Create Service Defaults project file without Version attributes (CPM)
await File.WriteAllTextAsync(
serviceDefaultsProjectFile.FullName,
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.StackExchange.Redis.OutputCaching" />
</ItemGroup>
</Project>
""");
// Create Directory.Packages.props with outdated versions
await File.WriteAllTextAsync(
directoryPackagesPropsFile.FullName,
$$"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.StackExchange.Redis.OutputCaching" Version="9.4.1" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.1" />
</ItemGroup>
</Project>
""");
var updatedFiles = new List<string>();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
"Aspire.StackExchange.Redis.OutputCaching" => new NuGetPackageCli { Id = "Aspire.StackExchange.Redis.OutputCaching", Version = "9.5.0", Source = "nuget.org" },
"Microsoft.Extensions.ServiceDiscovery" => new NuGetPackageCli { Id = "Microsoft.Extensions.ServiceDiscovery", Version = "9.5.0", Source = "nuget.org" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
{
var itemsAndProperties = new JsonObject();
if (projectFile.FullName == appHostProjectFile.FullName)
{
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithProjectReference(serviceDefaultsProjectFile.FullName);
}
else if (projectFile.FullName == serviceDefaultsProjectFile.FullName)
{
// For CPM projects, PackageReference elements don't have Version attributes
itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.StackExchange.Redis.OutputCaching");
}
else
{
throw new InvalidOperationException("Unexpected project file.");
}
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestConsoleInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
Assert.True(updateResult.UpdatedApplied);
// Verify Directory.Packages.props was updated
var updatedContent = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
Assert.Contains("Aspire.StackExchange.Redis.OutputCaching\" Version=\"9.5.0\"", updatedContent); // Redis package should be updated
Assert.DoesNotContain("Aspire.StackExchange.Redis.OutputCaching\" Version=\"9.4.1\"", updatedContent); // Redis package should not contain old version
// Microsoft.Extensions.ServiceDiscovery should remain unchanged since it's not referenced in any project file
Assert.Contains("Microsoft.Extensions.ServiceDiscovery\" Version=\"9.4.1\"", updatedContent);
}
[Fact]
public async Task UpdateProjectFileAsync_CentralPackageManagement_DetectedByDirectoryPackagesProps()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
// Create AppHost project file without ManagePackageVersionsCentrally property
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Redis" />
</ItemGroup>
</Project>
""");
// Create Directory.Packages.props (presence should be detected as CPM)
await File.WriteAllTextAsync(
directoryPackagesPropsFile.FullName,
$$"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.Redis" Version="9.4.1" />
</ItemGroup>
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.Hosting.Redis");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestConsoleInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
Assert.True(updateResult.UpdatedApplied);
// Verify Directory.Packages.props was updated
var updatedContent = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
Assert.Contains("Version=\"9.5.0\"", updatedContent);
}
[Fact]
public async Task UpdateProjectFileAsync_CentralPackageManagement_PackageNotInDirectoryPackagesProps()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
// Create AppHost project file
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Redis" />
</ItemGroup>
</Project>
""");
// Create Directory.Packages.props without the package (should be skipped)
await File.WriteAllTextAsync(
directoryPackagesPropsFile.FullName,
$$"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Package not included here -->
</ItemGroup>
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("9.5.0"); // Already up to date
itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.Hosting.Redis");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestConsoleInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
// Should not be called since no updates are needed
throw new InvalidOperationException("Should not prompt when no work required.");
};
return interactionService;
};
});
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
Assert.False(updateResult.UpdatedApplied);
// Verify Directory.Packages.props was not modified
var content = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
Assert.DoesNotContain("Aspire.Hosting.Redis", content);
}
private static Aspire.Cli.CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory)
{
// NOTE: This would normally be in the users home directory, but for tests we create
// it in the temporary workspace directory.
var settingsDirectory = workingDirectory.CreateSubdirectory(".aspire");
var hivesDirectory = settingsDirectory.CreateSubdirectory("hives");
return new CliExecutionContext(workingDirectory, hivesDirectory);
}
[Fact]
public void PackageUpdateStep_GetFormattedDisplayText_ReturnsFormattedString()
{
// Arrange
var projectFile = new FileInfo("/path/to/MyProject.csproj");
var packageStep = new PackageUpdateStep(
"Update package Aspire.Hosting.Redis from 9.0.0 to 9.1.0",
() => Task.CompletedTask,
"Aspire.Hosting.Redis",
"9.0.0",
"9.1.0",
projectFile);
// Act
var formattedText = packageStep.GetFormattedDisplayText();
// Assert
Assert.Equal("[bold yellow]Aspire.Hosting.Redis[/] [bold green]9.0.0[/] to [bold green]9.1.0[/]", formattedText);
}
[Fact]
public async Task UpdateProjectFileAsync_CentralPackageManagement_ResolvesAspireVersionProperty()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
// Create AppHost project file
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Redis" />
</ItemGroup>
</Project>
""");
// Create Directory.Packages.props with MSBuild property expression
await File.WriteAllTextAsync(
directoryPackagesPropsFile.FullName,
$$"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AspireVersion>9.4.1</AspireVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.Redis" Version="$(AspireVersion)" />
</ItemGroup>
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
{
var itemsAndProperties = new JsonObject();
// For SDK version queries
if (properties.Contains("AspireHostingSDKVersion"))
{
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.Hosting.Redis");
}
// For property resolution queries
if (properties.Contains("AspireVersion"))
{
itemsAndProperties.WithProperty("AspireVersion", "9.4.1");
}
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestConsoleInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
Assert.True(updateResult.UpdatedApplied);
// Verify Directory.Packages.props was updated with new version
var content = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
Assert.Contains("<PackageVersion Include=\"Aspire.Hosting.Redis\" Version=\"9.5.0\" />", content);
}
[Fact]
public async Task UpdateProjectFileAsync_CentralPackageManagement_ResolvesMultipleProperties()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
// Create AppHost project file
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Redis" />
<PackageReference Include="Aspire.StackExchange.Redis" />
</ItemGroup>
</Project>
""");
// Create Directory.Packages.props with multiple MSBuild property expressions
await File.WriteAllTextAsync(
directoryPackagesPropsFile.FullName,
$$"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AspireVersion>9.4.1</AspireVersion>
<AspireUnstableVersion>9.4.1-preview.1</AspireUnstableVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.Redis" Version="$(AspireVersion)" />
<PackageVersion Include="Aspire.StackExchange.Redis" Version="$(AspireUnstableVersion)" />
</ItemGroup>
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
"Aspire.StackExchange.Redis" => new NuGetPackageCli { Id = "Aspire.StackExchange.Redis", Version = "9.5.0-preview.1", Source = "nuget.org" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
{
var itemsAndProperties = new JsonObject();
// For SDK version queries
if (properties.Contains("AspireHostingSDKVersion"))
{
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.Hosting.Redis");
itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.StackExchange.Redis");
}
// For property resolution queries
if (properties.Contains("AspireVersion"))
{
itemsAndProperties.WithProperty("AspireVersion", "9.4.1");
}
if (properties.Contains("AspireUnstableVersion"))
{
itemsAndProperties.WithProperty("AspireUnstableVersion", "9.4.1-preview.1");
}
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestConsoleInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
Assert.True(updateResult.UpdatedApplied);
// Verify Directory.Packages.props was updated with new versions
var content = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
Assert.Contains("<PackageVersion Include=\"Aspire.Hosting.Redis\" Version=\"9.5.0\" />", content);
Assert.Contains("<PackageVersion Include=\"Aspire.StackExchange.Redis\" Version=\"9.5.0-preview.1\" />", content);
}
[Fact]
public async Task UpdateProjectFileAsync_CentralPackageManagement_PropertyResolutionFailsWithInvalidSemanticVersion()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
// Create AppHost project file
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Redis" />
</ItemGroup>
</Project>
""");
// Create Directory.Packages.props with MSBuild property expression that resolves to an invalid version
await File.WriteAllTextAsync(
directoryPackagesPropsFile.FullName,
$$"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<InvalidVersionProperty>not-a-version</InvalidVersionProperty>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.Redis" Version="$(InvalidVersionProperty)" />
</ItemGroup>
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
{
var itemsAndProperties = new JsonObject();
// For SDK version queries
if (properties.Contains("AspireHostingSDKVersion"))
{
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.Hosting.Redis");
}
// For property resolution queries - return invalid semantic version
if (properties.Contains("InvalidVersionProperty"))
{
itemsAndProperties.WithProperty("InvalidVersionProperty", "not-a-version");
}
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestConsoleInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
// This should throw a ProjectUpdaterException
var exception = await Assert.ThrowsAsync<ProjectUpdaterException>(async () =>
{
await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
});
Assert.Contains("Unable to resolve MSBuild property 'InvalidVersionProperty' to a valid semantic version", exception.Message);
}
[Fact]
public async Task UpdateProjectFileAsync_CentralPackageManagement_PropertyResolutionFailsWithUnresolvableProperty()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
// Create AppHost project file
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Redis" />
</ItemGroup>
</Project>
""");
// Create Directory.Packages.props with MSBuild property expression that cannot be resolved
await File.WriteAllTextAsync(
directoryPackagesPropsFile.FullName,
$$"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.Redis" Version="$(NonExistentProperty)" />
</ItemGroup>
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
{
var itemsAndProperties = new JsonObject();
// For SDK version queries
if (properties.Contains("AspireHostingSDKVersion"))
{
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.Hosting.Redis");
}
// For property resolution queries - don't include the property, simulating it doesn't exist
// This will result in the property not being in the response, which should be handled gracefully
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestConsoleInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
// This should throw a ProjectUpdaterException
var exception = await Assert.ThrowsAsync<ProjectUpdaterException>(async () =>
{
await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
});
Assert.Contains("Unable to resolve MSBuild property 'NonExistentProperty' to a valid semantic version", exception.Message);
Assert.Contains("Resolved value: 'null'", exception.Message);
}
[Fact]
public async Task UpdateProject_FallbackMode_WhenSdkUnresolvable()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
// Create AppHost project file with an unresolvable SDK version
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="99.0.0-unresolvable" />
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="99.0.0-unresolvable" />
<PackageReference Include="Aspire.Hosting.Redis" Version="99.0.0-unresolvable" />
</ItemGroup>
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
if (projectFile.FullName == appHostProjectFile.FullName)
{
// Simulate MSBuild failure due to unresolvable SDK
return (1, null);
}
else
{
throw new InvalidOperationException("Unexpected project file.");
}
},
AddPackageAsyncCallback = (projectFile, packageName, version, source, options, cancellationToken) =>
{
// Simulate successful package addition
return 0;
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestConsoleInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var fallbackParser = provider.GetRequiredService<FallbackProjectParser>();
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
// Should not throw ProjectUpdaterException; should produce update steps including AppHost SDK
Assert.True(updateResult.UpdatedApplied);
}
[Fact]
public async Task FallbackMode_PackageReferenceWithoutVersion_CPM()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
// Create AppHost project file with package reference without version (CPM)
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="99.0.0-unresolvable" />
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Redis" />
</ItemGroup>
</Project>
""");
// Create Directory.Packages.props
await File.WriteAllTextAsync(
directoryPackagesPropsFile.FullName,
"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.Redis" Version="9.4.1" />
</ItemGroup>
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
if (projectFile.FullName == appHostProjectFile.FullName)
{
// Simulate MSBuild failure due to unresolvable SDK
return (1, null);
}
else
{
throw new InvalidOperationException("Unexpected project file.");
}
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestConsoleInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var fallbackParser = provider.GetRequiredService<FallbackProjectParser>();
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
// Should discover package reference (version may be absent) and not crash
Assert.True(updateResult.UpdatedApplied);
}
[Fact]
public async Task FallbackMode_InvalidXml_StillFails()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
// Create malformed AppHost project file
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="99.0.0-unresolvable" />
<!-- Missing closing tag to make invalid XML -->
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="99.0.0-unresolvable" />
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
if (projectFile.FullName == appHostProjectFile.FullName)
{
// Simulate MSBuild failure due to unresolvable SDK
return (1, null);
}
else
{
throw new InvalidOperationException("Unexpected project file.");
}
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestConsoleInteractionService();
return interactionService;
};
});
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var fallbackParser = provider.GetRequiredService<FallbackProjectParser>();
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
// Should throw ProjectUpdaterException due to invalid XML
await Assert.ThrowsAsync<ProjectUpdaterException>(() =>
projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout));
}
[Fact]
public async Task NormalMode_NoFallback()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
packages.Add(query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.4.1", Source = "nuget.org" },
_ => throw new InvalidOperationException("Unexpected package query."),
});
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
// Normal successful MSBuild evaluation
var itemsAndProperties = new JsonObject();
if (projectFile.FullName == appHostProjectFile.FullName)
{
itemsAndProperties.WithSdkVersion("9.4.1");
}
else
{
throw new InvalidOperationException("Unexpected project file.");
}
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestConsoleInteractionService();
return interactionService;
};
});
var provider = services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
var runner = provider.GetRequiredService<IDotNetCliRunner>();
var interactionService = provider.GetRequiredService<IInteractionService>();
var cache = provider.GetRequiredService<IMemoryCache>();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var fallbackParser = provider.GetRequiredService<FallbackProjectParser>();
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
// Normal path unaffected - no updates needed since version is already current
Assert.False(updateResult.UpdatedApplied);
}
}
internal static class MSBuildJsonDocumentExtensions
{
public static JsonObject WithSdkVersion(this JsonObject root, string sdkVersion)
{
root.WithMSBuildOutput(); // Ensure Items structure exists
JsonObject properties = new JsonObject();
if (!root.TryAdd("Properties", properties))
{
properties = root["Properties"]!.AsObject();
}
properties.Add("AspireHostingSDKVersion", JsonValue.Create<string>(sdkVersion));
return root;
}
public static JsonObject WithMSBuildOutput(this JsonObject root)
{
JsonObject items = new JsonObject();
items.Add("ProjectReference", new JsonArray());
items.Add("PackageReference", new JsonArray());
if (!root.TryAdd("Items", items))
{
items = root["Items"]!.AsObject();
// Ensure both arrays exist
if (!items.ContainsKey("ProjectReference"))
{
items.Add("ProjectReference", new JsonArray());
}
if (!items.ContainsKey("PackageReference"))
{
items.Add("PackageReference", new JsonArray());
}
}
return root;
}
public static JsonObject WithPackageReference(this JsonObject root, string packageId, string packageVersion)
{
root.WithMSBuildOutput();
var items = root["Items"]!.AsObject();
var packageReferences = items["PackageReference"]!.AsArray();
JsonObject newPackageReference = new JsonObject
{
{ "Identity", JsonValue.Create<string>(packageId) },
{ "Version", JsonValue.Create<string>(packageVersion) }
};
packageReferences.Add(newPackageReference);
return root;
}
public static JsonObject WithPackageReferenceWithoutVersion(this JsonObject root, string packageId)
{
root.WithMSBuildOutput();
var items = root["Items"]!.AsObject();
var packageReferences = items["PackageReference"]!.AsArray();
JsonObject newPackageReference = new JsonObject
{
{ "Identity", JsonValue.Create<string>(packageId) }
// No Version property for CPM
};
packageReferences.Add(newPackageReference);
return root;
}
public static JsonObject WithProjectReference(this JsonObject root, string fullPath)
{
root.WithMSBuildOutput();
var items = root["Items"]!.AsObject();
var projectReferences = items["ProjectReference"]!.AsArray();
JsonObject newProjectReference = new JsonObject
{
{ "FullPath", JsonValue.Create<string>(fullPath) }
};
projectReferences.Add(newProjectReference);
return root;
}
public static JsonObject WithProperty(this JsonObject root, string propertyName, string propertyValue)
{
JsonObject properties = new JsonObject();
if (!root.TryAdd("Properties", properties))
{
properties = root["Properties"]!.AsObject();
}
properties.Add(propertyName, JsonValue.Create<string>(propertyValue));
return root;
}
} |