// 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;
using Microsoft.Extensions.Logging.Testing;
using Microsoft.AspNetCore.InternalTesting;
namespace Aspire.Cli.Tests.Projects;
public class ProjectUpdaterTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task UpdateProjectFileAsync_DoesAttemptToUpdateIfNoUpdatesRequired()
{
using var workspace = TemporaryWorkspace.CreateForCli(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 TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
throw new InvalidOperationException("Should not prompt when no work required.");
};
return interactionService;
};
});
using 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().DefaultTimeout();
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(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
Assert.False(updateResult.UpdatedApplied);
}
[Fact]
public async Task UpdateProjectFileAsync_CanUpdateFromStableToDaily()
{
using var workspace = TemporaryWorkspace.CreateForCli(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>
""");
// Pre-existing aspire.config.json pinned to a (stale) channel — the updater should
// rewrite the channel to match the resolved explicit channel after the update completes.
// See https://github.com/microsoft/aspire/issues/17295.
var aspireConfigFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"));
await File.WriteAllTextAsync(
aspireConfigFile.FullName,
"""
{
"appHost": { "path": "UpdateTester.AppHost/UpdateTester.AppHost.csproj" },
"channel": "stable",
"sdk": { "version": "9.4.1" }
}
""");
var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource, bool NoRestore)>();
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?, ProcessInvocationOptions, CancellationToken, int
AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) =>
{
packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!, noRestore));
return 0;
}
};
};
config.InteractionServiceFactory = (s) =>
{
var interactionService = new TestInteractionService();
return interactionService;
};
});
using 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().DefaultTimeout();
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(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
// Note: Aspire.Hosting.AppHost is not updated because it's removed during SDK migration
Assert.Collection(
packagesAddsExecuted,
item =>
{
Assert.Equal("Aspire.Hosting.Redis", item.PackageId);
Assert.Equal("9.5.0-preview.1", item.PackageVersion);
// PackageSource is null because ProjectUpdater passes nugetSource: null to AddPackageAsync.
Assert.Null(item.PackageSource);
// Per-package 'dotnet package add' must use --no-restore so the deferred final restore
// sees a fully-updated reference graph (regression guard for https://github.com/dotnet/aspire/issues/15891).
Assert.True(item.NoRestore);
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);
// PackageSource is null because ProjectUpdater passes nugetSource: null to AddPackageAsync.
Assert.Null(item.PackageSource);
// Per-package 'dotnet package add' must use --no-restore so the deferred final restore
// sees a fully-updated reference graph (regression guard for https://github.com/dotnet/aspire/issues/15891).
Assert.True(item.NoRestore);
Assert.Equal(webAppProjectFile.FullName, item.ProjectFile.FullName);
}
);
// Channel pin in aspire.config.json should have been rewritten from the pre-existing
// "stable" value to the resolved explicit channel ("daily") that we just updated to.
// Regression guard for https://github.com/microsoft/aspire/issues/17295.
var updatedConfig = await File.ReadAllTextAsync(aspireConfigFile.FullName);
using var configDoc = JsonDocument.Parse(updatedConfig);
Assert.Equal("daily", configDoc.RootElement.GetProperty("channel").GetString());
Assert.Equal("9.5.0-preview.1", configDoc.RootElement.GetProperty("sdk").GetProperty("version").GetString());
}
[Fact]
public async Task UpdateProjectFileAsync_DoesNotCreateAspireConfigJsonWhenAbsent()
{
// Sibling of UpdateProjectFileAsync_CanUpdateFromStableToDaily covering the case where
// the AppHost project predates aspire.config.json (legacy split layouts, pre-init projects).
// `aspire update` must not fabricate a fresh aspire.config.json — that's `aspire init`'s job.
using var workspace = TemporaryWorkspace.CreateForCli(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>
""");
// Intentionally do NOT pre-create aspire.config.json. The post-update assertion below
// verifies the file is still absent.
var aspireConfigFile = new FileInfo(Path.Combine(appHostFolder.FullName, "aspire.config.json"));
Assert.False(aspireConfigFile.Exists, "Pre-condition: aspire.config.json should not exist before the update.");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>
{
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);
},
AddPackageAsyncCallback = (_, _, _, _, _, _, _) => 0,
};
};
config.InteractionServiceFactory = (s) => new TestInteractionService();
});
using var provider = services.BuildServiceProvider();
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "daily");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
// Post-condition: the updater MUST NOT have created aspire.config.json. Creating one
// would silently introduce config the user never asked for and contradict the comment
// in ProjectUpdater.GetUpdateStepsAsync claiming the rewrite is skipped when the file
// is absent.
aspireConfigFile.Refresh();
Assert.False(aspireConfigFile.Exists, "aspire update must not create aspire.config.json for projects that never had one.");
}
[Fact]
public async Task UpdateProjectFileAsync_CanUpdateFromDailyToStableWhereOnePackageIsUnstableOnly()
{
using var workspace = TemporaryWorkspace.CreateForCli(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 aspireConfigFile = new FileInfo(Path.Combine(appHostFolder.FullName, "aspire.config.json"));
await File.WriteAllTextAsync(
aspireConfigFile.FullName,
"""
{
"appHost": { "path": "UpdateTester.AppHost.csproj" },
"channel": "daily"
}
""");
var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource, bool NoRestore)>();
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?, ProcessInvocationOptions, CancellationToken, int
AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) =>
{
packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!, noRestore));
return 0;
}
};
};
config.InteractionServiceFactory = (s) =>
{
var interactionService = new TestInteractionService();
return interactionService;
};
});
using 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().DefaultTimeout();
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(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
// Note: Aspire.Hosting.AppHost is not updated because it's removed during SDK migration
Assert.Collection(
packagesAddsExecuted,
item =>
{
Assert.Equal("Aspire.Hosting.Redis", item.PackageId);
Assert.Equal("9.4.1", item.PackageVersion);
// PackageSource is null because ProjectUpdater passes nugetSource: null to AddPackageAsync.
Assert.Null(item.PackageSource);
// Per-package 'dotnet package add' must use --no-restore so the deferred final restore
// sees a fully-updated reference graph (regression guard for https://github.com/dotnet/aspire/issues/15891).
Assert.True(item.NoRestore);
Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
},
item =>
{
Assert.Equal("Aspire.Hosting.Docker", item.PackageId);
Assert.Equal("9.4.1-preview.1", item.PackageVersion);
// PackageSource is null because ProjectUpdater passes nugetSource: null to AddPackageAsync.
Assert.Null(item.PackageSource);
// Per-package 'dotnet package add' must use --no-restore so the deferred final restore
// sees a fully-updated reference graph (regression guard for https://github.com/dotnet/aspire/issues/15891).
Assert.True(item.NoRestore);
Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
},
item =>
{
Assert.Equal("Aspire.StackExchange.Redis.OutputCaching", item.PackageId);
Assert.Equal("9.4.1", item.PackageVersion);
// PackageSource is null because ProjectUpdater passes nugetSource: null to AddPackageAsync.
Assert.Null(item.PackageSource);
// Per-package 'dotnet package add' must use --no-restore so the deferred final restore
// sees a fully-updated reference graph (regression guard for https://github.com/dotnet/aspire/issues/15891).
Assert.True(item.NoRestore);
Assert.Equal(webAppProjectFile.FullName, item.ProjectFile.FullName);
}
);
var updatedConfig = await File.ReadAllTextAsync(aspireConfigFile.FullName);
using var configDoc = JsonDocument.Parse(updatedConfig);
Assert.Equal("daily", configDoc.RootElement.GetProperty("channel").GetString());
}
[Fact]
public async Task UpdateProjectFileAsync_PackageNotInChannel_LogsWarningAndContinues()
{
using var workspace = TemporaryWorkspace.CreateForCli(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 packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource, bool NoRestore)>();
var testSink = new TestSink();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>();
var matchedPackage = 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" },
// Third-party or otherwise unpublished Aspire.Hosting.* package - returns nothing.
"Aspire.Hosting.ThirdParty" => null,
_ => throw new InvalidOperationException($"Unexpected package query: {query}."),
};
if (matchedPackage is not null)
{
packages.Add(matchedPackage);
}
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.WithPackageReference("Aspire.Hosting.ThirdParty", "1.0.0");
}
else
{
throw new InvalidOperationException("Unexpected project file.");
}
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
},
AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) =>
{
packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!, noRestore));
return 0;
}
};
};
config.InteractionServiceFactory = (s) =>
{
var interactionService = new TestInteractionService();
return interactionService;
};
});
services.AddLogging(logging => logging.AddProvider(new TestLoggerProvider(testSink)));
using var provider = services.BuildServiceProvider();
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "daily");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
// Should NOT throw even though Aspire.Hosting.ThirdParty is not in the channel.
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
// Aspire.Hosting.Redis should still be updated; Aspire.Hosting.ThirdParty should be skipped with a warning.
// Aspire.Hosting.AppHost is not updated because it's removed during SDK migration.
Assert.Collection(
packagesAddsExecuted,
item =>
{
Assert.Equal("Aspire.Hosting.Redis", item.PackageId);
Assert.Equal("9.5.0-preview.1", item.PackageVersion);
Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
}
);
// A warning should have been logged for the missing package.
Assert.Contains(
testSink.Writes,
w => w.LogLevel == LogLevel.Warning
&& w.Message is not null
&& w.Message.Contains("Aspire.Hosting.ThirdParty")
&& w.Message.Contains("daily"));
}
[Fact]
public async Task UpdateProjectFileAsync_DiamondDependency_DoesNotDuplicateUpdates()
{
using var workspace = TemporaryWorkspace.CreateForCli(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, bool NoRestore)>();
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, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
if (projectFile.FullName == appHostProjectFile.FullName)
{
// AppHost references both ProjectA and ProjectB
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "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("Aspire.Hosting.Redis", "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, noRestore, _, _) =>
{
packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!, noRestore));
return 0;
}
};
};
config.InteractionServiceFactory = (s) =>
{
var interactionService = new TestInteractionService();
return interactionService;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).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("Aspire.Hosting.Redis", 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.Redis", appHostUpdates.Single().PackageId);
}
[Fact]
public async Task UpdateProjectFileAsync_CentralPackageManagement_UpdatesDirectoryPackagesProps()
{
using var workspace = TemporaryWorkspace.CreateForCli(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 TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).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.CreateForCli(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 TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).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.CreateForCli(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 TestInteractionService();
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;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).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);
}
[Fact]
public async Task UpdateProjectFileAsync_CpmOrphanAppHostPackageVersion_RemovedEvenWhenSdkAlreadyCurrent()
{
// Reproduces https://github.com/microsoft/aspire/issues/15476.
//
// PR #14585 fixed the migration path (old <Sdk Name="..."/> element ->
// inline <Project Sdk="Aspire.AppHost.Sdk/x"> attribute) by removing any
// orphan PackageVersion entry for Aspire.Hosting.AppHost from
// Directory.Packages.props as part of the SDK update step.
//
// But that cleanup only runs when AnalyzeAppHostSdkAsync decides the SDK
// version itself needs to change. When the AppHost is already on the
// latest stable SDK using the new format, the analyzer early-returns and
// the orphan PackageVersion is left in place - which then breaks
// dotnet restore with NU1009 because the new SDK implicitly defines the
// matching PackageReference.
//
// This test pins the SDK version to the same value the search returns,
// so AnalyzeAppHostSdkAsync sees "App Host SDK is up to date" and skips
// the SDK update step. The updater must still detect the orphan
// PackageVersion and remove it.
using var workspace = TemporaryWorkspace.CreateForCli(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"));
// AppHost is already on the new SDK format pinned to the latest stable
// version (matches what SearchPackagesAsyncCallback returns below).
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
"""
<Project Sdk="Aspire.AppHost.Sdk/9.5.0">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<IsAspireHost>true</IsAspireHost>
</PropertyGroup>
</Project>
""");
// Directory.Packages.props with CPM enabled and an orphan PackageVersion
// entry for Aspire.Hosting.AppHost. NuGet rejects this with NU1009 when
// the new SDK implicitly adds the matching PackageReference.
await File.WriteAllTextAsync(
directoryPackagesPropsFile.FullName,
"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.AppHost" Version="9.5.0" />
</ItemGroup>
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>
{
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" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
}
};
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
if (projectFile.FullName == appHostProjectFile.FullName)
{
// SDK version already matches latest -> AnalyzeAppHostSdkAsync
// will log "App Host SDK is up to date" and skip the SDK
// update step.
itemsAndProperties.WithSdkVersion("9.5.0");
}
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 TestInteractionService();
interactionService.ConfirmCallback = (_, _) => true;
return interactionService;
};
});
using var provider = services.BuildServiceProvider();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
// The orphan PackageVersion for Aspire.Hosting.AppHost must be removed
// even when the SDK version itself was already current.
var updatedContent = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedContent);
}
[Fact]
public async Task UpdateProjectFileAsync_StaleAppHostPackageReference_RemovedEvenWhenSdkAlreadyCurrent()
{
// Companion to UpdateProjectFileAsync_CpmOrphanAppHostPackageVersion_RemovedEvenWhenSdkAlreadyCurrent.
// The new SDK implicitly defines Aspire.Hosting.AppHost, so an explicit
// PackageReference is now redundant and must be removed even when no
// SDK version bump is required. No CPM is involved here.
using var workspace = TemporaryWorkspace.CreateForCli(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="Aspire.AppHost.Sdk/9.5.0">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<IsAspireHost>true</IsAspireHost>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.0" />
</ItemGroup>
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>
{
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" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
}
};
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("9.5.0");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (_, _) => true;
return interactionService;
};
});
using var provider = services.BuildServiceProvider();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
var updatedContent = await File.ReadAllTextAsync(appHostProjectFile.FullName);
Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedContent);
}
[Fact]
public async Task UpdateProjectFileAsync_NoLegacyAppHostReferences_NoChangesWhenSdkAlreadyCurrent()
{
// Negative case: a clean, fully-migrated AppHost on the latest SDK with
// no orphan references must not be touched. Guards against the new
// cleanup path enqueuing spurious update steps.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
const string ProjectContent = """
<Project Sdk="Aspire.AppHost.Sdk/9.5.0">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<IsAspireHost>true</IsAspireHost>
</PropertyGroup>
</Project>
""";
await File.WriteAllTextAsync(appHostProjectFile.FullName, ProjectContent);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>
{
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, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("9.5.0");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (_, _) => true;
return interactionService;
};
});
using var provider = services.BuildServiceProvider();
var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
var unchangedContent = await File.ReadAllTextAsync(appHostProjectFile.FullName);
Assert.Equal(ProjectContent, unchangedContent);
}
[Fact]
public async Task UpdateProjectFileAsync_LegacyAppHostReferencesWithMixedCasing_StillRemoved()
{
// NuGet package IDs are case-insensitive, so `aspire.hosting.apphost` and
// `Aspire.Hosting.AppHost` refer to the same package. The cleanup XPaths
// must match the @Include attribute case-insensitively or differently
// cased entries authored by users (or generated by external tooling)
// will silently survive the cleanup and break the next restore with
// NU1009.
using var workspace = TemporaryWorkspace.CreateForCli(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"));
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
"""
<Project Sdk="Aspire.AppHost.Sdk/9.5.0">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<IsAspireHost>true</IsAspireHost>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="aspire.hosting.apphost" />
</ItemGroup>
</Project>
""");
await File.WriteAllTextAsync(
directoryPackagesPropsFile.FullName,
"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="ASPIRE.HOSTING.APPHOST" Version="9.5.0" />
</ItemGroup>
</Project>
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>
{
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" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
}
};
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("9.5.0");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (_, _) => true;
return interactionService;
};
});
using var provider = services.BuildServiceProvider();
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
var updatedProject = await File.ReadAllTextAsync(appHostProjectFile.FullName);
var updatedProps = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
Assert.DoesNotContain("<PackageReference", updatedProject, StringComparison.Ordinal);
Assert.DoesNotContain("<PackageVersion", updatedProps, StringComparison.Ordinal);
}
[Fact]
public async Task UpdateProjectAsync_StableChannel_DoesNotCreateNuGetConfigWhenNoneExists()
{
// When updating to the stable channel (maps Aspire* to nuget.org, so no config is
// needed) and no project-local nuget.config exists, the updater should NOT create one.
// See: https://github.com/microsoft/aspire/issues/18124
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (appHostProjectFile, _) = await SetupNuGetConfigTestProject(workspace);
var services = CreateNuGetConfigTestServices(workspace, "9.5.0", "nuget.org");
using var provider = services.BuildServiceProvider();
var channels = await provider.GetRequiredService<IPackagingService>().GetChannelsAsync().DefaultTimeout();
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, channels.Single(c => c.Name == "stable"))).DefaultTimeout();
Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config")));
Assert.False(File.Exists(Path.Combine(appHostProjectFile.DirectoryName!, "nuget.config")));
}
[Fact]
public async Task UpdateProjectAsync_StableChannel_UpdatesExistingNuGetConfig()
{
// When updating to the stable channel and a project-local nuget.config already
// exists (e.g. from a previous daily channel), the updater should update it to
// clean up old feeds.
// See: https://github.com/microsoft/aspire/issues/18124
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (appHostProjectFile, _) = await SetupNuGetConfigTestProject(workspace);
// Pre-existing nuget.config from a previous daily channel
var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
await File.WriteAllTextAsync(nugetConfigPath,
"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="aspire-daily" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="aspire-daily">
<package pattern="Aspire*" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
""");
var services = CreateNuGetConfigTestServices(workspace, "9.5.0", "nuget.org", getNuGetConfigPathsCallback: (_, _, _) =>
{
return (0, new[]
{
nugetConfigPath,
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "NuGet", "NuGet.Config")
});
});
using var provider = services.BuildServiceProvider();
var channels = await provider.GetRequiredService<IPackagingService>().GetChannelsAsync().DefaultTimeout();
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, channels.Single(c => c.Name == "stable"))).DefaultTimeout();
Assert.True(File.Exists(nugetConfigPath));
var updatedConfig = await File.ReadAllTextAsync(nugetConfigPath);
Assert.Contains("https://api.nuget.org/v3/index.json", updatedConfig);
}
[Fact]
public async Task UpdateProjectAsync_DailyChannel_CreatesNuGetConfigWhenNoneExists()
{
// Contrast: when updating to the daily channel (routes Aspire* to a custom feed),
// the updater should create a nuget.config if none exists.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (appHostProjectFile, _) = await SetupNuGetConfigTestProject(workspace);
var services = CreateNuGetConfigTestServices(workspace, "9.5.0-preview.1", "daily");
using var provider = services.BuildServiceProvider();
var channels = await provider.GetRequiredService<IPackagingService>().GetChannelsAsync().DefaultTimeout();
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, channels.Single(c => c.Name == "daily"))).DefaultTimeout();
Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config")));
}
private static async Task<(FileInfo AppHostProjectFile, DirectoryInfo AppHostFolder)> SetupNuGetConfigTestProject(TemporaryWorkspace workspace)
{
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>
""");
return (appHostProjectFile, appHostFolder);
}
private IServiceCollection CreateNuGetConfigTestServices(
TemporaryWorkspace workspace,
string targetVersion,
string source,
Func<DirectoryInfo, ProcessInvocationOptions, CancellationToken, (int, string[])>? getNuGetConfigPathsCallback = null)
{
return CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>
{
query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = targetVersion, Source = source },
"Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = targetVersion, Source = source },
_ => throw new InvalidOperationException("Unexpected package query."),
}
};
return (0, packages.ToArray());
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
},
AddPackageAsyncCallback = (_, _, _, _, _, _, _) => 0
};
if (getNuGetConfigPathsCallback is not null)
{
runner.GetNuGetConfigPathsAsyncCallback = getNuGetConfigPathsCallback;
}
return runner;
};
config.InteractionServiceFactory = (s) => new TestInteractionService();
});
}
private static UpdatePackagesContext CreateUpdateContext(FileInfo appHostFile, PackageChannel channel) =>
new()
{
AppHostFile = appHostFile,
Channel = channel,
ConfirmBinding = PromptBinding.CreateDefault(true),
NuGetConfigDirBinding = PromptBinding.CreateDefault<string?>(null),
};
private static Aspire.Cli.CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory)
{
return TestExecutionContextHelper.CreateExecutionContext(workingDirectory);
}
[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 void ProjectConfigUpdateStep_GetFormattedDisplayText_ReturnsFormattedString_WithExistingValues()
{
var step = new ProjectConfigUpdateStep(
"Update aspire.config.json channel from 'pr-17452' to 'stable'",
() => Task.CompletedTask,
"pr-17452",
"stable",
"13.4.0",
"13.5.0");
Assert.Equal(
"[bold yellow]aspire.config.json#channel[/] [bold green]pr-17452[/] to [bold green]stable[/], [bold yellow]aspire.config.json#sdk.version[/] [bold green]13.4.0[/] to [bold green]13.5.0[/]",
step.GetFormattedDisplayText());
}
[Fact]
public void ProjectConfigUpdateStep_GetFormattedDisplayText_ReturnsFormattedString_WhenValuesAbsent()
{
var step = new ProjectConfigUpdateStep(
"Update aspire.config.json channel from '(none)' to 'stable'",
() => Task.CompletedTask,
CurrentChannel: null,
NewChannel: "stable",
CurrentSdkVersion: null,
NewSdkVersion: "13.5.0");
Assert.Equal(
"[bold yellow]aspire.config.json#channel[/] [grey](none)[/] to [bold green]stable[/], [bold yellow]aspire.config.json#sdk.version[/] [grey](unknown)[/] to [bold green]13.5.0[/]",
step.GetFormattedDisplayText());
}
[Fact]
public async Task UpdateProjectFileAsync_CentralPackageManagement_ResolvesAspireVersionProperty()
{
using var workspace = TemporaryWorkspace.CreateForCli(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 TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).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.CreateForCli(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 TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).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.CreateForCli(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 TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using 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().DefaultTimeout();
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(CreateUpdateContext(appHostProjectFile, selectedChannel)).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.CreateForCli(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 TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using 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().DefaultTimeout();
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(CreateUpdateContext(appHostProjectFile, selectedChannel)).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.CreateForCli(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, noRestore, options, cancellationToken) =>
{
// Simulate successful package addition
return 0;
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).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.CreateForCli(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 TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).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.CreateForCli(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 TestInteractionService();
return interactionService;
};
});
using 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().DefaultTimeout();
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(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout());
}
[Fact]
public async Task NormalMode_NoFallback()
{
using var workspace = TemporaryWorkspace.CreateForCli(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 TestInteractionService();
return interactionService;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
// Normal path unaffected - no updates needed since version is already current
Assert.False(updateResult.UpdatedApplied);
}
[Fact]
public async Task UpdateProjectFileAsync_SingleFileAppHost_UpdatesSdkDirectiveWithVersion()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostFolder.FullName, "apphost.cs"));
// Create a single-file app host with an explicit version using @ separator
await File.WriteAllTextAsync(
appHostFile.FullName,
"""
#:sdk Aspire.AppHost.Sdk@9.4.1
using Aspire.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
builder.Build().Run();
""");
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, items, properties, options, cancellationToken) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("9.4.1");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostFile, selectedChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
// Verify the SDK directive was updated from old to new version
var updatedContent = await File.ReadAllTextAsync(appHostFile.FullName);
Assert.Contains("#:sdk Aspire.AppHost.Sdk@9.5.0", updatedContent);
Assert.DoesNotContain("9.4.1", updatedContent);
}
[Fact]
public async Task UpdateProjectFileAsync_SingleFileAppHost_RemovesLegacyAppHostPackageDirectiveDuringSdkBump()
{
// Companion to the .csproj path covered by
// UpdateProjectFileAsync_StaleAppHostPackageReference_RemovedEvenWhenSdkAlreadyCurrent.
// Aspire.AppHost.Sdk pulls Aspire.Hosting.AppHost in implicitly, so an
// explicit `#:package Aspire.Hosting.AppHost@<version>` directive in a
// single-file AppHost is redundant. After a channel switch rewrites
// nuget.config to pin Aspire* to the channel feed, the deferred restore
// would otherwise hit NU1103 because the pinned channel does not carry
// the user's stable version.
// See https://github.com/dotnet/aspire/issues/15891.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostFolder.FullName, "apphost.cs"));
await File.WriteAllTextAsync(
appHostFile.FullName,
"""
#:sdk Aspire.AppHost.Sdk@9.4.1
#:package Aspire.Hosting.AppHost@9.4.1
#:package Aspire.Hosting.Redis@9.4.1
using Aspire.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
builder.AddRedis("redis");
builder.Build().Run();
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>
{
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, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("9.4.1");
// Surface the explicit Aspire.Hosting.AppHost / Aspire.Hosting.Redis
// directives the way MSBuild would for an apphost.cs file.
var items = new JsonObject
{
["PackageReference"] = new JsonArray
{
new JsonObject { ["Identity"] = "Aspire.Hosting.AppHost", ["Version"] = "9.4.1" },
new JsonObject { ["Identity"] = "Aspire.Hosting.Redis", ["Version"] = "9.4.1" },
},
};
itemsAndProperties["Items"] = items;
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
},
AddPackageAsyncCallback = (_, _, _, _, _, _, _) => 0,
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (_, _) => true;
return interactionService;
};
});
using var provider = services.BuildServiceProvider();
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostFile, selectedChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
var updatedContent = await File.ReadAllTextAsync(appHostFile.FullName);
Assert.Contains("#:sdk Aspire.AppHost.Sdk@9.5.0", updatedContent);
Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedContent);
// Aspire.Hosting.Redis directive is preserved (and bumped via dotnet package add,
// which is mocked here, so the source text still shows 9.4.1; the important
// assertion is that the directive line itself was not removed).
Assert.Contains("#:package Aspire.Hosting.Redis@", updatedContent);
}
[Fact]
public async Task UpdateProjectFileAsync_SingleFileAppHost_RemovesLegacyAppHostPackageDirectiveWhenSdkAlreadyCurrent()
{
// SDK is already on the latest version, so AnalyzeAppHostSdkAsync
// early-returns. The cleanup step path must still strip the redundant
// `#:package Aspire.Hosting.AppHost@<version>` directive so a re-run of
// `aspire update` after a partial migration recovers cleanly.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostFolder.FullName, "apphost.cs"));
await File.WriteAllTextAsync(
appHostFile.FullName,
"""
#:sdk Aspire.AppHost.Sdk@9.5.0
#:package Aspire.Hosting.AppHost@9.5.0
using Aspire.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
builder.Build().Run();
""");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
{
var packages = new List<NuGetPackageCli>
{
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, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("9.5.0");
var items = new JsonObject
{
["PackageReference"] = new JsonArray
{
new JsonObject { ["Identity"] = "Aspire.Hosting.AppHost", ["Version"] = "9.5.0" },
},
};
itemsAndProperties["Items"] = items;
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (_, _) => true;
return interactionService;
};
});
using var provider = services.BuildServiceProvider();
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostFile, selectedChannel)).DefaultTimeout();
var updatedContent = await File.ReadAllTextAsync(appHostFile.FullName);
Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedContent);
Assert.Contains("#:sdk Aspire.AppHost.Sdk@9.5.0", updatedContent);
}
[Fact]
public async Task UpdateProjectFileAsync_SingleFileAppHost_UpdatesSdkDirectiveWithWildcard()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostFile = new FileInfo(Path.Combine(appHostFolder.FullName, "apphost.cs"));
// Create a single-file app host with wildcard version
await File.WriteAllTextAsync(
appHostFile.FullName,
"""
#:sdk Aspire.AppHost.Sdk@*
using Aspire.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
builder.Build().Run();
""");
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, items, properties, options, cancellationToken) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("*");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostFile, selectedChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
// Verify the SDK directive was updated from wildcard to specific version
var updatedContent = await File.ReadAllTextAsync(appHostFile.FullName);
Assert.Contains("#:sdk Aspire.AppHost.Sdk@9.5.0", updatedContent);
}
[Fact]
public async Task UpdateProjectFileAsync_PackageReferenceWithWildcard_DoesNotFail()
{
using var workspace = TemporaryWorkspace.CreateForCli(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.5.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.5.0", Source = "nuget.org" },
"Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", 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");
// Add a package reference with wildcard version
itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "*");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
},
AddPackageAsyncCallback = (projectFile, packageName, version, source, noRestore, options, cancellationToken) =>
{
// Simulate successful package addition
return 0;
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
// This should not throw and should handle the * version gracefully
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
}
[Fact]
public async Task UpdateProjectFileAsync_AppHost_UpdatesSdkAttributeFormat()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
// Create AppHost project with SDK specified via Sdk attribute on Project element (new 13.0 format)
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
"""
<Project Sdk="Aspire.AppHost.Sdk/9.4.1">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</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" },
_ => 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");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using var provider = services.BuildServiceProvider();
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
// Verify the SDK attribute was updated
var updatedContent = await File.ReadAllTextAsync(appHostProjectFile.FullName);
Assert.Contains("Aspire.AppHost.Sdk/9.5.0", updatedContent);
Assert.DoesNotContain("9.4.1", updatedContent);
}
[Fact]
public async Task UpdateProjectFileAsync_AppHost_UpdatesSdkElementFormat()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
// Create AppHost project with SDK specified via Sdk element (old 9.5 format)
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</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" },
_ => 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");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using var provider = services.BuildServiceProvider();
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
// Verify the project was migrated to the new SDK format
var updatedContent = await File.ReadAllTextAsync(appHostProjectFile.FullName);
await Verify(updatedContent, extension: "xml");
}
[Fact]
public async Task UpdateProjectFileAsync_TreatsVersionRangeExpressionsLikeWildcard()
{
using var workspace = TemporaryWorkspace.CreateForCli(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="Aspire.AppHost.Sdk/9.4.1">
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Azure.Functions" Version="(9.4-*,9.5]" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.4.1" />
</ItemGroup>
</Project>
""");
var packagesUpdated = 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.Azure.Functions" => new NuGetPackageCli { Id = "Aspire.Hosting.Azure.Functions", 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");
// Package with version range expression - should be treated like wildcard and updated
itemsAndProperties.WithPackageReference("Aspire.Hosting.Azure.Functions", "(9.4-*,9.5]");
// Normal package - should be updated
itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
},
AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
{
// Track which packages are updated
packagesUpdated.Add(packageName);
Assert.Equal("9.5.0", packageVersion);
return 0;
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using var provider = services.BuildServiceProvider();
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
// Both packages should be updated - range expression is treated like wildcard
Assert.True(updateResult.UpdatedApplied);
Assert.Contains("Aspire.Hosting.Azure.Functions", packagesUpdated);
Assert.Contains("Aspire.Hosting.Redis", packagesUpdated);
}
[Theory]
[InlineData("#:sdk Aspire.AppHost.Sdk@9.5.2", true)]
[InlineData("#:sdk Aspire.AppHost.Sdk@*", true)]
[InlineData("#:sdk Aspire.AppHost.Sdk@10.0.0-preview.1", true)]
[InlineData("#:sdk Aspire.AppHost.Sdk", false)]
[InlineData("#:sdk Aspire.AppHost.Sdk@", false)]
[InlineData("#:sdk Aspire.AppHost.Sdk 9.5.2", false)]
[InlineData("#:package Aspire.Hosting.Redis@9.5.2", false)]
[InlineData("#:package Aspire.Hosting.Redis", false)]
public void SdkDirectiveRegex_MatchesValidDirectivesOnly(string directive, bool shouldMatch)
{
var regex = ProjectUpdater.SdkDirectiveRegex();
var match = regex.IsMatch(directive);
Assert.Equal(shouldMatch, match);
}
[Theory]
[InlineData("#:package Aspire.Hosting.AppHost@13.2.1", true)]
[InlineData(" #:package Aspire.Hosting.AppHost@13.2.1", true)]
[InlineData("#:package aspire.hosting.apphost@13.2.1", true)]
[InlineData("#:package Aspire.Hosting.AppHost@*", true)]
[InlineData("#:package Aspire.Hosting.AppHost@10.0.0-preview.1.26264.14", true)]
[InlineData("#:package Aspire.Hosting.Redis@13.2.1", false)]
[InlineData("#:sdk Aspire.AppHost.Sdk@13.2.1", false)]
[InlineData("// #:package Aspire.Hosting.AppHost@13.2.1", false)]
[InlineData("#:package Aspire.Hosting.AppHost", false)]
[InlineData("#:package Aspire.Hosting.AppHost@", false)]
public void LegacyAppHostPackageDirectiveRegex_MatchesOnlyAppHostDirective(string line, bool shouldMatch)
{
var regex = ProjectUpdater.LegacyAppHostPackageDirectiveRegex();
Assert.Equal(shouldMatch, regex.IsMatch(line));
}
[Fact]
public async Task UpdateProjectFileAsync_ManagePackageVersionsCentrallyFalse_UpdatesLocalProjectFile()
{
using var workspace = TemporaryWorkspace.CreateForCli(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 ManagePackageVersionsCentrally set to false
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.2" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.2" />
</ItemGroup>
</Project>
""");
// Create Directory.Packages.props (which should be ignored due to ManagePackageVersionsCentrally=false)
await File.WriteAllTextAsync(
directoryPackagesPropsFile.FullName,
"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
</ItemGroup>
</Project>
""");
var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource, bool NoRestore)>();
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.6.0", Source = "nuget.org" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.6.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.2");
// Set ManagePackageVersionsCentrally to false
itemsAndProperties.WithProperty("ManagePackageVersionsCentrally", "false");
// Package has version in the project file (not in Directory.Packages.props)
itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.5.2");
var json = itemsAndProperties.ToJsonString();
var document = JsonDocument.Parse(json);
return (0, document);
},
AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) =>
{
packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!, noRestore));
return 0;
}
};
};
config.InteractionServiceFactory = (sp) =>
{
var interactionService = new TestInteractionService();
interactionService.ConfirmCallback = (promptText, defaultValue) =>
{
return true;
};
return interactionService;
};
});
using 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().DefaultTimeout();
var selectedChannel = channels.Single(c => c.Name == "default");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
// Verify that the package was updated via dotnet add package (traditional management),
// not by updating Directory.Packages.props
Assert.Single(packagesAddsExecuted);
var packageUpdate = packagesAddsExecuted.Single();
Assert.Equal("Aspire.Hosting.Redis", packageUpdate.PackageId);
Assert.Equal("9.6.0", packageUpdate.PackageVersion);
Assert.Equal(appHostProjectFile.FullName, packageUpdate.ProjectFile.FullName);
// Verify Directory.Packages.props was NOT modified (should still be empty)
var directoryPackagesContent = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
Assert.DoesNotContain("Aspire.Hosting.Redis", directoryPackagesContent);
}
[Theory]
[InlineData(".csproj")]
[InlineData(".fsproj")]
[InlineData(".vbproj")]
public async Task UpdateSdkVersionInCsprojAppHostAsync_MigratesFromOldFormatToNewFormat(string projectExtension)
{
// Arrange - tests migration from old <Sdk Name="..."> to new <Project Sdk="..."> format
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
var originalContent = """
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
</Project>
""";
await File.WriteAllTextAsync(projectFile, originalContent);
var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
// Act
await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
// Assert
var updatedContent = await File.ReadAllTextAsync(projectFile);
await Verify(updatedContent, extension: "xml")
.UseFileName($"ProjectUpdaterTests.{nameof(UpdateSdkVersionInCsprojAppHostAsync_MigratesFromOldFormatToNewFormat)}")
.DisableRequireUniquePrefix();
}
[Theory]
[InlineData(".csproj")]
[InlineData(".fsproj")]
[InlineData(".vbproj")]
public async Task UpdateSdkVersionInCsprojAppHostAsync_UpdatesExistingNewFormat(string projectExtension)
{
// Arrange - tests updating a project already using the new format
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
var originalContent = """
<Project Sdk="Aspire.AppHost.Sdk/13.0.1">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
""";
await File.WriteAllTextAsync(projectFile, originalContent);
var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
// Act
await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
// Assert
var updatedContent = await File.ReadAllTextAsync(projectFile);
await Verify(updatedContent, extension: "xml")
.UseFileName($"ProjectUpdaterTests.{nameof(UpdateSdkVersionInCsprojAppHostAsync_UpdatesExistingNewFormat)}")
.DisableRequireUniquePrefix();
}
[Theory]
[InlineData(".csproj")]
[InlineData(".fsproj")]
[InlineData(".vbproj")]
public async Task UpdateSdkVersionInCsprojAppHostAsync_RemovesAspireHostingAppHostPackageReference(string projectExtension)
{
// Arrange - tests removal of obsolete Aspire.Hosting.AppHost package reference
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
var originalContent = """
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.0" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.0" />
</ItemGroup>
</Project>
""";
await File.WriteAllTextAsync(projectFile, originalContent);
var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
// Act
await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
// Assert
var updatedContent = await File.ReadAllTextAsync(projectFile);
await Verify(updatedContent, extension: "xml")
.UseFileName($"ProjectUpdaterTests.{nameof(UpdateSdkVersionInCsprojAppHostAsync_RemovesAspireHostingAppHostPackageReference)}")
.DisableRequireUniquePrefix();
}
[Theory]
[InlineData(".csproj")]
[InlineData(".fsproj")]
[InlineData(".vbproj")]
public async Task UpdateSdkVersionInCsprojAppHostAsync_RemovesEmptyItemGroupAfterPackageRemoval(string projectExtension)
{
// Arrange - tests that empty ItemGroup is removed after Aspire.Hosting.AppHost removal
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
var originalContent = """
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.0" />
</ItemGroup>
</Project>
""";
await File.WriteAllTextAsync(projectFile, originalContent);
var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
// Act
await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
// Assert
var updatedContent = await File.ReadAllTextAsync(projectFile);
await Verify(updatedContent, extension: "xml")
.UseFileName($"ProjectUpdaterTests.{nameof(UpdateSdkVersionInCsprojAppHostAsync_RemovesEmptyItemGroupAfterPackageRemoval)}")
.DisableRequireUniquePrefix();
}
[Theory]
[InlineData(".csproj")]
[InlineData(".fsproj")]
[InlineData(".vbproj")]
public async Task UpdateSdkVersionInCsprojAppHostAsync_PreservesOtherSdksInAttribute(string projectExtension)
{
// Arrange - tests that other SDKs in the attribute are preserved
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
var originalContent = """
<Project Sdk="Aspire.AppHost.Sdk/13.0.1;Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
""";
await File.WriteAllTextAsync(projectFile, originalContent);
var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
// Act
await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
// Assert
var updatedContent = await File.ReadAllTextAsync(projectFile);
await Verify(updatedContent, extension: "xml")
.UseFileName($"ProjectUpdaterTests.{nameof(UpdateSdkVersionInCsprojAppHostAsync_PreservesOtherSdksInAttribute)}")
.DisableRequireUniquePrefix();
}
[Theory]
[InlineData(".csproj")]
[InlineData(".fsproj")]
[InlineData(".vbproj")]
public async Task UpdateSdkVersionInCsprojAppHostAsync_DoesNotMatchSimilarSdkName(string projectExtension)
{
// Arrange - tests that Aspire.AppHost.SdkFoo doesn't match as the Aspire SDK
// In this case, the old <Sdk Name="..."> element is used, so it's a migration scenario
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
var originalContent = """
<Project Sdk="Aspire.AppHost.SdkFoo/1.0.0">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
</Project>
""";
await File.WriteAllTextAsync(projectFile, originalContent);
var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
// Act
await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
// Assert
var updatedContent = await File.ReadAllTextAsync(projectFile);
await Verify(updatedContent, extension: "xml")
.UseFileName($"ProjectUpdaterTests.{nameof(UpdateSdkVersionInCsprojAppHostAsync_DoesNotMatchSimilarSdkName)}")
.DisableRequireUniquePrefix();
}
[Theory]
[InlineData(".csproj")]
[InlineData(".fsproj")]
[InlineData(".vbproj")]
public async Task UpdateSdkVersionInCsprojAppHostAsync_RemovesPackageVersionFromDirectoryPackagesPropsForCpm(string projectExtension)
{
// Arrange - simulates a CPM project with an old-format AppHost that has
// Aspire.Hosting.AppHost in both csproj (as PackageReference) and
// Directory.Packages.props (as PackageVersion). After SDK migration, the
// PackageReference is removed from csproj but the orphaned PackageVersion
// must also be removed to avoid NU1009.
// See: https://github.com/microsoft/aspire/issues/14550
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
var originalContent = """
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" />
<PackageReference Include="Aspire.Hosting.Redis" />
</ItemGroup>
</Project>
""";
await File.WriteAllTextAsync(projectFile, originalContent);
// Create Directory.Packages.props with CPM enabled and PackageVersion for Aspire.Hosting.AppHost
var directoryPackagesPropsFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props");
await File.WriteAllTextAsync(directoryPackagesPropsFile, """
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.AppHost" Version="9.5.0" />
<PackageVersion Include="Aspire.Hosting.Redis" Version="9.5.0" />
</ItemGroup>
</Project>
""");
var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
// Act
await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
// Assert - PackageVersion for Aspire.Hosting.AppHost should be removed from Directory.Packages.props
var updatedPropsContent = await File.ReadAllTextAsync(directoryPackagesPropsFile);
Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedPropsContent);
// Other PackageVersion entries should be preserved
Assert.Contains("Aspire.Hosting.Redis", updatedPropsContent);
// Also verify the csproj was updated correctly
var updatedCsprojContent = await File.ReadAllTextAsync(projectFile);
Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedCsprojContent);
Assert.Contains("Aspire.Hosting.Redis", updatedCsprojContent);
Assert.Contains("Aspire.AppHost.Sdk/13.0.2", updatedCsprojContent);
}
// Regression guard for https://github.com/dotnet/aspire/issues/15891.
//
// Repro shape: project on a stable channel referencing two or more Aspire integration
// packages whose stable versions are not carried by the daily-style Explicit channel
// being switched to. The bug was that ProjectUpdater rewrote nuget.config (adding a
// packageSourceMapping pinning Aspire* to the daily feed) and then ran 'dotnet package add'
// for each package one at a time *with restore enabled*. The first restore happened against
// a half-updated reference graph: the just-bumped package existed in the daily feed but the
// others were still on stable versions that didn't, and the new mapping blocked fallback
// to nuget.org -> NU1103.
//
// The fix passes --no-restore to per-package adds and runs a single deferred restore at the
// end. This test pins both halves of that contract:
// 1. every per-package add was invoked with noRestore=true, and
// 2. exactly one restore was invoked, and
// 3. the restore came after all the package adds (not interleaved with them).
[Fact]
public async Task UpdateProjectFileAsync_AppliesAllPackageEditsBeforeFinalRestore()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostFolder = workspace.CreateDirectory("Issue15891.AppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "Issue15891.AppHost.csproj"));
await File.WriteAllTextAsync(
appHostProjectFile.FullName,
"""
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
</Project>
""");
// Use a single shared list so we can assert that all 'add's happened before the 'restore'.
// Recording entries as strings keeps the assertion readable when it fails.
var calls = new List<string>();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
{
config.DotNetCliRunnerFactory = (sp) =>
{
return new TestDotNetCliRunner()
{
SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
{
var package = 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.Hosting.PostgreSQL" => new NuGetPackageCli { Id = "Aspire.Hosting.PostgreSQL", Version = "9.5.0-preview.1", Source = "daily" },
"Aspire.Hosting.Kafka" => new NuGetPackageCli { Id = "Aspire.Hosting.Kafka", Version = "9.5.0-preview.1", Source = "daily" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
};
return (0, new[] { package });
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
if (projectFile.FullName == appHostProjectFile.FullName)
{
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
// Three integration packages on the prior stable version - this is the
// shape that fails in the original issue: the first per-package restore
// would resolve the new daily Redis but reject the still-stable PostgreSQL
// and Kafka because the new packageSourceMapping prevents fallback to
// nuget.org for Aspire*.
itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.PostgreSQL", "9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.Kafka", "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, noRestore, _, _) =>
{
calls.Add($"add:{packageId}@{packageVersion}:noRestore={noRestore}");
return 0;
},
RestoreAsyncCallback = (projectFilePath, _, _) =>
{
calls.Add($"restore:{projectFilePath.Name}");
return 0;
},
};
};
config.InteractionServiceFactory = (s) => new TestInteractionService();
});
using var provider = services.BuildServiceProvider();
var packagingService = provider.GetRequiredService<IPackagingService>();
// 'daily' is an Explicit channel (see PackagingService.CreateExplicitChannel), which is
// the trigger condition for the NuGetConfigMerger path that surfaces the original bug.
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var dailyChannel = channels.Single(c => c.Name == "daily");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, dailyChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
// Every 'dotnet package add' must have been invoked with --no-restore so that NuGet
// never sees a half-updated reference graph against the new packageSourceMapping.
var addCalls = calls.Where(c => c.StartsWith("add:", StringComparison.Ordinal)).ToList();
Assert.NotEmpty(addCalls);
Assert.All(addCalls, c => Assert.EndsWith(":noRestore=True", c));
// Exactly one restore should have been invoked - the deferred final restore.
var restoreCalls = calls.Where(c => c.StartsWith("restore:", StringComparison.Ordinal)).ToList();
var restore = Assert.Single(restoreCalls);
Assert.Equal($"restore:{appHostProjectFile.Name}", restore);
// The restore must come after every package add. If anyone re-enables per-package
// restore (or moves the final restore back into / before the loop) this fails.
Assert.Equal(calls.Count - 1, calls.IndexOf(restore));
}
[Fact]
public async Task UpdateProjectFileAsync_StableChannel_DoesNotCreateNuGetConfigWhenNoneExists()
{
using var workspace = TemporaryWorkspace.CreateForCli(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 package = query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.4.3", Source = "stable" },
"Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "13.4.3", Source = "stable" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "13.4.3", Source = "stable" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
};
return (0, new[] { package });
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
var document = JsonDocument.Parse(itemsAndProperties.ToJsonString());
return (0, document);
},
AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) => 0,
};
};
config.InteractionServiceFactory = (s) => new TestInteractionService();
});
using var provider = services.BuildServiceProvider();
var packagingService = provider.GetRequiredService<IPackagingService>();
// The stable channel is Explicit but maps Aspire* only to nuget.org (the ambient source),
// so ShouldCreateNuGetConfig() is false and no project-level config is required.
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stableChannel = channels.Single(c => c.Name == "stable");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, stableChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
// Regression guard for https://github.com/microsoft/aspire/issues/18124: updating to the
// stable channel must NOT drop a redundant <clear/>-based NuGet.config. None existed, so
// none should have been created anywhere under the workspace.
var nugetConfigs = workspace.WorkspaceRoot
.EnumerateFiles("*", SearchOption.AllDirectories)
.Where(f => string.Equals(f.Name, "nuget.config", StringComparison.OrdinalIgnoreCase))
.ToList();
Assert.Empty(nugetConfigs);
}
[Fact]
public async Task UpdateProjectFileAsync_StableChannel_UpdatesExistingNuGetConfig()
{
using var workspace = TemporaryWorkspace.CreateForCli(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 package = query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.4.3", Source = "stable" },
"Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "13.4.3", Source = "stable" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "13.4.3", Source = "stable" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
};
return (0, new[] { package });
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
var document = JsonDocument.Parse(itemsAndProperties.ToJsonString());
return (0, document);
},
AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) => 0,
};
};
config.InteractionServiceFactory = (s) => new TestInteractionService();
});
using var provider = services.BuildServiceProvider();
// The update path writes/refreshes the config in the recommended directory, which falls
// back to the CLI working directory when only global configs are discovered (the default
// TestDotNetCliRunner behavior). Seed an existing config there with a private feed and no
// packageSourceMapping so the merge is a real change.
var executionContext = provider.GetRequiredService<Aspire.Cli.CliExecutionContext>();
var existingConfigFile = new FileInfo(Path.Combine(executionContext.WorkingDirectory.FullName, "nuget.config"));
await File.WriteAllTextAsync(
existingConfigFile.FullName,
"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="contoso" value="https://contoso.example/feed/v3/index.json" />
</packageSources>
</configuration>
""");
var packagingService = provider.GetRequiredService<IPackagingService>();
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stableChannel = channels.Single(c => c.Name == "stable");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, stableChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
// Even though the stable channel never *creates* a config, an existing one is still
// refreshed so feeds left over from a previous channel get cleaned up (#18124). The merge
// preserves the user's feed and adds a packageSourceMapping that wasn't there before.
Assert.True(existingConfigFile.Exists);
var updatedContent = await File.ReadAllTextAsync(existingConfigFile.FullName);
Assert.Contains("contoso", updatedContent, StringComparison.Ordinal);
Assert.Contains("packageSourceMapping", updatedContent, StringComparison.Ordinal);
}
[Fact]
public async Task UpdateProjectFileAsync_DailyChannel_CreatesNuGetConfigWhenNoneExists()
{
using var workspace = TemporaryWorkspace.CreateForCli(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 package = query switch
{
"Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.5.0-preview.1", Source = "daily" },
"Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "13.5.0-preview.1", Source = "daily" },
"Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "13.5.0-preview.1", Source = "daily" },
_ => throw new InvalidOperationException($"Unexpected package query: {query}"),
};
return (0, new[] { package });
},
GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
{
var itemsAndProperties = new JsonObject();
itemsAndProperties.WithSdkVersion("9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
var document = JsonDocument.Parse(itemsAndProperties.ToJsonString());
return (0, document);
},
AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) => 0,
};
};
config.InteractionServiceFactory = (s) => new TestInteractionService();
});
using var provider = services.BuildServiceProvider();
var packagingService = provider.GetRequiredService<IPackagingService>();
// Contrast with the stable channel: daily maps Aspire* to a custom feed, so
// ShouldCreateNuGetConfig() is true and a project-level config must be created.
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var dailyChannel = channels.Single(c => c.Name == "daily");
var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, dailyChannel)).DefaultTimeout();
Assert.True(updateResult.UpdatedApplied);
var nugetConfigs = workspace.WorkspaceRoot
.EnumerateFiles("*", SearchOption.AllDirectories)
.Where(f => string.Equals(f.Name, "nuget.config", StringComparison.OrdinalIgnoreCase))
.ToList();
var createdConfig = Assert.Single(nugetConfigs);
var content = await File.ReadAllTextAsync(createdConfig.FullName);
Assert.Contains("packageSourceMapping", content, StringComparison.Ordinal);
}
}
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;
}
}